@wickedevolutions/abilities-mcp 1.3.1 → 1.5.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/CHANGELOG.md +111 -0
- package/README.md +88 -17
- package/abilities-mcp.js +191 -114
- 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 +265 -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 +328 -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-source-line.js +85 -0
- package/lib/config.js +282 -22
- 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 +8 -2
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
OAuthClient,
|
|
5
|
+
TokenManager,
|
|
6
|
+
AUTH_STATUS,
|
|
7
|
+
DEFAULT_SCOPE,
|
|
8
|
+
} = require('../../auth');
|
|
9
|
+
const { discover } = require('../../auth/discovery-client');
|
|
10
|
+
const { makeRef, parseRef } = require('../../auth/secret-store');
|
|
11
|
+
const { CliError, EXIT_USAGE, fromAuthError, withProgress } = require('../errors');
|
|
12
|
+
const { subscribeProgress } = require('../output');
|
|
13
|
+
const { readConfig, writeConfig } = require('../config-store');
|
|
14
|
+
const testCmd = require('./test');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `upgrade-auth <site_id> [--confirm]` — migrate an App Password site to
|
|
18
|
+
* OAuth without disturbing the App Password fallback.
|
|
19
|
+
*
|
|
20
|
+
* Implements the four-step sequence locked in Appendix F.5:
|
|
21
|
+
*
|
|
22
|
+
* Step 1 — Pre-flight discovery. 404 → "Site does not have OAuth. Install
|
|
23
|
+
* abilities-mcp-adapter v1.5.0+ first."
|
|
24
|
+
* Step 2 — Dual-write: copy current apppassword credentials to
|
|
25
|
+
* apppassword_fallback, then run the OAuth flow. On success the
|
|
26
|
+
* site uses OAuth; the fallback remains.
|
|
27
|
+
* Step 3 — Validation: run `test <site_id>`. On success, "✓ OAuth working.
|
|
28
|
+
* App Password kept as fallback for now." On failure, revert.
|
|
29
|
+
* Step 4 — `--confirm`: remove apppassword_fallback and delete the legacy
|
|
30
|
+
* keychain entry.
|
|
31
|
+
*
|
|
32
|
+
* `upgrade-auth` without `--confirm` is idempotent (per F.5 prose) — running
|
|
33
|
+
* it twice on the same site is safe.
|
|
34
|
+
*
|
|
35
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
36
|
+
* @license GPL-2.0-or-later
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const SECRET_SERVICE = 'abilities-mcp';
|
|
40
|
+
|
|
41
|
+
async function run(args, ctx) {
|
|
42
|
+
const siteId = args._ && args._[0];
|
|
43
|
+
if (!siteId) {
|
|
44
|
+
throw new CliError('upgrade-auth requires a site_id argument', {
|
|
45
|
+
exitCode: EXIT_USAGE,
|
|
46
|
+
nextAction: 'Run: abilities-mcp upgrade-auth <site_id> [--confirm]',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const config = readConfig(ctx.configPath);
|
|
50
|
+
const site = config.sites[siteId];
|
|
51
|
+
if (!site) {
|
|
52
|
+
throw new CliError(`upgrade-auth: unknown site_id "${siteId}"`, {
|
|
53
|
+
exitCode: EXIT_USAGE,
|
|
54
|
+
nextAction: 'Run: abilities-mcp list-sites to see configured sites',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---- Step 4: --confirm cleans up the fallback. -----------------------
|
|
59
|
+
if (args.confirm) {
|
|
60
|
+
return _confirm(siteId, site, config, ctx);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (site.auth.method === 'oauth' && !site.auth.apppassword_fallback) {
|
|
64
|
+
return {
|
|
65
|
+
exitCode: 0,
|
|
66
|
+
lines: [`Site "${siteId}" already uses OAuth with no App Password fallback.`,
|
|
67
|
+
`(Idempotent — nothing to do.)`],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (site.auth.method !== 'apppassword') {
|
|
72
|
+
// OAuth-with-fallback case is allowed (Step 2 may have already run).
|
|
73
|
+
if (!(site.auth.method === 'oauth' && site.auth.apppassword_fallback)) {
|
|
74
|
+
throw new CliError(
|
|
75
|
+
`upgrade-auth: site "${siteId}" is not in a state we can upgrade (method=${site.auth.method})`,
|
|
76
|
+
{
|
|
77
|
+
exitCode: EXIT_USAGE,
|
|
78
|
+
nextAction: `Run: abilities-mcp list-sites and inspect the site state`,
|
|
79
|
+
}
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const out = [];
|
|
85
|
+
|
|
86
|
+
// ---- Step 1: pre-flight discovery. -----------------------------------
|
|
87
|
+
out.push(`Step 1: pre-flight OAuth discovery for "${siteId}" (${site.url})…`);
|
|
88
|
+
const discoverFn = (ctx.deps && ctx.deps.discover) || discover;
|
|
89
|
+
try {
|
|
90
|
+
await discoverFn(site.url, {
|
|
91
|
+
pinned: !!site.oauth_capability_pinned,
|
|
92
|
+
pinnedFirstSeenAt: site.oauth_capability_pinned && site.oauth_capability_pinned.first_seen_at,
|
|
93
|
+
allowInsecure: ctx.allowInsecure,
|
|
94
|
+
});
|
|
95
|
+
out.push(' ✓ OAuth discovery succeeded.');
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err && err.name === 'DiscoveryError') {
|
|
98
|
+
throw new CliError(
|
|
99
|
+
`Site ${siteId} does not have OAuth. Install abilities-mcp-adapter v1.5.0+ first.`,
|
|
100
|
+
{
|
|
101
|
+
exitCode: 4,
|
|
102
|
+
nextAction: 'Install / upgrade abilities-mcp-adapter on the site to v1.5.0 or later',
|
|
103
|
+
cause: err,
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
throw fromAuthError(err, { siteId });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- Step 2: dual-write. ---------------------------------------------
|
|
111
|
+
out.push(`Step 2: running OAuth flow while keeping App Password as fallback…`);
|
|
112
|
+
|
|
113
|
+
// Stash the current apppassword credentials before the OAuth flow runs.
|
|
114
|
+
// If the operator has already done step 2 (resuming after a partial run),
|
|
115
|
+
// the fallback already exists and we leave it untouched.
|
|
116
|
+
let pendingFallback = site.auth.apppassword_fallback || null;
|
|
117
|
+
if (!pendingFallback && site.auth.method === 'apppassword') {
|
|
118
|
+
pendingFallback = {
|
|
119
|
+
username: site.auth.username,
|
|
120
|
+
password_ref: site.auth.password_ref,
|
|
121
|
+
};
|
|
122
|
+
// Move the keychain entry from <siteId>/apppassword to
|
|
123
|
+
// <siteId>/apppassword-legacy per the F.5 example. We do this before
|
|
124
|
+
// the OAuth flow so a mid-flow crash leaves us with a recoverable
|
|
125
|
+
// state (operator can re-run upgrade-auth).
|
|
126
|
+
const legacyAccount = `${siteId}/apppassword-legacy`;
|
|
127
|
+
try {
|
|
128
|
+
const oldAccount = parseRef(site.auth.password_ref).account;
|
|
129
|
+
const value = await ctx.secretStore.get(SECRET_SERVICE, oldAccount);
|
|
130
|
+
if (typeof value === 'string') {
|
|
131
|
+
await ctx.secretStore.set(SECRET_SERVICE, legacyAccount, value);
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
// Non-fatal — operator may have already deleted the original entry.
|
|
135
|
+
}
|
|
136
|
+
pendingFallback = {
|
|
137
|
+
username: site.auth.username,
|
|
138
|
+
password_ref: makeRef(SECRET_SERVICE, legacyAccount),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const clientName = `${ctx.userLabel}'s Operator (${ctx.hostnameLabel})`;
|
|
143
|
+
const OAuthClientCls = (ctx.deps && ctx.deps.OAuthClient) || OAuthClient;
|
|
144
|
+
const oauth = new OAuthClientCls({
|
|
145
|
+
siteUrl: site.url,
|
|
146
|
+
clientName,
|
|
147
|
+
softwareVersion: ctx.softwareVersion,
|
|
148
|
+
scope: args.scope || DEFAULT_SCOPE,
|
|
149
|
+
identityProvider: ctx.identityProvider,
|
|
150
|
+
allowInsecure: ctx.allowInsecure,
|
|
151
|
+
capabilityPin: site.oauth_capability_pinned ? {
|
|
152
|
+
firstSeenAt: site.oauth_capability_pinned.first_seen_at,
|
|
153
|
+
} : null,
|
|
154
|
+
deps: ctx.deps && ctx.deps.oauthClientDeps,
|
|
155
|
+
});
|
|
156
|
+
subscribeProgress(oauth, out);
|
|
157
|
+
|
|
158
|
+
let result;
|
|
159
|
+
try { result = await oauth.run(); }
|
|
160
|
+
catch (err) {
|
|
161
|
+
out.push('✗ OAuth flow failed — config left unchanged. App Password remains primary.');
|
|
162
|
+
throw fromAuthError(err, { siteId });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const tm = new TokenManager({ secretStore: ctx.secretStore, allowInsecure: ctx.allowInsecure });
|
|
166
|
+
const persisted = await tm.persistTokens({ siteId, tokens: result.tokens });
|
|
167
|
+
|
|
168
|
+
site.auth = {
|
|
169
|
+
method: 'oauth',
|
|
170
|
+
client_id: result.clientId,
|
|
171
|
+
user_login: pendingFallback ? pendingFallback.username : (ctx.userLabel || 'operator'),
|
|
172
|
+
scopes: result.scopes,
|
|
173
|
+
access_token_expires_at: persisted.accessTokenExpiresAt,
|
|
174
|
+
refresh_token_expires_at: persisted.refreshTokenExpiresAt,
|
|
175
|
+
access_token_ref: persisted.accessTokenRef,
|
|
176
|
+
refresh_token_ref: persisted.refreshTokenRef,
|
|
177
|
+
apppassword_fallback: pendingFallback,
|
|
178
|
+
};
|
|
179
|
+
site.auth_status = AUTH_STATUS.ACTIVE;
|
|
180
|
+
site.oauth_capability_pinned = {
|
|
181
|
+
first_seen_at: result.capabilityPin.firstSeenAt,
|
|
182
|
+
last_confirmed_at: result.capabilityPin.lastConfirmedAt,
|
|
183
|
+
};
|
|
184
|
+
if (result.prMetadata && result.prMetadata.resource) {
|
|
185
|
+
site.mcp_resource = result.prMetadata.resource;
|
|
186
|
+
}
|
|
187
|
+
await writeConfig(ctx.configPath, config);
|
|
188
|
+
|
|
189
|
+
// ---- Step 3: validation via `test`. ----------------------------------
|
|
190
|
+
out.push(`Step 3: validating OAuth bearer with a ping…`);
|
|
191
|
+
let pingFailed = false;
|
|
192
|
+
let pingError;
|
|
193
|
+
try {
|
|
194
|
+
const testResult = await testCmd.run({ _: [siteId] }, ctx);
|
|
195
|
+
for (const line of testResult.lines) out.push(` ${line}`);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
pingFailed = true;
|
|
198
|
+
pingError = err;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (pingFailed) {
|
|
202
|
+
// Spec wording (binding): "✗ OAuth test failed — reverting."
|
|
203
|
+
out.push('✗ OAuth test failed — reverting.');
|
|
204
|
+
if (pendingFallback) {
|
|
205
|
+
site.auth = {
|
|
206
|
+
method: 'apppassword',
|
|
207
|
+
username: pendingFallback.username,
|
|
208
|
+
password_ref: pendingFallback.password_ref,
|
|
209
|
+
};
|
|
210
|
+
site.auth_status = AUTH_STATUS.ACTIVE;
|
|
211
|
+
delete site.mcp_resource;
|
|
212
|
+
await writeConfig(ctx.configPath, config);
|
|
213
|
+
}
|
|
214
|
+
// Attach progress lines so the router prints "✗ OAuth test failed —
|
|
215
|
+
// reverting." on stdout alongside the stderr error from the ping.
|
|
216
|
+
throw withProgress(fromAuthError(pingError, { siteId }), out);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Spec wording (binding):
|
|
220
|
+
// "✓ OAuth working. App Password kept as fallback for now."
|
|
221
|
+
out.push('✓ OAuth working. App Password kept as fallback for now.');
|
|
222
|
+
out.push(` Run: abilities-mcp upgrade-auth ${siteId} --confirm (when ready to remove the fallback)`);
|
|
223
|
+
return { exitCode: 0, lines: out };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function _confirm(siteId, site, config, ctx) {
|
|
227
|
+
if (site.auth.method !== 'oauth') {
|
|
228
|
+
throw new CliError(
|
|
229
|
+
`upgrade-auth --confirm: site "${siteId}" is not OAuth (method=${site.auth.method})`,
|
|
230
|
+
{
|
|
231
|
+
exitCode: EXIT_USAGE,
|
|
232
|
+
nextAction: `Run: abilities-mcp upgrade-auth ${siteId} (without --confirm) first`,
|
|
233
|
+
}
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (!site.auth.apppassword_fallback) {
|
|
237
|
+
return {
|
|
238
|
+
exitCode: 0,
|
|
239
|
+
lines: [`Site "${siteId}" has no App Password fallback to remove. (Idempotent — nothing to do.)`],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
// Delete the legacy keychain entry, then strip the fallback from config.
|
|
243
|
+
let legacyAccount;
|
|
244
|
+
try { legacyAccount = parseRef(site.auth.apppassword_fallback.password_ref).account; }
|
|
245
|
+
catch { legacyAccount = null; }
|
|
246
|
+
if (legacyAccount) {
|
|
247
|
+
await ctx.secretStore.delete(SECRET_SERVICE, legacyAccount).catch(() => {});
|
|
248
|
+
}
|
|
249
|
+
delete site.auth.apppassword_fallback;
|
|
250
|
+
await writeConfig(ctx.configPath, config);
|
|
251
|
+
return {
|
|
252
|
+
exitCode: 0,
|
|
253
|
+
// Spec wording (binding):
|
|
254
|
+
// "✓ Migration complete. App Password removed. Site siteX now uses OAuth only."
|
|
255
|
+
lines: [`✓ Migration complete. App Password removed. Site ${siteId} now uses OAuth only.`],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = { run };
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
|
|
7
|
+
const { SCHEMA_VERSION, validate, emptyConfig } = require('../auth/schema-v2');
|
|
8
|
+
const { _atomicWrite } = require('../auth/config-migration');
|
|
9
|
+
const { AUTH_STATUS } = require('../auth/events');
|
|
10
|
+
const { makeRef } = require('../auth/secret-store');
|
|
11
|
+
const { CliError, EXIT_CONFIG } = require('./errors');
|
|
12
|
+
|
|
13
|
+
const SECRET_SERVICE = 'abilities-mcp';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read / write the v2 wp-sites.json file from a CLI command.
|
|
17
|
+
*
|
|
18
|
+
* This is the only place CLI commands touch the on-disk config — keeping it
|
|
19
|
+
* here means atomic-write, validation, and pathing rules live in one spot.
|
|
20
|
+
*
|
|
21
|
+
* Search order (matches lib/config.js so the MCP server and CLI agree on
|
|
22
|
+
* which file is "the" config):
|
|
23
|
+
* 1. --config=<path> explicit
|
|
24
|
+
* 2. <repo root>/wp-sites.json (alongside the bridge bin)
|
|
25
|
+
* 3. ~/.abilities-mcp/wp-sites.json
|
|
26
|
+
*
|
|
27
|
+
* If no file exists, `resolveConfigPath` returns the canonical home-dir path
|
|
28
|
+
* so commands like `add-site` write a fresh config there.
|
|
29
|
+
*
|
|
30
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
31
|
+
* @license GPL-2.0-or-later
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const HOME_DIR_REL = path.join('.abilities-mcp', 'wp-sites.json');
|
|
35
|
+
|
|
36
|
+
function _scriptRootConfig() {
|
|
37
|
+
// lib/cli/ → repo root
|
|
38
|
+
return path.resolve(__dirname, '..', '..', 'wp-sites.json');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _homeConfig() {
|
|
42
|
+
return path.join(os.homedir(), HOME_DIR_REL);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the on-disk wp-sites.json path. Returns the first match in the
|
|
47
|
+
* search order, or the home-dir path if none exists yet.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} [args]
|
|
50
|
+
* @param {string} [args.config]
|
|
51
|
+
* @returns {{ path: string, exists: boolean, source: 'explicit'|'script-root'|'home'|'home-default' }}
|
|
52
|
+
*/
|
|
53
|
+
function resolveConfigPath(args = {}) {
|
|
54
|
+
if (args.config) {
|
|
55
|
+
return {
|
|
56
|
+
path: path.resolve(args.config),
|
|
57
|
+
exists: fs.existsSync(args.config),
|
|
58
|
+
source: 'explicit',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const scriptCfg = _scriptRootConfig();
|
|
62
|
+
if (fs.existsSync(scriptCfg)) {
|
|
63
|
+
return { path: scriptCfg, exists: true, source: 'script-root' };
|
|
64
|
+
}
|
|
65
|
+
const homeCfg = _homeConfig();
|
|
66
|
+
if (fs.existsSync(homeCfg)) {
|
|
67
|
+
return { path: homeCfg, exists: true, source: 'home' };
|
|
68
|
+
}
|
|
69
|
+
return { path: homeCfg, exists: false, source: 'home-default' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read a v2 config file. Throws CliError on parse / validation failure.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} filePath
|
|
76
|
+
* @returns {object}
|
|
77
|
+
*/
|
|
78
|
+
function readConfig(filePath) {
|
|
79
|
+
let raw;
|
|
80
|
+
try {
|
|
81
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err.code === 'ENOENT') {
|
|
84
|
+
throw new CliError(`Config not found: ${filePath}`, {
|
|
85
|
+
exitCode: EXIT_CONFIG,
|
|
86
|
+
nextAction: 'Run: abilities-mcp add-site <url> to create your first site',
|
|
87
|
+
cause: err,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
throw new CliError(`Cannot read ${filePath}: ${err.message}`, {
|
|
91
|
+
exitCode: EXIT_CONFIG,
|
|
92
|
+
nextAction: 'Verify file permissions on the config path',
|
|
93
|
+
cause: err,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
let parsed;
|
|
97
|
+
try { parsed = JSON.parse(raw); }
|
|
98
|
+
catch (err) {
|
|
99
|
+
throw new CliError(`Cannot parse ${filePath}: ${err.message}`, {
|
|
100
|
+
exitCode: EXIT_CONFIG,
|
|
101
|
+
nextAction: 'Inspect the JSON syntax of the config file',
|
|
102
|
+
cause: err,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (parsed.schema_version !== SCHEMA_VERSION) {
|
|
106
|
+
throw new CliError(
|
|
107
|
+
`Config schema is v${parsed.schema_version || '<unknown>'} but CLI expects v${SCHEMA_VERSION}`,
|
|
108
|
+
{
|
|
109
|
+
exitCode: EXIT_CONFIG,
|
|
110
|
+
nextAction: 'Run the bridge once to trigger v1→v2 migration, or update the config manually',
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const v = validate(parsed);
|
|
115
|
+
if (!v.ok) {
|
|
116
|
+
throw new CliError(
|
|
117
|
+
`Config failed v2 validation:\n - ${v.errors.join('\n - ')}`,
|
|
118
|
+
{
|
|
119
|
+
exitCode: EXIT_CONFIG,
|
|
120
|
+
nextAction: 'Fix the listed validation errors in the config file',
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return parsed;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Atomically write a v2 config file. Validates before writing.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} filePath
|
|
131
|
+
* @param {object} config
|
|
132
|
+
*/
|
|
133
|
+
async function writeConfig(filePath, config) {
|
|
134
|
+
const v = validate(config);
|
|
135
|
+
if (!v.ok) {
|
|
136
|
+
throw new CliError(
|
|
137
|
+
`Refusing to write invalid v2 config:\n - ${v.errors.join('\n - ')}`,
|
|
138
|
+
{
|
|
139
|
+
exitCode: EXIT_CONFIG,
|
|
140
|
+
nextAction: 'This is a CLI bug — please report it. The on-disk config was not modified.',
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
// Ensure parent dir exists (e.g. ~/.abilities-mcp/ on first add-site).
|
|
145
|
+
const dir = path.dirname(filePath);
|
|
146
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
147
|
+
await _atomicWrite(filePath, config);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build an empty v2 config skeleton. Used the first time `add-site` runs and
|
|
152
|
+
* no config exists yet.
|
|
153
|
+
* @returns {object}
|
|
154
|
+
*/
|
|
155
|
+
function freshConfig() {
|
|
156
|
+
return emptyConfig();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Derive a site-id from a URL hostname. Mirrors `add-site`'s deriveSiteId so a
|
|
161
|
+
* `.mcpb`-seeded site collides with a CLI-added entry for the same host (the
|
|
162
|
+
* file-absence guard in `seedFromEnvIfMissing` prevents the collision in
|
|
163
|
+
* practice; the parity matters for `upgrade-auth <site-id>` to be intuitive).
|
|
164
|
+
*
|
|
165
|
+
* @param {string} siteUrl
|
|
166
|
+
* @returns {string|null} Site-id or null if URL is unparseable.
|
|
167
|
+
*/
|
|
168
|
+
function deriveSiteId(siteUrl) {
|
|
169
|
+
let host;
|
|
170
|
+
try { host = new URL(siteUrl).hostname; }
|
|
171
|
+
catch { return null; }
|
|
172
|
+
const trimmed = host.replace(/^www\./, '');
|
|
173
|
+
const dot = trimmed.indexOf('.');
|
|
174
|
+
return dot > 0 ? trimmed.slice(0, dot) : trimmed;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Seed wp-sites.json from env vars (`ABILITIES_MCP_URL/USERNAME/PASSWORD`)
|
|
179
|
+
* when the file doesn't yet exist. Used on first launch of the `.mcpb`
|
|
180
|
+
* extension so subsequent CLI commands (`list-sites`, `upgrade-auth`,
|
|
181
|
+
* `add-site`) operate on a single source of truth that already includes
|
|
182
|
+
* the site Claude Desktop is connected to.
|
|
183
|
+
*
|
|
184
|
+
* Behavior:
|
|
185
|
+
* - If `configPath` already exists → no-op. Operators who manage their own
|
|
186
|
+
* `wp-sites.json` are never overwritten.
|
|
187
|
+
* - If keytar isn't loadable on this host (e.g. the .mcpb is somehow
|
|
188
|
+
* running without the bundled keytar prebuild) → no-op. The bridge
|
|
189
|
+
* falls back to env-var-only mode. Graceful degradation.
|
|
190
|
+
* - If any of the three env vars is missing → no-op. Should not happen
|
|
191
|
+
* in the .mcpb path (manifest user_config marks all three required) but
|
|
192
|
+
* guards against partial env in other invocations.
|
|
193
|
+
* - Otherwise: writes the App Password to keychain via the shared
|
|
194
|
+
* SecretStore, builds a v2 apppassword entry shaped to pass both the
|
|
195
|
+
* schema-v2 validator and the bridge's runtime validateSiteConfig
|
|
196
|
+
* (matching the migration `_convertSite` pattern — preserves
|
|
197
|
+
* `transport: 'http'` and the legacy http block alongside the v2 auth
|
|
198
|
+
* block, with `password_ref` in both).
|
|
199
|
+
*
|
|
200
|
+
* If the keychain write succeeds but the file write fails the keychain
|
|
201
|
+
* entry is rolled back so the operator's keychain doesn't accumulate
|
|
202
|
+
* orphans on repeated failures.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} configPath Absolute path of the wp-sites.json to seed.
|
|
205
|
+
* @param {object} env Environment shape — expects ABILITIES_MCP_URL,
|
|
206
|
+
* ABILITIES_MCP_USERNAME, ABILITIES_MCP_PASSWORD.
|
|
207
|
+
* Defaults to process.env.
|
|
208
|
+
* @param {object} [deps]
|
|
209
|
+
* @param {object} [deps.secretStore] Inject for tests (a MemorySecretStore).
|
|
210
|
+
* Defaults to a fresh KeychainSecretStore.
|
|
211
|
+
* @returns {Promise<{
|
|
212
|
+
* seeded: boolean,
|
|
213
|
+
* reason?: 'exists'|'missing-env-vars'|'keytar-unavailable'|'invalid-url'|'error',
|
|
214
|
+
* siteId?: string,
|
|
215
|
+
* configPath?: string,
|
|
216
|
+
* error?: Error,
|
|
217
|
+
* }>}
|
|
218
|
+
*/
|
|
219
|
+
async function seedFromEnvIfMissing(configPath, env, deps = {}) {
|
|
220
|
+
if (!configPath) {
|
|
221
|
+
return { seeded: false, reason: 'missing-env-vars' };
|
|
222
|
+
}
|
|
223
|
+
if (fs.existsSync(configPath)) {
|
|
224
|
+
return { seeded: false, reason: 'exists' };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const url = env && env.ABILITIES_MCP_URL;
|
|
228
|
+
const username = env && env.ABILITIES_MCP_USERNAME;
|
|
229
|
+
const password = env && env.ABILITIES_MCP_PASSWORD;
|
|
230
|
+
if (!url || !username || !password) {
|
|
231
|
+
return { seeded: false, reason: 'missing-env-vars' };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let parsedUrl;
|
|
235
|
+
try { parsedUrl = new URL(url); }
|
|
236
|
+
catch { return { seeded: false, reason: 'invalid-url' }; }
|
|
237
|
+
|
|
238
|
+
const siteId = deriveSiteId(url);
|
|
239
|
+
if (!siteId) {
|
|
240
|
+
return { seeded: false, reason: 'invalid-url' };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Lazily build a SecretStore so SSH-only / env-var-only setups never load
|
|
244
|
+
// keytar on the seed path — the no-op "missing env vars" exit above keeps
|
|
245
|
+
// them out, but keep the require deferred for symmetry with the runtime.
|
|
246
|
+
let secretStore = deps.secretStore;
|
|
247
|
+
if (!secretStore) {
|
|
248
|
+
const { KeychainSecretStore } = require('../auth/keychain-secret-store');
|
|
249
|
+
secretStore = new KeychainSecretStore();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Probe keytar before writing. If it isn't loadable (e.g. the .mcpb
|
|
253
|
+
// somehow shipped without the bundled binary) we skip seeding — the
|
|
254
|
+
// bridge keeps working in env-var-only mode and the operator can run
|
|
255
|
+
// `abilities-mcp add-site` from a CLI install instead.
|
|
256
|
+
if (typeof secretStore.isAvailable === 'function') {
|
|
257
|
+
const available = await secretStore.isAvailable();
|
|
258
|
+
if (!available) {
|
|
259
|
+
return { seeded: false, reason: 'keytar-unavailable' };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Build the endpoint the same way buildEnvConfig does — strip trailing
|
|
264
|
+
// slash, append the adapter route. This is the URL the runtime will hit
|
|
265
|
+
// for App-Password requests.
|
|
266
|
+
const base = (parsedUrl.origin + parsedUrl.pathname).replace(/\/+$/, '');
|
|
267
|
+
const endpoint = `${base}/wp-json/mcp/mcp-adapter-default-server`;
|
|
268
|
+
const account = `${siteId}/apppassword`;
|
|
269
|
+
const passwordRef = makeRef(SECRET_SERVICE, account);
|
|
270
|
+
|
|
271
|
+
// Write the secret to keychain first. If the file write below fails we
|
|
272
|
+
// roll this back so the keychain doesn't accumulate orphan entries on
|
|
273
|
+
// repeated seed attempts.
|
|
274
|
+
try {
|
|
275
|
+
await secretStore.set(SECRET_SERVICE, account, password);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
return { seeded: false, reason: 'error', error: err };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const allowInsecure = parsedUrl.protocol === 'http:';
|
|
281
|
+
const site = {
|
|
282
|
+
label: parsedUrl.hostname,
|
|
283
|
+
url: parsedUrl.origin,
|
|
284
|
+
transport: 'http',
|
|
285
|
+
http: {
|
|
286
|
+
endpoint,
|
|
287
|
+
username,
|
|
288
|
+
password_ref: passwordRef,
|
|
289
|
+
},
|
|
290
|
+
auth: {
|
|
291
|
+
method: 'apppassword',
|
|
292
|
+
username,
|
|
293
|
+
password_ref: passwordRef,
|
|
294
|
+
},
|
|
295
|
+
auth_status: AUTH_STATUS.ACTIVE,
|
|
296
|
+
};
|
|
297
|
+
if (allowInsecure) site.allowInsecure = true;
|
|
298
|
+
|
|
299
|
+
const v2Config = {
|
|
300
|
+
$schema: 'https://wickedevolutions.com/schemas/abilities-mcp/wp-sites/v2.json',
|
|
301
|
+
schema_version: SCHEMA_VERSION,
|
|
302
|
+
defaultSite: siteId,
|
|
303
|
+
sites: { [siteId]: site },
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const dir = path.dirname(configPath);
|
|
308
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
309
|
+
await _atomicWrite(configPath, v2Config);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
// Roll back the keychain write so we don't leave an orphan secret.
|
|
312
|
+
try { await secretStore.delete(SECRET_SERVICE, account); }
|
|
313
|
+
catch { /* best-effort rollback */ }
|
|
314
|
+
return { seeded: false, reason: 'error', error: err };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { seeded: true, siteId, configPath };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
module.exports = {
|
|
321
|
+
resolveConfigPath,
|
|
322
|
+
readConfig,
|
|
323
|
+
writeConfig,
|
|
324
|
+
freshConfig,
|
|
325
|
+
seedFromEnvIfMissing,
|
|
326
|
+
deriveSiteId,
|
|
327
|
+
HOME_DIR_REL,
|
|
328
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
KeychainSecretStore,
|
|
7
|
+
MemorySecretStore,
|
|
8
|
+
FreshEachTimeIdentityProvider,
|
|
9
|
+
} = require('../auth');
|
|
10
|
+
const { resolveConfigPath } = require('./config-store');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* CliContext — DI container the subcommands use to reach the outside world.
|
|
14
|
+
*
|
|
15
|
+
* The router builds one CliContext per invocation and hands it to the chosen
|
|
16
|
+
* command. Tests build their own context with `MemorySecretStore` and
|
|
17
|
+
* dependency-injected helpers (clock, fetch, identity provider) so they never
|
|
18
|
+
* touch real keychain or network.
|
|
19
|
+
*
|
|
20
|
+
* The context owns:
|
|
21
|
+
* - secretStore SecretStore instance (KeychainSecretStore by default)
|
|
22
|
+
* - identityProvider BridgeIdentityProvider (FreshEachTime in v1.0)
|
|
23
|
+
* - configPath absolute path to wp-sites.json
|
|
24
|
+
* - debug boolean — when true, command errors include cause
|
|
25
|
+
* - softwareVersion bridge version (read from package.json)
|
|
26
|
+
* - hostnameLabel OS hostname, used in DCR client_name
|
|
27
|
+
* - userLabel username, used in DCR client_name
|
|
28
|
+
* - now () => number (Date.now), test seam
|
|
29
|
+
*
|
|
30
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
31
|
+
* @license GPL-2.0-or-later
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
function _readPackageVersion() {
|
|
35
|
+
try {
|
|
36
|
+
// eslint-disable-next-line global-require
|
|
37
|
+
return require('../../package.json').version;
|
|
38
|
+
} catch {
|
|
39
|
+
return '0.0.0';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build a real context for production CLI invocations.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} args Parsed CLI args (minimum: { config?, debug? })
|
|
47
|
+
* @returns {object} ctx
|
|
48
|
+
*/
|
|
49
|
+
function createContext(args = {}) {
|
|
50
|
+
const { path: configPath } = resolveConfigPath(args);
|
|
51
|
+
const secretStore = new KeychainSecretStore();
|
|
52
|
+
const identityProvider = new FreshEachTimeIdentityProvider({ store: secretStore });
|
|
53
|
+
return {
|
|
54
|
+
secretStore,
|
|
55
|
+
identityProvider,
|
|
56
|
+
configPath,
|
|
57
|
+
debug: !!args.debug,
|
|
58
|
+
allowInsecure: !!args.allowInsecure,
|
|
59
|
+
softwareVersion: _readPackageVersion(),
|
|
60
|
+
hostnameLabel: _safeHostname(),
|
|
61
|
+
userLabel: _safeUserLogin(),
|
|
62
|
+
now: () => Date.now(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build a test context. Uses MemorySecretStore unless caller overrides.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} [overrides]
|
|
70
|
+
* @returns {object}
|
|
71
|
+
*/
|
|
72
|
+
function createTestContext(overrides = {}) {
|
|
73
|
+
const secretStore = overrides.secretStore || new MemorySecretStore();
|
|
74
|
+
const identityProvider = overrides.identityProvider
|
|
75
|
+
|| new FreshEachTimeIdentityProvider({ store: secretStore });
|
|
76
|
+
return Object.assign({
|
|
77
|
+
secretStore,
|
|
78
|
+
identityProvider,
|
|
79
|
+
configPath: overrides.configPath || null,
|
|
80
|
+
debug: !!overrides.debug,
|
|
81
|
+
allowInsecure: overrides.allowInsecure !== false, // tests default to true
|
|
82
|
+
softwareVersion: overrides.softwareVersion || '1.4.0-test',
|
|
83
|
+
hostnameLabel: overrides.hostnameLabel || 'host.local',
|
|
84
|
+
userLabel: overrides.userLabel || 'tester',
|
|
85
|
+
now: overrides.now || (() => Date.now()),
|
|
86
|
+
}, overrides);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function _safeHostname() {
|
|
90
|
+
try { return os.hostname(); } catch { return 'host.local'; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function _safeUserLogin() {
|
|
94
|
+
try {
|
|
95
|
+
const u = os.userInfo();
|
|
96
|
+
return u.username || 'operator';
|
|
97
|
+
} catch {
|
|
98
|
+
return 'operator';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { createContext, createTestContext };
|