@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +88 -17
  3. package/abilities-mcp.js +182 -113
  4. package/lib/auth/bridge-identity-provider.js +34 -0
  5. package/lib/auth/browser-launcher.js +67 -0
  6. package/lib/auth/config-migration.js +322 -0
  7. package/lib/auth/dcr-client.js +123 -0
  8. package/lib/auth/discovery-client.js +273 -0
  9. package/lib/auth/errors.js +114 -0
  10. package/lib/auth/events.js +55 -0
  11. package/lib/auth/fresh-each-time-identity.js +101 -0
  12. package/lib/auth/http-json.js +151 -0
  13. package/lib/auth/index.js +88 -0
  14. package/lib/auth/keychain-secret-store.js +98 -0
  15. package/lib/auth/loopback-server.js +249 -0
  16. package/lib/auth/memory-secret-store.js +0 -0
  17. package/lib/auth/oauth-client.js +357 -0
  18. package/lib/auth/pkce.js +93 -0
  19. package/lib/auth/schema-v2.js +110 -0
  20. package/lib/auth/secret-store.js +78 -0
  21. package/lib/auth/token-manager.js +378 -0
  22. package/lib/cli/commands/add-site.js +226 -0
  23. package/lib/cli/commands/force-downgrade.js +93 -0
  24. package/lib/cli/commands/list-sites.js +93 -0
  25. package/lib/cli/commands/reauth.js +108 -0
  26. package/lib/cli/commands/revoke.js +127 -0
  27. package/lib/cli/commands/self-check.js +158 -0
  28. package/lib/cli/commands/test.js +174 -0
  29. package/lib/cli/commands/upgrade-auth.js +259 -0
  30. package/lib/cli/config-store.js +161 -0
  31. package/lib/cli/context.js +102 -0
  32. package/lib/cli/errors.js +227 -0
  33. package/lib/cli/index.js +173 -0
  34. package/lib/cli/output.js +175 -0
  35. package/lib/cli/parse-args.js +80 -0
  36. package/lib/config.js +248 -19
  37. package/lib/connection-pool.js +214 -11
  38. package/lib/router.js +29 -11
  39. package/lib/transports/oauth-http-transport.js +601 -0
  40. package/package.json +7 -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,161 @@
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 { CliError, EXIT_CONFIG } = require('./errors');
10
+
11
+ /**
12
+ * Read / write the v2 wp-sites.json file from a CLI command.
13
+ *
14
+ * This is the only place CLI commands touch the on-disk config — keeping it
15
+ * here means atomic-write, validation, and pathing rules live in one spot.
16
+ *
17
+ * Search order (matches lib/config.js so the MCP server and CLI agree on
18
+ * which file is "the" config):
19
+ * 1. --config=<path> explicit
20
+ * 2. <repo root>/wp-sites.json (alongside the bridge bin)
21
+ * 3. ~/.abilities-mcp/wp-sites.json
22
+ *
23
+ * If no file exists, `resolveConfigPath` returns the canonical home-dir path
24
+ * so commands like `add-site` write a fresh config there.
25
+ *
26
+ * Copyright (C) 2026 Influencentricity | Wicked Evolutions
27
+ * @license GPL-2.0-or-later
28
+ */
29
+
30
+ const HOME_DIR_REL = path.join('.abilities-mcp', 'wp-sites.json');
31
+
32
+ function _scriptRootConfig() {
33
+ // lib/cli/ → repo root
34
+ return path.resolve(__dirname, '..', '..', 'wp-sites.json');
35
+ }
36
+
37
+ function _homeConfig() {
38
+ return path.join(os.homedir(), HOME_DIR_REL);
39
+ }
40
+
41
+ /**
42
+ * Resolve the on-disk wp-sites.json path. Returns the first match in the
43
+ * search order, or the home-dir path if none exists yet.
44
+ *
45
+ * @param {object} [args]
46
+ * @param {string} [args.config]
47
+ * @returns {{ path: string, exists: boolean, source: 'explicit'|'script-root'|'home'|'home-default' }}
48
+ */
49
+ function resolveConfigPath(args = {}) {
50
+ if (args.config) {
51
+ return {
52
+ path: path.resolve(args.config),
53
+ exists: fs.existsSync(args.config),
54
+ source: 'explicit',
55
+ };
56
+ }
57
+ const scriptCfg = _scriptRootConfig();
58
+ if (fs.existsSync(scriptCfg)) {
59
+ return { path: scriptCfg, exists: true, source: 'script-root' };
60
+ }
61
+ const homeCfg = _homeConfig();
62
+ if (fs.existsSync(homeCfg)) {
63
+ return { path: homeCfg, exists: true, source: 'home' };
64
+ }
65
+ return { path: homeCfg, exists: false, source: 'home-default' };
66
+ }
67
+
68
+ /**
69
+ * Read a v2 config file. Throws CliError on parse / validation failure.
70
+ *
71
+ * @param {string} filePath
72
+ * @returns {object}
73
+ */
74
+ function readConfig(filePath) {
75
+ let raw;
76
+ try {
77
+ raw = fs.readFileSync(filePath, 'utf8');
78
+ } catch (err) {
79
+ if (err.code === 'ENOENT') {
80
+ throw new CliError(`Config not found: ${filePath}`, {
81
+ exitCode: EXIT_CONFIG,
82
+ nextAction: 'Run: abilities-mcp add-site <url> to create your first site',
83
+ cause: err,
84
+ });
85
+ }
86
+ throw new CliError(`Cannot read ${filePath}: ${err.message}`, {
87
+ exitCode: EXIT_CONFIG,
88
+ nextAction: 'Verify file permissions on the config path',
89
+ cause: err,
90
+ });
91
+ }
92
+ let parsed;
93
+ try { parsed = JSON.parse(raw); }
94
+ catch (err) {
95
+ throw new CliError(`Cannot parse ${filePath}: ${err.message}`, {
96
+ exitCode: EXIT_CONFIG,
97
+ nextAction: 'Inspect the JSON syntax of the config file',
98
+ cause: err,
99
+ });
100
+ }
101
+ if (parsed.schema_version !== SCHEMA_VERSION) {
102
+ throw new CliError(
103
+ `Config schema is v${parsed.schema_version || '<unknown>'} but CLI expects v${SCHEMA_VERSION}`,
104
+ {
105
+ exitCode: EXIT_CONFIG,
106
+ nextAction: 'Run the bridge once to trigger v1→v2 migration, or update the config manually',
107
+ }
108
+ );
109
+ }
110
+ const v = validate(parsed);
111
+ if (!v.ok) {
112
+ throw new CliError(
113
+ `Config failed v2 validation:\n - ${v.errors.join('\n - ')}`,
114
+ {
115
+ exitCode: EXIT_CONFIG,
116
+ nextAction: 'Fix the listed validation errors in the config file',
117
+ }
118
+ );
119
+ }
120
+ return parsed;
121
+ }
122
+
123
+ /**
124
+ * Atomically write a v2 config file. Validates before writing.
125
+ *
126
+ * @param {string} filePath
127
+ * @param {object} config
128
+ */
129
+ async function writeConfig(filePath, config) {
130
+ const v = validate(config);
131
+ if (!v.ok) {
132
+ throw new CliError(
133
+ `Refusing to write invalid v2 config:\n - ${v.errors.join('\n - ')}`,
134
+ {
135
+ exitCode: EXIT_CONFIG,
136
+ nextAction: 'This is a CLI bug — please report it. The on-disk config was not modified.',
137
+ }
138
+ );
139
+ }
140
+ // Ensure parent dir exists (e.g. ~/.abilities-mcp/ on first add-site).
141
+ const dir = path.dirname(filePath);
142
+ await fs.promises.mkdir(dir, { recursive: true });
143
+ await _atomicWrite(filePath, config);
144
+ }
145
+
146
+ /**
147
+ * Build an empty v2 config skeleton. Used the first time `add-site` runs and
148
+ * no config exists yet.
149
+ * @returns {object}
150
+ */
151
+ function freshConfig() {
152
+ return emptyConfig();
153
+ }
154
+
155
+ module.exports = {
156
+ resolveConfigPath,
157
+ readConfig,
158
+ writeConfig,
159
+ freshConfig,
160
+ HOME_DIR_REL,
161
+ };
@@ -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 };