@wickedevolutions/abilities-mcp 1.3.1 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +88 -17
- package/abilities-mcp.js +182 -113
- package/lib/auth/bridge-identity-provider.js +34 -0
- package/lib/auth/browser-launcher.js +67 -0
- package/lib/auth/config-migration.js +322 -0
- package/lib/auth/dcr-client.js +123 -0
- package/lib/auth/discovery-client.js +273 -0
- package/lib/auth/errors.js +114 -0
- package/lib/auth/events.js +55 -0
- package/lib/auth/fresh-each-time-identity.js +101 -0
- package/lib/auth/http-json.js +151 -0
- package/lib/auth/index.js +88 -0
- package/lib/auth/keychain-secret-store.js +98 -0
- package/lib/auth/loopback-server.js +249 -0
- package/lib/auth/memory-secret-store.js +0 -0
- package/lib/auth/oauth-client.js +357 -0
- package/lib/auth/pkce.js +93 -0
- package/lib/auth/schema-v2.js +110 -0
- package/lib/auth/secret-store.js +78 -0
- package/lib/auth/token-manager.js +378 -0
- package/lib/cli/commands/add-site.js +226 -0
- package/lib/cli/commands/force-downgrade.js +93 -0
- package/lib/cli/commands/list-sites.js +93 -0
- package/lib/cli/commands/reauth.js +108 -0
- package/lib/cli/commands/revoke.js +127 -0
- package/lib/cli/commands/self-check.js +158 -0
- package/lib/cli/commands/test.js +174 -0
- package/lib/cli/commands/upgrade-auth.js +259 -0
- package/lib/cli/config-store.js +161 -0
- package/lib/cli/context.js +102 -0
- package/lib/cli/errors.js +227 -0
- package/lib/cli/index.js +173 -0
- package/lib/cli/output.js +175 -0
- package/lib/cli/parse-args.js +80 -0
- package/lib/config.js +248 -19
- package/lib/connection-pool.js +214 -11
- package/lib/router.js +29 -11
- package/lib/transports/oauth-http-transport.js +601 -0
- package/package.json +7 -2
|
@@ -0,0 +1,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 };
|