@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,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Format the operator-visible startup diagnostic line that names which config
|
|
7
|
+
* source `loadConfig` resolved to and what's in it.
|
|
8
|
+
*
|
|
9
|
+
* Output goes to stderr where Claude Desktop's MCP log captures it
|
|
10
|
+
* (visible in `mcp-server-WordPress (Abilities MCP).log` on macOS), so the
|
|
11
|
+
* operator can tell at a glance:
|
|
12
|
+
* - Whether the .mcpb extension is in env-var single-site mode or has
|
|
13
|
+
* handed off to a home-dir wp-sites.json.
|
|
14
|
+
* - How many sites are configured and what auth method each uses.
|
|
15
|
+
* - Which file path or env var is the source of truth right now.
|
|
16
|
+
*
|
|
17
|
+
* Discriminants set in `lib/config.js`:
|
|
18
|
+
* - 'explicit-config' — args.config / --config=<path>
|
|
19
|
+
* - 'script-adjacent' — wp-sites.json next to abilities-mcp.js
|
|
20
|
+
* - 'home-dir' — ~/.abilities-mcp/wp-sites.json
|
|
21
|
+
* - 'env-var' — ABILITIES_MCP_URL injected by Claude Desktop user_config
|
|
22
|
+
* - 'legacy-cli' — --host / --path (mcp-ssh-bridge backward compat)
|
|
23
|
+
*
|
|
24
|
+
* The line never includes secrets — only site IDs, auth methods, hostnames,
|
|
25
|
+
* tildified file paths, and counts.
|
|
26
|
+
*
|
|
27
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
28
|
+
* @license GPL-2.0-or-later
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Replace a leading $HOME prefix with `~/` so logs don't leak the operator's
|
|
33
|
+
* full username path. No-op for paths outside $HOME.
|
|
34
|
+
*/
|
|
35
|
+
function tildify(p) {
|
|
36
|
+
if (!p) return p;
|
|
37
|
+
const home = os.homedir();
|
|
38
|
+
if (home && (p === home || p.startsWith(home + '/'))) {
|
|
39
|
+
return '~' + p.slice(home.length);
|
|
40
|
+
}
|
|
41
|
+
return p;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Per-site short auth label: prefer auth.method (v2 schema), fall back to
|
|
46
|
+
* transport (v1 schema), 'unknown' if neither is set.
|
|
47
|
+
*/
|
|
48
|
+
function siteAuthLabel(site) {
|
|
49
|
+
if (site && site.auth && site.auth.method) return site.auth.method;
|
|
50
|
+
if (site && site.transport) return site.transport;
|
|
51
|
+
return 'unknown';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the Config-source line.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} config Output of `loadConfig` — must carry `_configSource`
|
|
58
|
+
* and `_configSourceLabel`.
|
|
59
|
+
* @returns {string} One-line operator diagnostic, no trailing newline.
|
|
60
|
+
*/
|
|
61
|
+
function formatConfigSourceLine(config) {
|
|
62
|
+
const source = config && config._configSource;
|
|
63
|
+
const rawLabel = (config && config._configSourceLabel) || '';
|
|
64
|
+
|
|
65
|
+
if (source === 'env-var') {
|
|
66
|
+
const site = config.sites && config.sites[config.defaultSite];
|
|
67
|
+
const username = (site && site.http && site.http.username) || '?';
|
|
68
|
+
return `Config source: ABILITIES_MCP_URL env var (single-site basic auth: ${rawLabel} as ${username})`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (source === 'legacy-cli') {
|
|
72
|
+
return `Config source: --host/--path legacy CLI (single-site SSH: ${rawLabel})`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// File-based: explicit-config / script-adjacent / home-dir
|
|
76
|
+
const label = tildify(rawLabel);
|
|
77
|
+
const siteEntries = Object.entries(config.sites || {}).map(
|
|
78
|
+
([id, site]) => `${id} ${siteAuthLabel(site)}`
|
|
79
|
+
);
|
|
80
|
+
const sitesHeader = siteEntries.length === 1 ? '1 site' : `${siteEntries.length} sites`;
|
|
81
|
+
const sourcePrefix = source ? `[${source}] ` : '';
|
|
82
|
+
return `Config source: ${sourcePrefix}${label} (${sitesHeader}: ${siteEntries.join(', ')})`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { formatConfigSourceLine, tildify, siteAuthLabel };
|
package/lib/config.js
CHANGED
|
@@ -1,40 +1,114 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
3
|
+
const { exec } = require('child_process');
|
|
4
|
+
const { promisify } = require('util');
|
|
4
5
|
const fs = require('fs');
|
|
6
|
+
const fsp = require('fs').promises;
|
|
5
7
|
const path = require('path');
|
|
6
8
|
const os = require('os');
|
|
7
9
|
|
|
10
|
+
const execAsync = promisify(exec);
|
|
11
|
+
|
|
8
12
|
/**
|
|
9
|
-
* Load configuration from wp-sites.json
|
|
13
|
+
* Load configuration from wp-sites.json, environment variables, or CLI args.
|
|
10
14
|
*
|
|
11
|
-
* Search order
|
|
15
|
+
* Search order:
|
|
12
16
|
* 1. --config=<path> explicit path
|
|
13
17
|
* 2. Same directory as abilities-mcp.js
|
|
14
18
|
* 3. ~/.abilities-mcp/wp-sites.json
|
|
19
|
+
* 4. ABILITIES_MCP_URL/USERNAME/PASSWORD env vars (single-site, .mcpb path)
|
|
20
|
+
* 5. --host/--path CLI args (legacy SSH single-site)
|
|
15
21
|
*
|
|
16
|
-
*
|
|
22
|
+
* Per Issue #5 / Phase E.2 (Stretch to Stable sprint): the startup chain
|
|
23
|
+
* — `resolveConfigFilePath`, `loadConfig`, `loadConfigFile`,
|
|
24
|
+
* `validateSiteConfig`, `resolvePassword` — is async so file reads and
|
|
25
|
+
* `passwordCommand` shell-outs do not block the event loop during boot.
|
|
17
26
|
*
|
|
18
27
|
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
19
28
|
* @license GPL-2.0-or-later
|
|
20
29
|
*/
|
|
21
|
-
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Async non-throwing existence check. Returns true iff the path resolves
|
|
33
|
+
* via fs.access(); any error (ENOENT, EACCES, …) yields false. Used by the
|
|
34
|
+
* search-order helpers which only care whether a candidate file is readable.
|
|
35
|
+
*/
|
|
36
|
+
async function _exists(p) {
|
|
37
|
+
try {
|
|
38
|
+
await fsp.access(p, fs.constants.F_OK);
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the on-disk wp-sites.json path that `loadConfig` would consume,
|
|
47
|
+
* without reading or validating it. Returns `null` when no file path applies
|
|
48
|
+
* (env-var single-site, legacy --host/--path, or no config at all).
|
|
49
|
+
*
|
|
50
|
+
* Used by the v1→v2 migration shim in `abilities-mcp.js` so the migration can
|
|
51
|
+
* run before `loadConfig` parses + validates a stale v1 file.
|
|
52
|
+
*
|
|
53
|
+
* Mirrors steps 1-3 of `loadConfig`'s search order. Steps 4-5 (env / legacy
|
|
54
|
+
* CLI) intentionally return `null` — there is no on-disk file to migrate.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} args
|
|
57
|
+
* @returns {Promise<string|null>} Absolute path of the resolved wp-sites.json, or null.
|
|
58
|
+
*/
|
|
59
|
+
async function resolveConfigFilePath(args) {
|
|
60
|
+
if (args && args.config) {
|
|
61
|
+
return path.resolve(args.config);
|
|
62
|
+
}
|
|
63
|
+
const scriptDir = path.resolve(__dirname, '..');
|
|
64
|
+
const scriptConfig = path.join(scriptDir, 'wp-sites.json');
|
|
65
|
+
if (await _exists(scriptConfig)) {
|
|
66
|
+
return scriptConfig;
|
|
67
|
+
}
|
|
68
|
+
const homeConfig = path.join(os.homedir(), '.abilities-mcp', 'wp-sites.json');
|
|
69
|
+
if (await _exists(homeConfig)) {
|
|
70
|
+
return homeConfig;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function loadConfig(args) {
|
|
22
76
|
// Explicit config path
|
|
23
77
|
if (args.config) {
|
|
24
|
-
return loadConfigFile(args.config);
|
|
78
|
+
return loadConfigFile(args.config, 'explicit-config');
|
|
25
79
|
}
|
|
26
80
|
|
|
27
81
|
// Check alongside script (lib/ → package root)
|
|
28
82
|
const scriptDir = path.resolve(__dirname, '..');
|
|
29
83
|
const scriptConfig = path.join(scriptDir, 'wp-sites.json');
|
|
30
|
-
if (
|
|
31
|
-
return loadConfigFile(scriptConfig);
|
|
84
|
+
if (await _exists(scriptConfig)) {
|
|
85
|
+
return loadConfigFile(scriptConfig, 'script-adjacent');
|
|
32
86
|
}
|
|
33
87
|
|
|
34
88
|
// Check home directory
|
|
35
89
|
const homeConfig = path.join(os.homedir(), '.abilities-mcp', 'wp-sites.json');
|
|
36
|
-
if (
|
|
37
|
-
return loadConfigFile(homeConfig);
|
|
90
|
+
if (await _exists(homeConfig)) {
|
|
91
|
+
return loadConfigFile(homeConfig, 'home-dir');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Env-var single-site config — covers the .mcpb install path and any
|
|
95
|
+
// env-var-based MCP client configuration (claude mcp add, Docker, etc.)
|
|
96
|
+
//
|
|
97
|
+
// First-launch seed (#34): if the .mcpb just installed and no home-dir
|
|
98
|
+
// wp-sites.json exists yet, seed one from the env vars so subsequent CLI
|
|
99
|
+
// commands (`list-sites`, `upgrade-auth`, `add-site`) operate on a single
|
|
100
|
+
// source of truth that already includes the site Claude Desktop is
|
|
101
|
+
// connected to. On success the bridge loads the freshly seeded file —
|
|
102
|
+
// same shape it would read on next restart — so the runtime path stays
|
|
103
|
+
// identical regardless of whether seeding just happened. On failure
|
|
104
|
+
// (keytar unavailable, file-already-exists, write error) the seed is a
|
|
105
|
+
// graceful no-op and we fall back to env-var-only mode below.
|
|
106
|
+
if (process.env.ABILITIES_MCP_URL) {
|
|
107
|
+
const seedResult = await _seedFromEnvIfMissing(homeConfig, process.env);
|
|
108
|
+
if (seedResult && seedResult.seeded) {
|
|
109
|
+
return loadConfigFile(homeConfig, 'home-dir');
|
|
110
|
+
}
|
|
111
|
+
return buildEnvConfig(process.env);
|
|
38
112
|
}
|
|
39
113
|
|
|
40
114
|
// Legacy CLI mode — single site from --host/--path
|
|
@@ -43,17 +117,90 @@ function loadConfig(args) {
|
|
|
43
117
|
}
|
|
44
118
|
|
|
45
119
|
throw new Error(
|
|
46
|
-
'No
|
|
47
|
-
'
|
|
120
|
+
'No configuration found.\n' +
|
|
121
|
+
'Provide one of: wp-sites.json, ABILITIES_MCP_URL+USERNAME+PASSWORD env vars, or --host/--path.'
|
|
48
122
|
);
|
|
49
123
|
}
|
|
50
124
|
|
|
51
|
-
|
|
52
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Build single-site config from env vars (ABILITIES_MCP_URL/USERNAME/PASSWORD).
|
|
127
|
+
*
|
|
128
|
+
* Auto-derives the MCP adapter endpoint from the site URL:
|
|
129
|
+
* https://example.com → https://example.com/wp-json/mcp/mcp-adapter-default-server
|
|
130
|
+
*
|
|
131
|
+
* This is the path used by .mcpb bundles installed in Claude Desktop and any
|
|
132
|
+
* other env-var-based MCP client configuration.
|
|
133
|
+
*/
|
|
134
|
+
function buildEnvConfig(env) {
|
|
135
|
+
const rawUrl = env.ABILITIES_MCP_URL;
|
|
136
|
+
const username = env.ABILITIES_MCP_USERNAME;
|
|
137
|
+
const password = env.ABILITIES_MCP_PASSWORD;
|
|
138
|
+
|
|
139
|
+
if (!username) {
|
|
140
|
+
throw new Error('ABILITIES_MCP_URL is set but ABILITIES_MCP_USERNAME is missing');
|
|
141
|
+
}
|
|
142
|
+
if (!password) {
|
|
143
|
+
throw new Error('ABILITIES_MCP_URL is set but ABILITIES_MCP_PASSWORD is missing');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let parsedUrl;
|
|
147
|
+
try {
|
|
148
|
+
parsedUrl = new URL(rawUrl);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
throw new Error(`ABILITIES_MCP_URL is not a valid URL: ${rawUrl}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const isHttps = parsedUrl.protocol === 'https:';
|
|
154
|
+
const isHttp = parsedUrl.protocol === 'http:';
|
|
155
|
+
if (!isHttps && !isHttp) {
|
|
156
|
+
throw new Error(`ABILITIES_MCP_URL must be http or https: ${rawUrl}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Strip trailing slash from origin+path, then append the adapter route.
|
|
160
|
+
const base = (parsedUrl.origin + parsedUrl.pathname).replace(/\/+$/, '');
|
|
161
|
+
const endpoint = `${base}/wp-json/mcp/mcp-adapter-default-server`;
|
|
162
|
+
|
|
163
|
+
const siteConfig = {
|
|
164
|
+
label: parsedUrl.hostname,
|
|
165
|
+
url: parsedUrl.origin,
|
|
166
|
+
transport: 'http',
|
|
167
|
+
http: {
|
|
168
|
+
endpoint,
|
|
169
|
+
username,
|
|
170
|
+
password,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Allow plain HTTP only when the operator explicitly opts in. The .mcpb path
|
|
175
|
+
// expects HTTPS by default; localhost dev gets a narrow exception.
|
|
176
|
+
if (isHttp) {
|
|
177
|
+
const isLocal = parsedUrl.hostname === 'localhost' || parsedUrl.hostname === '127.0.0.1';
|
|
178
|
+
if (!isLocal && env.ABILITIES_MCP_ALLOW_INSECURE !== 'true') {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`ABILITIES_MCP_URL is HTTP (not HTTPS): ${rawUrl}\n` +
|
|
181
|
+
`Set ABILITIES_MCP_ALLOW_INSECURE=true to allow plain HTTP.`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
siteConfig.allowInsecure = true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
defaultSite: 'default',
|
|
189
|
+
_isMultiSite: false,
|
|
190
|
+
_configSource: 'env-var',
|
|
191
|
+
_configSourceLabel: parsedUrl.hostname,
|
|
192
|
+
sites: {
|
|
193
|
+
default: siteConfig,
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function loadConfigFile(filePath, source = 'explicit-config') {
|
|
199
|
+
const raw = await fsp.readFile(filePath, 'utf8');
|
|
53
200
|
|
|
54
201
|
// Warn if config file is readable by group or world
|
|
55
202
|
try {
|
|
56
|
-
const stat =
|
|
203
|
+
const stat = await fsp.stat(filePath);
|
|
57
204
|
if (stat.mode & 0o077) {
|
|
58
205
|
process.stderr.write(
|
|
59
206
|
`WARNING: ${filePath} is readable by group/world (mode ${(stat.mode & 0o777).toString(8)}). ` +
|
|
@@ -78,17 +225,69 @@ function loadConfigFile(filePath) {
|
|
|
78
225
|
|
|
79
226
|
// Validate each site
|
|
80
227
|
for (const [key, site] of Object.entries(config.sites)) {
|
|
81
|
-
validateSiteConfig(key, site);
|
|
228
|
+
await validateSiteConfig(key, site);
|
|
82
229
|
}
|
|
83
230
|
|
|
84
231
|
config._isMultiSite = Object.keys(config.sites).length > 1 ||
|
|
85
232
|
Object.values(config.sites).some(s => s.multisite);
|
|
86
233
|
config._configPath = filePath;
|
|
234
|
+
config._configSource = source;
|
|
235
|
+
config._configSourceLabel = filePath;
|
|
87
236
|
|
|
88
237
|
return config;
|
|
89
238
|
}
|
|
90
239
|
|
|
91
|
-
function validateSiteConfig(key, site) {
|
|
240
|
+
async function validateSiteConfig(key, site) {
|
|
241
|
+
// v2 OAuth sites carry no transport block (Appendix F.5 + add-site flow).
|
|
242
|
+
// The runtime treats them as HTTP — endpoint comes from auth.mcp_resource
|
|
243
|
+
// or site.mcp_resource (resolved by the OAuth-aware transport).
|
|
244
|
+
if (site.auth && site.auth.method === 'oauth') {
|
|
245
|
+
if (!site.url) {
|
|
246
|
+
throw new Error(`Site "${key}" (oauth): requires "url"`);
|
|
247
|
+
}
|
|
248
|
+
if (!site.auth.access_token_ref || !site.auth.refresh_token_ref) {
|
|
249
|
+
throw new Error(`Site "${key}" (oauth): requires auth.access_token_ref and auth.refresh_token_ref`);
|
|
250
|
+
}
|
|
251
|
+
if (!site.mcp_resource) {
|
|
252
|
+
throw new Error(`Site "${key}" (oauth): requires mcp_resource (set during add-site / reauth)`);
|
|
253
|
+
}
|
|
254
|
+
if (!site.mcp_resource.startsWith('https://') && !site.allowInsecure) {
|
|
255
|
+
throw new Error(`Site "${key}" (oauth): mcp_resource is not HTTPS. Set "allowInsecure": true on the site to allow HTTP`);
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// v2 App-Password sites — produced by config-migration from v1 transport-style
|
|
261
|
+
// configs. Secret lives in keychain (auth.password_ref); the legacy http.password*
|
|
262
|
+
// fields are stripped during migration so the keychain is the sole source of
|
|
263
|
+
// truth. Carrier transport (http or ssh) is preserved for the runtime.
|
|
264
|
+
if (site.auth && site.auth.method === 'apppassword') {
|
|
265
|
+
if (!site.auth.username) {
|
|
266
|
+
throw new Error(`Site "${key}" (apppassword): requires auth.username`);
|
|
267
|
+
}
|
|
268
|
+
if (!site.auth.password_ref) {
|
|
269
|
+
throw new Error(`Site "${key}" (apppassword): requires auth.password_ref`);
|
|
270
|
+
}
|
|
271
|
+
if (!site.transport) {
|
|
272
|
+
throw new Error(`Site "${key}" (apppassword): missing "transport" (ssh or http)`);
|
|
273
|
+
}
|
|
274
|
+
if (site.transport === 'ssh') {
|
|
275
|
+
if (!site.ssh || !site.ssh.host || !site.ssh.path) {
|
|
276
|
+
throw new Error(`Site "${key}" (apppassword/ssh): requires ssh.host and ssh.path`);
|
|
277
|
+
}
|
|
278
|
+
} else if (site.transport === 'http') {
|
|
279
|
+
if (!site.http || !site.http.endpoint) {
|
|
280
|
+
throw new Error(`Site "${key}" (apppassword/http): requires http.endpoint`);
|
|
281
|
+
}
|
|
282
|
+
if (!site.http.endpoint.startsWith('https://') && !site.allowInsecure) {
|
|
283
|
+
throw new Error(`Site "${key}" (apppassword/http): endpoint is not HTTPS. Set "allowInsecure": true on the site to allow HTTP`);
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
throw new Error(`Site "${key}" (apppassword): unknown transport "${site.transport}" (use ssh or http)`);
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
92
291
|
if (!site.transport) {
|
|
93
292
|
throw new Error(`Site "${key}": missing "transport" (ssh or http)`);
|
|
94
293
|
}
|
|
@@ -119,6 +318,8 @@ function buildLegacyConfig(args) {
|
|
|
119
318
|
return {
|
|
120
319
|
defaultSite: 'default',
|
|
121
320
|
_isMultiSite: false,
|
|
321
|
+
_configSource: 'legacy-cli',
|
|
322
|
+
_configSourceLabel: args.host,
|
|
122
323
|
sites: {
|
|
123
324
|
default: {
|
|
124
325
|
label: args.host,
|
|
@@ -137,6 +338,7 @@ function buildLegacyConfig(args) {
|
|
|
137
338
|
|
|
138
339
|
/**
|
|
139
340
|
* Resolve a composite site key like "wicked.community" into config + subsite URL.
|
|
341
|
+
* Pure in-memory dispatch — kept synchronous since it has no I/O.
|
|
140
342
|
*/
|
|
141
343
|
function resolveSiteKey(config, compositeKey) {
|
|
142
344
|
// Direct match — "helena" or "wicked"
|
|
@@ -189,17 +391,23 @@ function buildSiteKeyEnum(config) {
|
|
|
189
391
|
}
|
|
190
392
|
|
|
191
393
|
/**
|
|
192
|
-
* Resolve the password for an HTTP transport config, supporting
|
|
193
|
-
*
|
|
394
|
+
* Resolve the password for an HTTP transport config, supporting plaintext
|
|
395
|
+
* password, environment variable, or shell command.
|
|
396
|
+
*
|
|
397
|
+
* `passwordCommand` is dispatched through `util.promisify(exec)` so the shell
|
|
398
|
+
* interprets the command string the same way the previous `execSync` did
|
|
399
|
+
* (operators rely on pipes, redirects, and command chaining — e.g.
|
|
400
|
+
* `op read 'op://Vault/foo' | tr -d '\n'` — which `execFile` cannot run).
|
|
194
401
|
*/
|
|
195
|
-
function resolvePassword(httpConfig) {
|
|
402
|
+
async function resolvePassword(httpConfig) {
|
|
196
403
|
if (httpConfig.passwordEnv) {
|
|
197
404
|
const val = process.env[httpConfig.passwordEnv];
|
|
198
405
|
if (!val) throw new Error(`Environment variable ${httpConfig.passwordEnv} is not set`);
|
|
199
406
|
return val;
|
|
200
407
|
}
|
|
201
408
|
if (httpConfig.passwordCommand) {
|
|
202
|
-
|
|
409
|
+
const { stdout } = await execAsync(httpConfig.passwordCommand, { encoding: 'utf8' });
|
|
410
|
+
return stdout.trim();
|
|
203
411
|
}
|
|
204
412
|
if (httpConfig.password) {
|
|
205
413
|
return httpConfig.password;
|
|
@@ -207,4 +415,56 @@ function resolvePassword(httpConfig) {
|
|
|
207
415
|
throw new Error('No password, passwordEnv, or passwordCommand configured');
|
|
208
416
|
}
|
|
209
417
|
|
|
210
|
-
|
|
418
|
+
/**
|
|
419
|
+
* Resolve a site's HTTP password, dispatching on schema shape.
|
|
420
|
+
*
|
|
421
|
+
* v2 App-Password sites (auth.method === 'apppassword' + auth.password_ref)
|
|
422
|
+
* read the secret from the keychain via the SecretStore. v1 carriers without
|
|
423
|
+
* a ref fall back to the `resolvePassword(http)` resolver.
|
|
424
|
+
*
|
|
425
|
+
* @param {object} site
|
|
426
|
+
* @param {object} [secretStore] SecretStore instance. Lazily defaults to
|
|
427
|
+
* KeychainSecretStore when an apppassword path
|
|
428
|
+
* runs and no store was supplied — preserves
|
|
429
|
+
* the "no keytar for SSH-only / v1-only setups"
|
|
430
|
+
* property by deferring the require to the
|
|
431
|
+
* point of need.
|
|
432
|
+
* @returns {Promise<string>}
|
|
433
|
+
*/
|
|
434
|
+
async function resolveSitePassword(site, secretStore) {
|
|
435
|
+
if (site && site.auth && site.auth.method === 'apppassword' && site.auth.password_ref) {
|
|
436
|
+
let store = secretStore;
|
|
437
|
+
if (!store) {
|
|
438
|
+
const { KeychainSecretStore } = require('./auth/keychain-secret-store');
|
|
439
|
+
store = new KeychainSecretStore();
|
|
440
|
+
}
|
|
441
|
+
const { resolveRef } = require('./auth/secret-store');
|
|
442
|
+
return resolveRef(store, site.auth.password_ref);
|
|
443
|
+
}
|
|
444
|
+
if (site && site.http) {
|
|
445
|
+
return resolvePassword(site.http);
|
|
446
|
+
}
|
|
447
|
+
throw new Error('No password source configured for site');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Lazy wrapper around `lib/cli/config-store.js#seedFromEnvIfMissing`. Only
|
|
452
|
+
* loads the CLI config-store + KeychainSecretStore modules when the env-var
|
|
453
|
+
* branch of `loadConfig` actually fires — so SSH-only, explicit-config, and
|
|
454
|
+
* legacy-CLI install paths never pay the keytar import cost.
|
|
455
|
+
*/
|
|
456
|
+
async function _seedFromEnvIfMissing(configPath, env) {
|
|
457
|
+
const { seedFromEnvIfMissing } = require('./cli/config-store');
|
|
458
|
+
return seedFromEnvIfMissing(configPath, env);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
module.exports = {
|
|
462
|
+
loadConfig,
|
|
463
|
+
resolveConfigFilePath,
|
|
464
|
+
resolvePassword,
|
|
465
|
+
resolveSitePassword,
|
|
466
|
+
resolveSiteKey,
|
|
467
|
+
buildSiteKeyEnum,
|
|
468
|
+
buildEnvConfig,
|
|
469
|
+
validateSiteConfig,
|
|
470
|
+
};
|