niranzwp 0.1.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/LICENSE +21 -0
- package/README.md +74 -0
- package/bin/niranzwp.js +180 -0
- package/lib/auth.js +94 -0
- package/lib/store.js +122 -0
- package/lib/wp.js +149 -0
- package/package.json +19 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Niranjan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# NiranzWP CLI
|
|
2
|
+
|
|
3
|
+
A CLI for WordPress.
|
|
4
|
+
|
|
5
|
+
Works on **any** WordPress site through core Application Passwords — no plugin
|
|
6
|
+
required. Where a site also exposes the WordPress Abilities API (core, 6.9+),
|
|
7
|
+
`niranzwp` unlocks those abilities too.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
niranzwp probe example.com # what does this site support? (no auth)
|
|
11
|
+
niranzwp auth login example.com # browser consent -> credential in Keychain
|
|
12
|
+
niranzwp post list --status draft
|
|
13
|
+
niranzwp discover # abilities, if the site exposes any
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Why
|
|
17
|
+
|
|
18
|
+
`wp-cli` is the standard WordPress CLI, and it needs **SSH access to the
|
|
19
|
+
server**. On shared hosting, on managed platforms, or across a portfolio of
|
|
20
|
+
client sites, you usually do not have that.
|
|
21
|
+
|
|
22
|
+
`niranzwp` needs a **URL and one browser click**.
|
|
23
|
+
|
|
24
|
+
## Two tiers
|
|
25
|
+
|
|
26
|
+
| | Requires | Gives you |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| **Tier 1** | nothing — core WordPress 5.6+ | posts, pages, media, users, taxonomies, comments, settings |
|
|
29
|
+
| **Tier 2** | an abilities provider on the site | whatever that site registers — PHP execution, filesystem, WP-CLI, … |
|
|
30
|
+
|
|
31
|
+
`niranzwp probe` reports which tiers a site offers before you connect.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
$ niranzwp probe uaestories.com
|
|
35
|
+
UAE Stories -- UAE's first people centric magazine
|
|
36
|
+
https://uaestories.com
|
|
37
|
+
Tier 1 (app passwords): yes
|
|
38
|
+
Tier 2 (abilities): yes
|
|
39
|
+
MCP endpoint: yes
|
|
40
|
+
OAuth server: yes
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Tier 2 is deliberately **not** tied to one plugin. Any provider that registers
|
|
44
|
+
abilities through the core API works — Novamira, your own plugin, anything else.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npm install -g niranzwp
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Requires Node 22+. No runtime dependencies.
|
|
53
|
+
|
|
54
|
+
## Authentication
|
|
55
|
+
|
|
56
|
+
`niranzwp auth login` opens WordPress's own
|
|
57
|
+
`/wp-admin/authorize-application.php` consent screen. You approve there, and
|
|
58
|
+
WordPress redirects back to a temporary loopback listener with the credential.
|
|
59
|
+
Nothing is typed or pasted, and no password crosses a third party.
|
|
60
|
+
|
|
61
|
+
Credentials go to the **macOS Keychain** where available, otherwise to a
|
|
62
|
+
`0600` file under `~/.config/niranzwp`. Profile metadata (site URL, username)
|
|
63
|
+
lives in `~/.config/niranzwp/profiles.json`; passwords never do.
|
|
64
|
+
|
|
65
|
+
`niranzwp auth logout <profile>` removes the local copy. Revoke the password
|
|
66
|
+
itself under **Users → Profile → Application Passwords** in WordPress.
|
|
67
|
+
|
|
68
|
+
## Status
|
|
69
|
+
|
|
70
|
+
Early. Tier 1 auth, `probe`, `post list`, `discover`, and `run` work today.
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
package/bin/niranzwp.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { loginWithAppPassword } from '../lib/auth.js';
|
|
3
|
+
import { saveProfile, getProfile, listProfiles, deleteProfile, storageKind, configDir } from '../lib/store.js';
|
|
4
|
+
import { normalizeSite, probe, whoami, listPosts, listAbilities, runAbility, WpError } from '../lib/wp.js';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
const USAGE = `niranzwp -- a CLI for WordPress
|
|
8
|
+
|
|
9
|
+
niranzwp auth login <url> [--name <profile>] [--no-open]
|
|
10
|
+
niranzwp auth status [--site <profile>]
|
|
11
|
+
niranzwp auth logout <profile>
|
|
12
|
+
|
|
13
|
+
niranzwp probe <url> what does this site support? (no auth)
|
|
14
|
+
niranzwp discover [--site <profile>] list abilities this site exposes
|
|
15
|
+
|
|
16
|
+
niranzwp post list [--status draft] [--search x] [--limit 10] [--page 1]
|
|
17
|
+
niranzwp run <ability> [--input '<json>' | --file <path>]
|
|
18
|
+
|
|
19
|
+
Options
|
|
20
|
+
--site <profile> which site to act on (default: the only one, or NIRANZWP_SITE)
|
|
21
|
+
--json raw JSON output
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
function parseArgs(argv) {
|
|
25
|
+
const args = { _: [], flags: {} };
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const a = argv[i];
|
|
28
|
+
if (a.startsWith('--')) {
|
|
29
|
+
const key = a.slice(2);
|
|
30
|
+
if (key.startsWith('no-')) { args.flags[key.slice(3)] = false; continue; }
|
|
31
|
+
const next = argv[i + 1];
|
|
32
|
+
if (next === undefined || next.startsWith('--')) { args.flags[key] = true; }
|
|
33
|
+
else { args.flags[key] = next; i++; }
|
|
34
|
+
} else {
|
|
35
|
+
args._.push(a);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return args;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function die(msg, code = 1) {
|
|
42
|
+
console.error(`error: ${msg}`);
|
|
43
|
+
process.exit(code);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveProfile(flags) {
|
|
47
|
+
const wanted = flags.site || process.env.NIRANZWP_SITE;
|
|
48
|
+
const all = listProfiles();
|
|
49
|
+
if (!all.length) die('no sites connected yet. Run: niranzwp auth login <url>');
|
|
50
|
+
|
|
51
|
+
const name = wanted || (all.length === 1 ? all[0].name : null);
|
|
52
|
+
if (!name) die(`multiple sites connected (${all.map((p) => p.name).join(', ')}). Pass --site <profile>.`);
|
|
53
|
+
|
|
54
|
+
const p = getProfile(name);
|
|
55
|
+
if (!p) die(`profile "${name}" has no stored credential. Run: niranzwp auth login <url> --name ${name}`);
|
|
56
|
+
return p;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const out = (flags, value, pretty) => {
|
|
60
|
+
if (flags.json || !pretty) console.log(JSON.stringify(value, null, 2));
|
|
61
|
+
else pretty(value);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
async function main() {
|
|
65
|
+
const { _: pos, flags } = parseArgs(process.argv.slice(2));
|
|
66
|
+
const [cmd, sub, ...rest] = pos;
|
|
67
|
+
|
|
68
|
+
if (!cmd || flags.help || cmd === 'help') { console.log(USAGE); return; }
|
|
69
|
+
|
|
70
|
+
if (cmd === 'auth' && sub === 'login') {
|
|
71
|
+
const url = rest[0];
|
|
72
|
+
if (!url) die('usage: niranzwp auth login <url>');
|
|
73
|
+
const site = normalizeSite(url);
|
|
74
|
+
const creds = await loginWithAppPassword(site, { open: flags.open !== false });
|
|
75
|
+
const name = flags.name || new URL(creds.siteUrl).hostname.replace(/^www\./, '');
|
|
76
|
+
saveProfile(name, creds);
|
|
77
|
+
console.log(`Connected "${name}" -> ${creds.siteUrl} as ${creds.user}`);
|
|
78
|
+
console.log(`Credential stored in ${storageKind()}.`);
|
|
79
|
+
console.log(creds.info.tier2
|
|
80
|
+
? 'This site exposes the Abilities API. Try: niranzwp discover'
|
|
81
|
+
: 'Tier 1 only (core REST). No abilities provider detected on this site.');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (cmd === 'auth' && sub === 'status') {
|
|
86
|
+
const all = listProfiles();
|
|
87
|
+
if (!all.length) { console.log('No sites connected.'); return; }
|
|
88
|
+
for (const p of all) {
|
|
89
|
+
const full = getProfile(p.name);
|
|
90
|
+
let who = 'credential missing';
|
|
91
|
+
if (full) {
|
|
92
|
+
try {
|
|
93
|
+
const me = await whoami(full);
|
|
94
|
+
who = `${me.name} (id ${me.id}, ${(me.roles || []).join(',') || 'no role'})`;
|
|
95
|
+
} catch (e) {
|
|
96
|
+
who = `unreachable: ${e.message}`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
console.log(`${p.name}\n ${p.siteUrl}\n ${who}`);
|
|
100
|
+
}
|
|
101
|
+
console.log(`\nStorage: ${storageKind()}\nConfig: ${configDir()}`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (cmd === 'auth' && sub === 'logout') {
|
|
106
|
+
const name = rest[0];
|
|
107
|
+
if (!name) die('usage: niranzwp auth logout <profile>');
|
|
108
|
+
deleteProfile(name);
|
|
109
|
+
console.log(`Removed "${name}" locally.`);
|
|
110
|
+
console.log('Revoke the password itself under Users -> Profile -> Application Passwords in WordPress.');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (cmd === 'probe') {
|
|
115
|
+
const url = sub;
|
|
116
|
+
if (!url) die('usage: niranzwp probe <url>');
|
|
117
|
+
const info = await probe(normalizeSite(url));
|
|
118
|
+
out(flags, info, (i) => {
|
|
119
|
+
console.log(`${i.name} -- ${i.description || ''}`);
|
|
120
|
+
console.log(` ${i.home}`);
|
|
121
|
+
console.log(` Tier 1 (app passwords): ${i.tier1 ? 'yes' : 'no'}`);
|
|
122
|
+
console.log(` Tier 2 (abilities): ${i.tier2 ? 'yes' : 'no'}`);
|
|
123
|
+
console.log(` MCP endpoint: ${i.mcp ? 'yes' : 'no'}`);
|
|
124
|
+
console.log(` OAuth server: ${i.oauth ? 'yes' : 'no'}`);
|
|
125
|
+
console.log(` namespaces: ${i.namespaces.length}`);
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (cmd === 'discover') {
|
|
131
|
+
const p = resolveProfile(flags);
|
|
132
|
+
const list = await listAbilities(p);
|
|
133
|
+
const items = Array.isArray(list) ? list : list?.abilities || [];
|
|
134
|
+
out(flags, items, (rows) => {
|
|
135
|
+
console.log(`${rows.length} abilities on ${p.siteUrl}`);
|
|
136
|
+
for (const a of rows) console.log(` ${a.name}${a.label ? ` -- ${a.label}` : ''}`);
|
|
137
|
+
});
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (cmd === 'post' && sub === 'list') {
|
|
142
|
+
const p = resolveProfile(flags);
|
|
143
|
+
const { data, headers } = await listPosts(p, {
|
|
144
|
+
status: flags.status || 'publish',
|
|
145
|
+
perPage: Number(flags.limit || 10),
|
|
146
|
+
page: Number(flags.page || 1),
|
|
147
|
+
search: flags.search === true ? undefined : flags.search,
|
|
148
|
+
});
|
|
149
|
+
out(flags, data, (rows) => {
|
|
150
|
+
const total = headers.get('x-wp-total');
|
|
151
|
+
console.log(`${rows.length} shown of ${total ?? '?'} (${flags.status || 'publish'})`);
|
|
152
|
+
for (const r of rows) {
|
|
153
|
+
console.log(` ${String(r.id).padEnd(8)}${r.date.slice(0, 10)} ${(r.title?.raw ?? r.title?.rendered ?? '').slice(0, 60)}`);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (cmd === 'run') {
|
|
160
|
+
const p = resolveProfile(flags);
|
|
161
|
+
const ability = sub;
|
|
162
|
+
if (!ability) die('usage: niranzwp run <ability> [--input <json>|--file <path>]');
|
|
163
|
+
let input = {};
|
|
164
|
+
if (flags.file) input = JSON.parse(readFileSync(flags.file, 'utf8'));
|
|
165
|
+
else if (typeof flags.input === 'string') input = JSON.parse(flags.input);
|
|
166
|
+
const result = await runAbility(p, ability, input);
|
|
167
|
+
console.log(JSON.stringify(result, null, 2));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
console.log(USAGE);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
main().catch((e) => {
|
|
176
|
+
if (e instanceof WpError) {
|
|
177
|
+
die(`${e.message}${e.code ? ` [${e.code}]` : ''}${e.status ? ` (HTTP ${e.status})` : ''}`);
|
|
178
|
+
}
|
|
179
|
+
die(e.message);
|
|
180
|
+
});
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { platform } from 'node:os';
|
|
4
|
+
import { probe } from './wp.js';
|
|
5
|
+
|
|
6
|
+
// WordPress core ships an Application Password authorization screen at
|
|
7
|
+
// /wp-admin/authorize-application.php. Given a success_url it redirects back
|
|
8
|
+
// with site_url, user_login and password in the query string. That gives us a
|
|
9
|
+
// browser consent flow on ANY WordPress since 5.6 -- no plugin required.
|
|
10
|
+
|
|
11
|
+
function openBrowser(url) {
|
|
12
|
+
const cmd = platform() === 'darwin' ? 'open'
|
|
13
|
+
: platform() === 'win32' ? 'cmd'
|
|
14
|
+
: 'xdg-open';
|
|
15
|
+
const args = platform() === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
16
|
+
execFile(cmd, args, () => { /* a failure just means the user opens it themselves */ });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function page(title, body) {
|
|
20
|
+
return `<!doctype html><meta charset="utf-8">
|
|
21
|
+
<title>${title}</title>
|
|
22
|
+
<style>
|
|
23
|
+
body{font:16px/1.6 -apple-system,system-ui,sans-serif;max-width:34rem;margin:12vh auto;padding:0 1.5rem;color:#111}
|
|
24
|
+
h1{font-size:1.3rem;margin:0 0 .5rem}
|
|
25
|
+
p{color:#555;margin:0}
|
|
26
|
+
code{background:#f4f4f5;padding:.15em .4em;border-radius:4px}
|
|
27
|
+
</style>
|
|
28
|
+
<h1>${title}</h1><p>${body}</p>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function loginWithAppPassword(siteUrl, { appName = 'NiranzWP CLI', open = true, timeoutMs = 300000 } = {}) {
|
|
32
|
+
const info = await probe(siteUrl);
|
|
33
|
+
|
|
34
|
+
if (!info.tier1 || !info.authorizeUrl) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'This site does not advertise Application Passwords. It needs WordPress 5.6+ over HTTPS, ' +
|
|
37
|
+
'or WP_ENVIRONMENT_TYPE set to "local". A security plugin may also be disabling them.'
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const server = createServer((req, res) => {
|
|
43
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
44
|
+
if (!url.pathname.startsWith('/callback')) {
|
|
45
|
+
res.writeHead(404).end();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const user = url.searchParams.get('user_login');
|
|
50
|
+
const password = url.searchParams.get('password');
|
|
51
|
+
const site = url.searchParams.get('site_url') || siteUrl;
|
|
52
|
+
|
|
53
|
+
if (!user || !password) {
|
|
54
|
+
res.writeHead(400, { 'Content-Type': 'text/html' })
|
|
55
|
+
.end(page('Authorization failed', 'WordPress did not return a credential. You can close this tab and try again.'));
|
|
56
|
+
cleanup();
|
|
57
|
+
reject(new Error('Authorization was rejected or returned no credential.'));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
res.writeHead(200, { 'Content-Type': 'text/html' })
|
|
62
|
+
.end(page('Connected', `<code>niranzwp</code> is now authorized for <code>${site}</code>. You can close this tab.`));
|
|
63
|
+
|
|
64
|
+
cleanup();
|
|
65
|
+
resolve({ siteUrl: site.replace(/\/+$/, ''), user, password, info });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const timer = setTimeout(() => {
|
|
69
|
+
cleanup();
|
|
70
|
+
reject(new Error('Timed out waiting for authorization.'));
|
|
71
|
+
}, timeoutMs);
|
|
72
|
+
|
|
73
|
+
function cleanup() {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
server.close();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
server.on('error', (e) => { cleanup(); reject(e); });
|
|
79
|
+
|
|
80
|
+
// Port 0 lets the OS pick a free port.
|
|
81
|
+
server.listen(0, '127.0.0.1', () => {
|
|
82
|
+
const port = server.address().port;
|
|
83
|
+
const successUrl = `http://127.0.0.1:${port}/callback`;
|
|
84
|
+
const authorize = new URL(info.authorizeUrl);
|
|
85
|
+
authorize.searchParams.set('app_name', appName);
|
|
86
|
+
authorize.searchParams.set('success_url', successUrl);
|
|
87
|
+
|
|
88
|
+
console.error('Approve this connection in your browser:');
|
|
89
|
+
console.error(` ${authorize}`);
|
|
90
|
+
console.error('');
|
|
91
|
+
if (open) openBrowser(authorize.toString());
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
|
3
|
+
import { homedir, platform } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
// Profiles (which sites exist, who we are on each) live in a plain JSON file.
|
|
7
|
+
// Passwords never do -- they go to the OS keychain where available.
|
|
8
|
+
|
|
9
|
+
const DIR = join(homedir(), '.config', 'niranzwp');
|
|
10
|
+
const PROFILES = join(DIR, 'profiles.json');
|
|
11
|
+
const SERVICE = 'niranzwp';
|
|
12
|
+
|
|
13
|
+
function readProfiles() {
|
|
14
|
+
if (!existsSync(PROFILES)) return {};
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(PROFILES, 'utf8'));
|
|
17
|
+
} catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeProfiles(all) {
|
|
23
|
+
mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
24
|
+
writeFileSync(PROFILES, JSON.stringify(all, null, 2), { mode: 0o600 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const useKeychain = () => platform() === 'darwin';
|
|
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
|
+
}
|
|
38
|
+
|
|
39
|
+
function keychainGet(account) {
|
|
40
|
+
try {
|
|
41
|
+
return execFileSync('security', [
|
|
42
|
+
'find-generic-password', '-a', account, '-s', SERVICE, '-w',
|
|
43
|
+
], { encoding: 'utf8' }).trim();
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function keychainDelete(account) {
|
|
50
|
+
try {
|
|
51
|
+
execFileSync('security', [
|
|
52
|
+
'delete-generic-password', '-a', account, '-s', SERVICE,
|
|
53
|
+
], { stdio: 'ignore' });
|
|
54
|
+
} catch { /* nothing stored */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Fallback for non-macOS: a 0600 file. Less safe than a keychain, so we say so.
|
|
58
|
+
const secretsPath = () => join(DIR, 'secrets.json');
|
|
59
|
+
|
|
60
|
+
function fileSecrets() {
|
|
61
|
+
if (!existsSync(secretsPath())) return {};
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(readFileSync(secretsPath(), 'utf8'));
|
|
64
|
+
} catch {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function account(name) {
|
|
70
|
+
return `${SERVICE}:${name}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function saveProfile(name, { siteUrl, user, password }) {
|
|
74
|
+
const all = readProfiles();
|
|
75
|
+
all[name] = { siteUrl, user, createdAt: new Date().toISOString() };
|
|
76
|
+
writeProfiles(all);
|
|
77
|
+
|
|
78
|
+
if (useKeychain()) {
|
|
79
|
+
keychainSet(account(name), password);
|
|
80
|
+
} else {
|
|
81
|
+
const s = fileSecrets();
|
|
82
|
+
s[name] = password;
|
|
83
|
+
mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
84
|
+
writeFileSync(secretsPath(), JSON.stringify(s), { mode: 0o600 });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getProfile(name) {
|
|
89
|
+
const p = readProfiles()[name];
|
|
90
|
+
if (!p) return null;
|
|
91
|
+
const password = useKeychain() ? keychainGet(account(name)) : fileSecrets()[name];
|
|
92
|
+
if (!password) return null;
|
|
93
|
+
return { name, ...p, password };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function listProfiles() {
|
|
97
|
+
return Object.entries(readProfiles()).map(([name, p]) => ({ name, ...p }));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function deleteProfile(name) {
|
|
101
|
+
const all = readProfiles();
|
|
102
|
+
delete all[name];
|
|
103
|
+
writeProfiles(all);
|
|
104
|
+
|
|
105
|
+
if (useKeychain()) {
|
|
106
|
+
keychainDelete(account(name));
|
|
107
|
+
} else {
|
|
108
|
+
const s = fileSecrets();
|
|
109
|
+
delete s[name];
|
|
110
|
+
if (existsSync(secretsPath())) writeFileSync(secretsPath(), JSON.stringify(s), { mode: 0o600 });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function storageKind() {
|
|
115
|
+
return useKeychain() ? 'macOS Keychain' : `file (${secretsPath()}, mode 0600)`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function configDir() {
|
|
119
|
+
return DIR;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export { rmSync };
|
package/lib/wp.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Thin WordPress REST client. No dependencies -- Node's fetch is enough.
|
|
2
|
+
|
|
3
|
+
export class WpError extends Error {
|
|
4
|
+
constructor(message, { status, code, body } = {}) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'WpError';
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.body = body;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizeSite(url) {
|
|
14
|
+
let u = url.trim();
|
|
15
|
+
if (!/^https?:\/\//i.test(u)) u = `https://${u}`;
|
|
16
|
+
return u.replace(/\/+$/, '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function authHeader(profile) {
|
|
20
|
+
// Application Passwords are sent as HTTP Basic. WordPress strips the
|
|
21
|
+
// spaces WordPress itself put in the generated password.
|
|
22
|
+
const raw = `${profile.user}:${profile.password.replace(/\s+/g, '')}`;
|
|
23
|
+
return `Basic ${Buffer.from(raw, 'utf8').toString('base64')}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function parse(res) {
|
|
27
|
+
const text = await res.text();
|
|
28
|
+
if (!text) return null;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(text);
|
|
31
|
+
} catch {
|
|
32
|
+
return text;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function request(profile, path, { method = 'GET', body, query, raw = false } = {}) {
|
|
37
|
+
const url = new URL(path.startsWith('http') ? path : `${profile.siteUrl}/wp-json${path}`);
|
|
38
|
+
for (const [k, v] of Object.entries(query || {})) {
|
|
39
|
+
if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const headers = { Accept: 'application/json', 'User-Agent': 'niranzwp' };
|
|
43
|
+
if (profile.password) headers.Authorization = authHeader(profile);
|
|
44
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
45
|
+
|
|
46
|
+
const res = await fetch(url, {
|
|
47
|
+
method,
|
|
48
|
+
headers,
|
|
49
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const data = await parse(res);
|
|
53
|
+
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const code = data && typeof data === 'object' ? data.code : undefined;
|
|
56
|
+
const msg = (data && typeof data === 'object' && data.message) || `HTTP ${res.status}`;
|
|
57
|
+
throw new WpError(msg, { status: res.status, code, body: data });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return raw ? { data, headers: res.headers } : data;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Unauthenticated probe: what does this site actually support?
|
|
64
|
+
export async function probe(siteUrl) {
|
|
65
|
+
let res;
|
|
66
|
+
try {
|
|
67
|
+
res = await fetch(`${siteUrl}/wp-json/`, {
|
|
68
|
+
headers: { Accept: 'application/json', 'User-Agent': 'niranzwp' },
|
|
69
|
+
});
|
|
70
|
+
} catch (e) {
|
|
71
|
+
throw new WpError(`cannot reach ${siteUrl}: ${e.message}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const text = await res.text();
|
|
75
|
+
let root;
|
|
76
|
+
try {
|
|
77
|
+
root = JSON.parse(text);
|
|
78
|
+
} catch {
|
|
79
|
+
// A WordPress REST root always returns JSON. HTML means this is either
|
|
80
|
+
// not WordPress, or the REST API is disabled or behind a firewall.
|
|
81
|
+
throw new WpError(
|
|
82
|
+
`${siteUrl} did not return a WordPress REST API (HTTP ${res.status}, got ${
|
|
83
|
+
text.trimStart().startsWith('<') ? 'HTML' : 'non-JSON'
|
|
84
|
+
}). Either it is not WordPress, or /wp-json/ is disabled or blocked.`,
|
|
85
|
+
{ status: res.status }
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!root || typeof root !== 'object' || !Array.isArray(root.namespaces)) {
|
|
90
|
+
throw new WpError(`${siteUrl} returned JSON, but not a WordPress REST root.`, { status: res.status });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const namespaces = root.namespaces || [];
|
|
94
|
+
const appPasswords = Boolean(root.authentication?.['application-passwords']);
|
|
95
|
+
|
|
96
|
+
// Abilities API is WordPress core from 6.9. Its presence is the Tier 2 signal.
|
|
97
|
+
const abilities = namespaces.includes('wp-abilities/v1');
|
|
98
|
+
const mcp = namespaces.includes('mcp');
|
|
99
|
+
|
|
100
|
+
let oauth = null;
|
|
101
|
+
try {
|
|
102
|
+
const r = await fetch(`${siteUrl}/.well-known/oauth-authorization-server`, {
|
|
103
|
+
headers: { Accept: 'application/json', 'User-Agent': 'niranzwp' },
|
|
104
|
+
});
|
|
105
|
+
if (r.ok) oauth = await r.json();
|
|
106
|
+
} catch { /* not every site has one */ }
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
name: root.name,
|
|
110
|
+
description: root.description,
|
|
111
|
+
home: root.home,
|
|
112
|
+
namespaces,
|
|
113
|
+
tier1: appPasswords,
|
|
114
|
+
tier2: abilities,
|
|
115
|
+
mcp,
|
|
116
|
+
oauth: Boolean(oauth),
|
|
117
|
+
oauthMeta: oauth,
|
|
118
|
+
authorizeUrl: root.authentication?.['application-passwords']?.endpoints?.authorization || null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function whoami(profile) {
|
|
123
|
+
return request(profile, '/wp/v2/users/me', { query: { context: 'edit' } });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function listPosts(profile, { status = 'publish', perPage = 10, page = 1, search } = {}) {
|
|
127
|
+
return request(profile, '/wp/v2/posts', {
|
|
128
|
+
raw: true,
|
|
129
|
+
query: {
|
|
130
|
+
status,
|
|
131
|
+
per_page: perPage,
|
|
132
|
+
page,
|
|
133
|
+
search,
|
|
134
|
+
context: 'edit',
|
|
135
|
+
_fields: 'id,date,status,link,title',
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function listAbilities(profile) {
|
|
141
|
+
return request(profile, '/wp-abilities/v1/abilities');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function runAbility(profile, name, input) {
|
|
145
|
+
return request(profile, `/wp-abilities/v1/abilities/${name}/run`, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
body: input ?? {},
|
|
148
|
+
});
|
|
149
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "niranzwp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A CLI for WordPress. Works on any site via Application Passwords, and unlocks Abilities where a site provides them.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"niranzwp": "bin/niranzwp.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=22"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"lib",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT"
|
|
19
|
+
}
|