@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +88 -17
  3. package/abilities-mcp.js +191 -114
  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 +265 -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 +328 -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-source-line.js +85 -0
  37. package/lib/config.js +282 -22
  38. package/lib/connection-pool.js +214 -11
  39. package/lib/router.js +29 -11
  40. package/lib/transports/oauth-http-transport.js +601 -0
  41. 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 };