@wickedevolutions/abilities-mcp 1.3.1 → 1.5.1
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/CHANGELOG.md +61 -0
- package/README.md +88 -17
- package/abilities-mcp.js +182 -113
- package/lib/auth/bridge-identity-provider.js +34 -0
- package/lib/auth/browser-launcher.js +67 -0
- package/lib/auth/config-migration.js +322 -0
- package/lib/auth/dcr-client.js +123 -0
- package/lib/auth/discovery-client.js +273 -0
- package/lib/auth/errors.js +114 -0
- package/lib/auth/events.js +55 -0
- package/lib/auth/fresh-each-time-identity.js +101 -0
- package/lib/auth/http-json.js +151 -0
- package/lib/auth/index.js +88 -0
- package/lib/auth/keychain-secret-store.js +98 -0
- package/lib/auth/loopback-server.js +249 -0
- package/lib/auth/memory-secret-store.js +0 -0
- package/lib/auth/oauth-client.js +357 -0
- package/lib/auth/pkce.js +93 -0
- package/lib/auth/schema-v2.js +110 -0
- package/lib/auth/secret-store.js +78 -0
- package/lib/auth/token-manager.js +378 -0
- package/lib/cli/commands/add-site.js +226 -0
- package/lib/cli/commands/force-downgrade.js +93 -0
- package/lib/cli/commands/list-sites.js +93 -0
- package/lib/cli/commands/reauth.js +108 -0
- package/lib/cli/commands/revoke.js +127 -0
- package/lib/cli/commands/self-check.js +158 -0
- package/lib/cli/commands/test.js +174 -0
- package/lib/cli/commands/upgrade-auth.js +259 -0
- package/lib/cli/config-store.js +161 -0
- package/lib/cli/context.js +102 -0
- package/lib/cli/errors.js +227 -0
- package/lib/cli/index.js +173 -0
- package/lib/cli/output.js +175 -0
- package/lib/cli/parse-args.js +80 -0
- package/lib/config.js +248 -19
- package/lib/connection-pool.js +214 -11
- package/lib/router.js +29 -11
- package/lib/transports/oauth-http-transport.js +601 -0
- package/package.json +7 -2
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CLI error helpers + exit-code table.
|
|
5
|
+
*
|
|
6
|
+
* The design doc does not pin a CLI exit-code table — only the constraint that
|
|
7
|
+
* `lib/auth/` itself contains no `process.exit` calls. The table below is a
|
|
8
|
+
* Phase 5 design choice, intentionally narrow:
|
|
9
|
+
*
|
|
10
|
+
* 0 success
|
|
11
|
+
* 1 generic / unexpected error (catch-all, includes --debug stack)
|
|
12
|
+
* 2 usage error (bad args, unknown subcommand)
|
|
13
|
+
* 3 config error (wp-sites.json missing/invalid)
|
|
14
|
+
* 4 auth failure (consent denied, refresh expired,
|
|
15
|
+
* token endpoint 4xx, network)
|
|
16
|
+
* 5 capability-pinning violation (H.2.3 — pinned site lost OAuth)
|
|
17
|
+
*
|
|
18
|
+
* Operator-facing error messages MUST name the next action to take. CliError
|
|
19
|
+
* carries that next-action text on the `nextAction` field so the formatter can
|
|
20
|
+
* render it consistently.
|
|
21
|
+
*
|
|
22
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
23
|
+
* @license GPL-2.0-or-later
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const EXIT_OK = 0;
|
|
27
|
+
const EXIT_GENERIC = 1;
|
|
28
|
+
const EXIT_USAGE = 2;
|
|
29
|
+
const EXIT_CONFIG = 3;
|
|
30
|
+
const EXIT_AUTH = 4;
|
|
31
|
+
const EXIT_PIN_VIOLATION = 5;
|
|
32
|
+
|
|
33
|
+
class CliError extends Error {
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} message One-line operator-facing summary.
|
|
36
|
+
* @param {object} [opts]
|
|
37
|
+
* @param {number} [opts.exitCode] Defaults to EXIT_GENERIC.
|
|
38
|
+
* @param {string} [opts.nextAction] Required for non-zero exits — names the
|
|
39
|
+
* exact command / action the operator
|
|
40
|
+
* should run next.
|
|
41
|
+
* @param {Error} [opts.cause]
|
|
42
|
+
*/
|
|
43
|
+
constructor(message, opts = {}) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'CliError';
|
|
46
|
+
this.exitCode = typeof opts.exitCode === 'number' ? opts.exitCode : EXIT_GENERIC;
|
|
47
|
+
this.nextAction = opts.nextAction || null;
|
|
48
|
+
// Progress lines accumulated by the command before it threw — these are
|
|
49
|
+
// the operator's record of "how far the command got." The router prints
|
|
50
|
+
// them on stdout so the operator sees the partial trace alongside the
|
|
51
|
+
// error on stderr. (The lib/auth/ state machine has no notion of CLI
|
|
52
|
+
// output, so commands accumulate lines locally and pass them along.)
|
|
53
|
+
if (Array.isArray(opts.progressLines)) this.progressLines = opts.progressLines;
|
|
54
|
+
if (opts.cause) this.cause = opts.cause;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Map a thrown error from `lib/auth/` (or anywhere) onto a CliError.
|
|
60
|
+
* Pure — does not print. The caller decides how to render.
|
|
61
|
+
*
|
|
62
|
+
* @param {Error} err
|
|
63
|
+
* @param {object} [ctx] Optional context to enrich next-action
|
|
64
|
+
* @param {string} [ctx.siteId]
|
|
65
|
+
* @returns {CliError}
|
|
66
|
+
*/
|
|
67
|
+
function fromAuthError(err, ctx = {}) {
|
|
68
|
+
if (err instanceof CliError) {
|
|
69
|
+
// Caller may layer additional progressLines onto an already-CliError.
|
|
70
|
+
if (Array.isArray(ctx.progressLines) && !err.progressLines) {
|
|
71
|
+
err.progressLines = ctx.progressLines;
|
|
72
|
+
}
|
|
73
|
+
return err;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const code = err && err.code;
|
|
77
|
+
const siteRef = ctx.siteId || (err && err.reauthHint && err.reauthHint.siteId) || null;
|
|
78
|
+
const progressLines = Array.isArray(ctx.progressLines) ? ctx.progressLines : undefined;
|
|
79
|
+
|
|
80
|
+
// H.2.3 — capability pinning failure has its own exit code so scripts can
|
|
81
|
+
// distinguish a possible network-attack signal from a routine auth failure.
|
|
82
|
+
if (err && err.name === 'CapabilityPinningError') {
|
|
83
|
+
return new CliError(err.message, {
|
|
84
|
+
exitCode: EXIT_PIN_VIOLATION,
|
|
85
|
+
nextAction: siteRef
|
|
86
|
+
? `Run: abilities-mcp force-downgrade ${siteRef} --i-understand-the-risk (only if you intend to override OAuth pinning)`
|
|
87
|
+
: 'Run: abilities-mcp force-downgrade <site_id> --i-understand-the-risk (only if you intend to override OAuth pinning)',
|
|
88
|
+
cause: err,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Operator denied consent on the adapter screen — no remediation needed
|
|
93
|
+
// beyond re-running add-site / reauth.
|
|
94
|
+
if (err && err.name === 'UserDeniedError') {
|
|
95
|
+
return new CliError('Authorization was denied on the consent screen.', {
|
|
96
|
+
exitCode: EXIT_AUTH,
|
|
97
|
+
nextAction: siteRef
|
|
98
|
+
? `Run: abilities-mcp reauth ${siteRef} (and click Allow on the consent screen)`
|
|
99
|
+
: 'Re-run add-site and click Allow on the consent screen',
|
|
100
|
+
cause: err,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Refresh token rejected by adapter → reauth.
|
|
105
|
+
if (err && err.name === 'RefreshError') {
|
|
106
|
+
if (code === 'reauth_required' || code === 'invalid_grant' || code === 'unauthorized_client') {
|
|
107
|
+
return new CliError(`Refresh token rejected (${code || 'invalid_grant'}).`, {
|
|
108
|
+
exitCode: EXIT_AUTH,
|
|
109
|
+
nextAction: siteRef
|
|
110
|
+
? `Run: abilities-mcp reauth ${siteRef} to refresh consent`
|
|
111
|
+
: 'Run: abilities-mcp reauth <site_id> to refresh consent',
|
|
112
|
+
cause: err,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (code === 'no_refresh_token' || code === 'revoked') {
|
|
116
|
+
return new CliError(err.message || 'No usable refresh token.', {
|
|
117
|
+
exitCode: EXIT_AUTH,
|
|
118
|
+
nextAction: siteRef
|
|
119
|
+
? `Run: abilities-mcp reauth ${siteRef}`
|
|
120
|
+
: 'Run: abilities-mcp reauth <site_id>',
|
|
121
|
+
cause: err,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return new CliError(err.message || 'Token refresh failed.', {
|
|
125
|
+
exitCode: EXIT_AUTH,
|
|
126
|
+
nextAction: siteRef
|
|
127
|
+
? `Run: abilities-mcp reauth ${siteRef} if the failure persists`
|
|
128
|
+
: 'Re-check site connectivity and run reauth if the failure persists',
|
|
129
|
+
cause: err,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Discovery 404 / no metadata → adapter likely not installed.
|
|
134
|
+
if (err && err.name === 'DiscoveryError') {
|
|
135
|
+
return new CliError(err.message || 'OAuth discovery failed.', {
|
|
136
|
+
exitCode: EXIT_AUTH,
|
|
137
|
+
nextAction: 'Verify the site has abilities-mcp-adapter v1.5.0+ installed and reachable over HTTPS',
|
|
138
|
+
cause: err,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Adapter rejected DCR — usually a configuration mismatch on the adapter.
|
|
143
|
+
if (err && err.name === 'RegistrationError') {
|
|
144
|
+
return new CliError(err.message || 'Dynamic Client Registration failed.', {
|
|
145
|
+
exitCode: EXIT_AUTH,
|
|
146
|
+
nextAction: 'Check the adapter\'s OAuth client policy and try add-site again',
|
|
147
|
+
cause: err,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Token exchange rejected — typically PKCE / code re-use.
|
|
152
|
+
if (err && err.name === 'TokenExchangeError') {
|
|
153
|
+
return new CliError(err.message || 'Token exchange failed.', {
|
|
154
|
+
exitCode: EXIT_AUTH,
|
|
155
|
+
nextAction: siteRef
|
|
156
|
+
? `Run: abilities-mcp reauth ${siteRef}`
|
|
157
|
+
: 'Re-run add-site to start a fresh authorization',
|
|
158
|
+
cause: err,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// SecretStore (keytar) unavailable on this host.
|
|
163
|
+
if (err && err.name === 'SecretStoreError') {
|
|
164
|
+
return new CliError(err.message || 'OS keychain unavailable.', {
|
|
165
|
+
exitCode: EXIT_CONFIG,
|
|
166
|
+
nextAction: 'Install OS keychain support (libsecret on Linux) or run on a host with native keychain',
|
|
167
|
+
cause: err,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// State machine state-token mismatch — almost always the operator
|
|
172
|
+
// re-clicking an old consent link.
|
|
173
|
+
if (err && err.name === 'StateMismatchError') {
|
|
174
|
+
return new CliError(err.message, {
|
|
175
|
+
exitCode: EXIT_AUTH,
|
|
176
|
+
nextAction: siteRef
|
|
177
|
+
? `Run: abilities-mcp reauth ${siteRef} and complete the flow without re-using stale links`
|
|
178
|
+
: 'Re-run add-site and complete the flow without re-using stale browser tabs',
|
|
179
|
+
cause: err,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Migration failure — surfaced from config-migration.js.
|
|
184
|
+
if (err && err.name === 'MigrationError') {
|
|
185
|
+
return new CliError(err.message || 'Config migration failed.', {
|
|
186
|
+
exitCode: EXIT_CONFIG,
|
|
187
|
+
nextAction: 'Inspect wp-sites.json (and its .v1.bak) and fix the source schema',
|
|
188
|
+
cause: err,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Fallback — unknown error class.
|
|
193
|
+
const fallback = new CliError(err && err.message ? err.message : String(err), {
|
|
194
|
+
exitCode: EXIT_GENERIC,
|
|
195
|
+
cause: err,
|
|
196
|
+
});
|
|
197
|
+
if (progressLines) fallback.progressLines = progressLines;
|
|
198
|
+
return fallback;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* After fromAuthError() returns, attach `progressLines` collected by the
|
|
203
|
+
* command. Done via a wrapper so each `new CliError(...)` branch above stays
|
|
204
|
+
* a one-liner.
|
|
205
|
+
*
|
|
206
|
+
* @param {CliError} cliErr
|
|
207
|
+
* @param {string[]} progressLines
|
|
208
|
+
* @returns {CliError}
|
|
209
|
+
*/
|
|
210
|
+
function withProgress(cliErr, progressLines) {
|
|
211
|
+
if (cliErr instanceof CliError && Array.isArray(progressLines) && progressLines.length) {
|
|
212
|
+
cliErr.progressLines = progressLines;
|
|
213
|
+
}
|
|
214
|
+
return cliErr;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
CliError,
|
|
219
|
+
fromAuthError,
|
|
220
|
+
withProgress,
|
|
221
|
+
EXIT_OK,
|
|
222
|
+
EXIT_GENERIC,
|
|
223
|
+
EXIT_USAGE,
|
|
224
|
+
EXIT_CONFIG,
|
|
225
|
+
EXIT_AUTH,
|
|
226
|
+
EXIT_PIN_VIOLATION,
|
|
227
|
+
};
|
package/lib/cli/index.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parse } = require('./parse-args');
|
|
4
|
+
const { createContext } = require('./context');
|
|
5
|
+
const { CliError, fromAuthError, EXIT_USAGE, EXIT_OK, EXIT_GENERIC } = require('./errors');
|
|
6
|
+
const { renderNextAction } = require('./output');
|
|
7
|
+
const { migrateFile } = require('../auth/config-migration');
|
|
8
|
+
|
|
9
|
+
const COMMANDS = {
|
|
10
|
+
'add-site': () => require('./commands/add-site'),
|
|
11
|
+
'reauth': () => require('./commands/reauth'),
|
|
12
|
+
'revoke': () => require('./commands/revoke'),
|
|
13
|
+
'list-sites': () => require('./commands/list-sites'),
|
|
14
|
+
'test': () => require('./commands/test'),
|
|
15
|
+
'upgrade-auth': () => require('./commands/upgrade-auth'),
|
|
16
|
+
// Documented in Appendix J of the design doc:
|
|
17
|
+
'force-downgrade': () => require('./commands/force-downgrade'), // J.1 — H.2.3 escape hatch
|
|
18
|
+
'self-check': () => require('./commands/self-check'), // J.2 — H.2.6 header probe
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Subcommand router for `abilities-mcp <subcommand> ...`.
|
|
23
|
+
*
|
|
24
|
+
* The router is invoked by abilities-mcp.js when the first argv token is one
|
|
25
|
+
* of the known subcommand names. When it isn't, the bridge falls through to
|
|
26
|
+
* MCP server mode (the original behavior).
|
|
27
|
+
*
|
|
28
|
+
* Tests can call `runCommand({ subcommand, argv, ctx })` directly with a
|
|
29
|
+
* test context, bypassing process.argv / process.exit / stdout entirely.
|
|
30
|
+
*
|
|
31
|
+
* Exit codes are defined in `./errors.js` (0/1/2/3/4/5).
|
|
32
|
+
*
|
|
33
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
34
|
+
* @license GPL-2.0-or-later
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
function isKnownSubcommand(name) {
|
|
38
|
+
return Object.prototype.hasOwnProperty.call(COMMANDS, name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run a single subcommand. Pure of process / stdout — returns { exitCode,
|
|
43
|
+
* lines, errLines }. The caller writes to streams and exits.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} opts
|
|
46
|
+
* @param {string} opts.subcommand
|
|
47
|
+
* @param {string[]} opts.argv Tokens after the subcommand name.
|
|
48
|
+
* @param {object} [opts.ctx] Pre-built context (tests). When
|
|
49
|
+
* omitted, createContext(args) is used.
|
|
50
|
+
* @returns {Promise<{exitCode:number, lines:string[], errLines:string[]}>}
|
|
51
|
+
*/
|
|
52
|
+
async function runCommand(opts) {
|
|
53
|
+
const args = parse(opts.argv || []);
|
|
54
|
+
if (!isKnownSubcommand(opts.subcommand)) {
|
|
55
|
+
return {
|
|
56
|
+
exitCode: EXIT_USAGE,
|
|
57
|
+
lines: [],
|
|
58
|
+
errLines: [
|
|
59
|
+
`abilities-mcp: unknown subcommand "${opts.subcommand}"`,
|
|
60
|
+
` → Run: abilities-mcp --help`,
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const cmd = COMMANDS[opts.subcommand]();
|
|
65
|
+
const ctx = opts.ctx || createContext(args);
|
|
66
|
+
|
|
67
|
+
// Schema v1→v2 migration runs before any subcommand reads the config, so
|
|
68
|
+
// `upgrade-auth`, `add-site`, `list-sites`, etc. work on a fresh v1.4.x
|
|
69
|
+
// upgrade without first starting the bridge as MCP server. `migrateFile`
|
|
70
|
+
// is idempotent (a v2 file is a no-op) and ENOENT-safe (returns
|
|
71
|
+
// missing:true for clean installs that haven't run add-site yet). The
|
|
72
|
+
// migration uses `ctx.secretStore` so lifted secrets land in the same
|
|
73
|
+
// store the subcommand will read from a moment later — production
|
|
74
|
+
// contexts share KeychainSecretStore (entry identity is (service,
|
|
75
|
+
// account), not instance-bound); tests share their MemorySecretStore.
|
|
76
|
+
const preLines = [];
|
|
77
|
+
try {
|
|
78
|
+
if (ctx.configPath) {
|
|
79
|
+
const result = await migrateFile({
|
|
80
|
+
filePath: ctx.configPath,
|
|
81
|
+
secretStore: ctx.secretStore,
|
|
82
|
+
});
|
|
83
|
+
if (result.migrated) {
|
|
84
|
+
preLines.push(
|
|
85
|
+
`Migrated wp-sites.json v1 → v2 (${result.liftedCount} secret(s) lifted; backup: ${result.backupPath})`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const cliErr = fromAuthError(err);
|
|
91
|
+
return {
|
|
92
|
+
exitCode: cliErr.exitCode || EXIT_GENERIC,
|
|
93
|
+
lines: [],
|
|
94
|
+
errLines: renderNextAction(cliErr),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const r = await cmd.run(args, ctx);
|
|
100
|
+
const lines = preLines.length
|
|
101
|
+
? preLines.concat(r.lines || [])
|
|
102
|
+
: (r.lines || []);
|
|
103
|
+
return { exitCode: r.exitCode || EXIT_OK, lines, errLines: [] };
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const cliErr = err instanceof CliError ? err : fromAuthError(err);
|
|
106
|
+
const errLines = renderNextAction(cliErr);
|
|
107
|
+
if (ctx.debug && cliErr.cause && cliErr.cause.stack) {
|
|
108
|
+
errLines.push('');
|
|
109
|
+
errLines.push('--- debug stack ---');
|
|
110
|
+
errLines.push(cliErr.cause.stack);
|
|
111
|
+
}
|
|
112
|
+
// If the command accumulated progress before throwing, surface it on
|
|
113
|
+
// stdout — the operator sees how far the command got alongside the
|
|
114
|
+
// stderr error message. Migration-success preLines also need to surface
|
|
115
|
+
// when the subsequent subcommand throws.
|
|
116
|
+
const progress = Array.isArray(cliErr.progressLines) ? cliErr.progressLines : [];
|
|
117
|
+
const lines = preLines.length ? preLines.concat(progress) : progress;
|
|
118
|
+
return {
|
|
119
|
+
exitCode: cliErr.exitCode || EXIT_GENERIC,
|
|
120
|
+
lines,
|
|
121
|
+
errLines,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const HELP_TEXT = [
|
|
127
|
+
'abilities-mcp — MCP bridge for WordPress Abilities API',
|
|
128
|
+
'',
|
|
129
|
+
'Subcommands:',
|
|
130
|
+
' add-site <url> Register a new site (OAuth by default)',
|
|
131
|
+
' --apppassword Use App Password authentication instead',
|
|
132
|
+
' --username=<user> --password=<pw> (required with --apppassword)',
|
|
133
|
+
' --scope="<space-sep scopes>" Override default DCR scope',
|
|
134
|
+
' --site-id=<id> Override the derived site_id',
|
|
135
|
+
' --label=<text> Human-readable label',
|
|
136
|
+
' --force Overwrite an existing site_id',
|
|
137
|
+
' reauth <site_id> Re-run OAuth flow for an existing site',
|
|
138
|
+
' revoke <site_id> Revoke OAuth tokens (local + remote)',
|
|
139
|
+
' list-sites Show configured sites + auth status',
|
|
140
|
+
' test <site_id> Ping the adapter and report scopes',
|
|
141
|
+
' upgrade-auth <site_id> Migrate App Password → OAuth (Step 1-3)',
|
|
142
|
+
' --confirm Step 4: remove App Password fallback',
|
|
143
|
+
' force-downgrade <site_id> Override OAuth pinning (H.2.3)',
|
|
144
|
+
' --i-understand-the-risk (required)',
|
|
145
|
+
' --reason="<text>" Audit message (visible in list-sites for 30 days)',
|
|
146
|
+
' self-check <site_id> Probe Authorization-header survival (H.2.6)',
|
|
147
|
+
'',
|
|
148
|
+
'Global flags:',
|
|
149
|
+
' --config=<path> Use this wp-sites.json (defaults: ./wp-sites.json or',
|
|
150
|
+
' ~/.abilities-mcp/wp-sites.json)',
|
|
151
|
+
' --debug Include cause stack on errors',
|
|
152
|
+
' --allow-insecure Allow plain HTTP (localhost dev only)',
|
|
153
|
+
'',
|
|
154
|
+
'Exit codes:',
|
|
155
|
+
' 0 success',
|
|
156
|
+
' 1 unexpected error',
|
|
157
|
+
' 2 usage error',
|
|
158
|
+
' 3 config error (wp-sites.json)',
|
|
159
|
+
' 4 auth failure (consent denied / token rejected / network)',
|
|
160
|
+
' 5 capability-pinning violation (H.2.3 — pinned site lost OAuth)',
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
function isHelpToken(tok) {
|
|
164
|
+
return tok === '-h' || tok === '--help' || tok === 'help';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
isKnownSubcommand,
|
|
169
|
+
runCommand,
|
|
170
|
+
COMMANDS,
|
|
171
|
+
HELP_TEXT,
|
|
172
|
+
isHelpToken,
|
|
173
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Output formatter for CLI subcommands.
|
|
5
|
+
*
|
|
6
|
+
* Returns lines (strings) rather than printing directly so commands stay
|
|
7
|
+
* testable without stdout capture. The router writes `lines` to stdout, and
|
|
8
|
+
* any `errLines` to stderr, after the command resolves.
|
|
9
|
+
*
|
|
10
|
+
* Renders:
|
|
11
|
+
* - State-machine progress lines (subscribed from lib/auth/ events).
|
|
12
|
+
* - Site tables for `list-sites`.
|
|
13
|
+
* - "next action" hints for failed commands.
|
|
14
|
+
*
|
|
15
|
+
* No emoji unless the spec verbatim text uses one (✓, ✗, ⚠ appear in the
|
|
16
|
+
* design doc's binding error wording — those we preserve).
|
|
17
|
+
*
|
|
18
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
19
|
+
* @license GPL-2.0-or-later
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { STATES } = require('../auth/events');
|
|
23
|
+
|
|
24
|
+
const PROGRESS_LABEL = Object.freeze({
|
|
25
|
+
[STATES.DISCOVERING]: '→ Discovering OAuth metadata',
|
|
26
|
+
[STATES.REGISTERING]: '→ Registering bridge client (DCR)',
|
|
27
|
+
[STATES.AWAITING_CONSENT]: '→ Waiting for consent in browser',
|
|
28
|
+
[STATES.EXCHANGING]: '→ Exchanging authorization code for tokens',
|
|
29
|
+
[STATES.COMPLETE]: '✓ Authorization complete',
|
|
30
|
+
[STATES.FAILED]: '✗ Authorization failed',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Subscribe to an OAuthClient event emitter and push human-readable progress
|
|
35
|
+
* lines onto `out`. The caller decides when to flush; this only mutates `out`.
|
|
36
|
+
*
|
|
37
|
+
* @param {import('node:events').EventEmitter} client
|
|
38
|
+
* @param {string[]} out
|
|
39
|
+
* @param {object} [opts]
|
|
40
|
+
* @param {boolean} [opts.includeProgress] default true — sub-step lines
|
|
41
|
+
* @param {boolean} [opts.includeAuthorizeUrl] default true — print URL when
|
|
42
|
+
* awaiting_consent so headless
|
|
43
|
+
* operators can paste it.
|
|
44
|
+
*/
|
|
45
|
+
function subscribeProgress(client, out, opts = {}) {
|
|
46
|
+
const includeProgress = opts.includeProgress !== false;
|
|
47
|
+
const includeAuthorizeUrl = opts.includeAuthorizeUrl !== false;
|
|
48
|
+
client.on('state', ({ to, data }) => {
|
|
49
|
+
const label = PROGRESS_LABEL[to];
|
|
50
|
+
if (label) out.push(label);
|
|
51
|
+
if (includeAuthorizeUrl && to === STATES.AWAITING_CONSENT && data && data.authorizeUrl) {
|
|
52
|
+
out.push(` If a browser tab does not open, paste this URL:`);
|
|
53
|
+
out.push(` ${data.authorizeUrl}`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
if (includeProgress) {
|
|
57
|
+
client.on('progress', ({ message, data }) => {
|
|
58
|
+
// Pull a couple of useful fields out for the operator without dumping
|
|
59
|
+
// raw JSON. Keep it terse.
|
|
60
|
+
if (message === 'discovery_succeeded' && data && data.asMetadataUrl) {
|
|
61
|
+
out.push(` Authorization server: ${data.asMetadataUrl}`);
|
|
62
|
+
} else if (message === 'registered' && data && data.clientId) {
|
|
63
|
+
out.push(` Registered client: ${data.clientId}`);
|
|
64
|
+
} else if (message === 'callback_received') {
|
|
65
|
+
out.push(' Consent received from browser');
|
|
66
|
+
} else if (message === 'browser_launch_failed') {
|
|
67
|
+
out.push(' (Browser launch failed — paste the URL above into your browser)');
|
|
68
|
+
} else if (message === 'reusing_persisted_client_id' && data && data.clientId) {
|
|
69
|
+
out.push(` Reusing persisted client_id: ${data.clientId}`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Render a list of `sites` per F.5 example:
|
|
77
|
+
*
|
|
78
|
+
* SITE URL AUTH USER SCOPES EXPIRES
|
|
79
|
+
* siteA https://siteA.com oauth wp_agent read, write, menus in 87 days
|
|
80
|
+
*
|
|
81
|
+
* @param {Array<{
|
|
82
|
+
* siteId: string,
|
|
83
|
+
* url: string,
|
|
84
|
+
* authMethod: string,
|
|
85
|
+
* user: string,
|
|
86
|
+
* scopesShort: string,
|
|
87
|
+
* expires: string,
|
|
88
|
+
* statusBadge: string,
|
|
89
|
+
* downgradeAnnotation: string,
|
|
90
|
+
* }>} rows
|
|
91
|
+
* @param {object} [opts]
|
|
92
|
+
* @param {number} [opts.maxWidth] Default 120 — wraps wide tables to fit.
|
|
93
|
+
* @returns {string[]} lines
|
|
94
|
+
*/
|
|
95
|
+
function renderSiteTable(rows, opts = {}) {
|
|
96
|
+
if (!rows.length) {
|
|
97
|
+
return ['(no sites configured — run: abilities-mcp add-site <url>)'];
|
|
98
|
+
}
|
|
99
|
+
const headers = ['SITE', 'URL', 'AUTH', 'USER', 'SCOPES', 'EXPIRES', 'STATUS'];
|
|
100
|
+
const widths = headers.map((h) => h.length);
|
|
101
|
+
for (const r of rows) {
|
|
102
|
+
widths[0] = Math.max(widths[0], r.siteId.length);
|
|
103
|
+
widths[1] = Math.max(widths[1], r.url.length);
|
|
104
|
+
widths[2] = Math.max(widths[2], r.authMethod.length);
|
|
105
|
+
widths[3] = Math.max(widths[3], r.user.length);
|
|
106
|
+
widths[4] = Math.max(widths[4], r.scopesShort.length);
|
|
107
|
+
widths[5] = Math.max(widths[5], r.expires.length);
|
|
108
|
+
widths[6] = Math.max(widths[6], r.statusBadge.length);
|
|
109
|
+
}
|
|
110
|
+
const fmt = (cells) => cells.map((c, i) => String(c).padEnd(widths[i])).join(' ').trimEnd();
|
|
111
|
+
const lines = [];
|
|
112
|
+
lines.push(fmt(headers));
|
|
113
|
+
for (const r of rows) {
|
|
114
|
+
lines.push(fmt([
|
|
115
|
+
r.siteId, r.url, r.authMethod, r.user, r.scopesShort, r.expires, r.statusBadge,
|
|
116
|
+
]));
|
|
117
|
+
if (r.downgradeAnnotation) {
|
|
118
|
+
lines.push(` ${r.downgradeAnnotation}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return lines;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Compute a human "in N days" / "expired" string from an ISO timestamp.
|
|
126
|
+
* @param {string|null|undefined} iso
|
|
127
|
+
* @param {number} [nowMs]
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
function expiresLabel(iso, nowMs) {
|
|
131
|
+
if (!iso) return '—';
|
|
132
|
+
const at = Date.parse(iso);
|
|
133
|
+
if (Number.isNaN(at)) return '—';
|
|
134
|
+
const now = typeof nowMs === 'number' ? nowMs : Date.now();
|
|
135
|
+
const days = Math.floor((at - now) / (24 * 3600 * 1000));
|
|
136
|
+
if (days < 0) return `expired ${-days}d ago`;
|
|
137
|
+
if (days === 0) return 'today';
|
|
138
|
+
if (days === 1) return 'in 1 day';
|
|
139
|
+
return `in ${days} days`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Compact rendering of a scope list for the EXPIRES table.
|
|
144
|
+
* Drops the `abilities:` prefix and joins with commas. Truncates to 4 entries
|
|
145
|
+
* with a "+N" suffix to keep the table readable.
|
|
146
|
+
*
|
|
147
|
+
* @param {string[]} scopes
|
|
148
|
+
* @returns {string}
|
|
149
|
+
*/
|
|
150
|
+
function shortScopes(scopes) {
|
|
151
|
+
if (!Array.isArray(scopes) || scopes.length === 0) return '(full)';
|
|
152
|
+
const trimmed = scopes.map((s) => s.replace(/^abilities:/, ''));
|
|
153
|
+
if (trimmed.length <= 4) return trimmed.join(', ');
|
|
154
|
+
return trimmed.slice(0, 3).join(', ') + ` +${trimmed.length - 3}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Render the trailing "next action" hint for a failed CliError.
|
|
159
|
+
* @param {import('./errors').CliError} err
|
|
160
|
+
* @returns {string[]}
|
|
161
|
+
*/
|
|
162
|
+
function renderNextAction(err) {
|
|
163
|
+
const lines = [`✗ ${err.message}`];
|
|
164
|
+
if (err.nextAction) lines.push(` → ${err.nextAction}`);
|
|
165
|
+
return lines;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = {
|
|
169
|
+
subscribeProgress,
|
|
170
|
+
renderSiteTable,
|
|
171
|
+
renderNextAction,
|
|
172
|
+
expiresLabel,
|
|
173
|
+
shortScopes,
|
|
174
|
+
PROGRESS_LABEL,
|
|
175
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal CLI arg parser for the abilities-mcp subcommand surface.
|
|
5
|
+
*
|
|
6
|
+
* Supported forms:
|
|
7
|
+
* <subcommand> <positional...> [--flag] [--key=value] [--key value]
|
|
8
|
+
*
|
|
9
|
+
* Boolean flags: `--debug`, `--apppassword`, `--confirm`, `--force`,
|
|
10
|
+
* `--allow-insecure`, `--i-understand-the-risk`.
|
|
11
|
+
*
|
|
12
|
+
* Value flags accept either `--key=value` or `--key value` form.
|
|
13
|
+
*
|
|
14
|
+
* Long options only — no short forms — to keep the surface small and
|
|
15
|
+
* predictable. The argv array starts at the subcommand (i.e. caller has
|
|
16
|
+
* already sliced argv to remove `node` / script path).
|
|
17
|
+
*
|
|
18
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
19
|
+
* @license GPL-2.0-or-later
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Keys we treat as boolean even when they appear as `--key value` — value
|
|
23
|
+
// will be left as a positional. (Currently empty; we use the "next token
|
|
24
|
+
// is a flag → boolean" heuristic, which works for our flags.)
|
|
25
|
+
const BOOLEAN_FLAGS = new Set([
|
|
26
|
+
'debug',
|
|
27
|
+
'apppassword',
|
|
28
|
+
'confirm',
|
|
29
|
+
'force',
|
|
30
|
+
'allow-insecure',
|
|
31
|
+
'i-understand-the-risk',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string[]} argv Tokens after the subcommand name.
|
|
36
|
+
* @returns {object} { _: [...positionals], <flag>: <value|true>, ... }
|
|
37
|
+
*/
|
|
38
|
+
function parse(argv) {
|
|
39
|
+
const out = { _: [] };
|
|
40
|
+
for (let i = 0; i < argv.length; i++) {
|
|
41
|
+
const tok = argv[i];
|
|
42
|
+
if (tok === '--') {
|
|
43
|
+
// Everything after `--` is positional.
|
|
44
|
+
for (let j = i + 1; j < argv.length; j++) out._.push(argv[j]);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
if (tok.startsWith('--')) {
|
|
48
|
+
const eq = tok.indexOf('=');
|
|
49
|
+
if (eq > -1) {
|
|
50
|
+
const key = tok.slice(2, eq);
|
|
51
|
+
const value = tok.slice(eq + 1);
|
|
52
|
+
out[key] = _coerce(value);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const key = tok.slice(2);
|
|
56
|
+
if (BOOLEAN_FLAGS.has(key)) {
|
|
57
|
+
out[key] = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const next = argv[i + 1];
|
|
61
|
+
if (next === undefined || next.startsWith('--')) {
|
|
62
|
+
out[key] = true;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
out[key] = _coerce(next);
|
|
66
|
+
i++;
|
|
67
|
+
} else {
|
|
68
|
+
out._.push(tok);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function _coerce(s) {
|
|
75
|
+
if (s === 'true') return true;
|
|
76
|
+
if (s === 'false') return false;
|
|
77
|
+
return s;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { parse, BOOLEAN_FLAGS };
|