fraim 2.0.326 → 2.0.328
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/dist/src/api/payment/webhook.js +7 -6
- package/dist/src/cli/commands/first-run.js +1 -1
- package/dist/src/cli/commands/sync.js +11 -2
- package/dist/src/cli/doctor/checks/fraim-mcp-diagnostics.js +17 -10
- package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +20 -2
- package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +200 -130
- package/dist/src/cli/mcp/mcp-server-registry.js +1 -6
- package/dist/src/cli/utils/managed-agent-install.js +5 -1
- package/dist/src/cli/utils/managed-agent-paths.js +14 -3
- package/dist/src/cli/utils/managed-node-runtime.js +22 -19
- package/dist/src/cli/utils/persist-shell-path.js +121 -0
- package/dist/src/core/utils/redact-secrets.js +89 -0
- package/dist/src/first-run/session-service.js +25 -145
- package/dist/src/local-mcp-server/stdio-server.js +19 -6
- package/dist/src/middleware/auth.js +4 -3
- package/dist/src/middleware/logger.js +7 -24
- package/dist/src/middleware/telemetry.js +2 -1
- package/dist/src/services/installer-service.js +5 -47
- package/dist/src/services/session-service.js +2 -1
- package/package.json +1 -1
|
@@ -11,6 +11,7 @@ exports.resolveSubscriptionNextBillingDateWithInvoiceFallback = resolveSubscript
|
|
|
11
11
|
exports.buildCurrentPeriodEndUpdate = buildCurrentPeriodEndUpdate;
|
|
12
12
|
exports.resolveInvoicePaidNextBillingDate = resolveInvoicePaidNextBillingDate;
|
|
13
13
|
const stripe_1 = require("../../config/stripe");
|
|
14
|
+
const redact_secrets_1 = require("../../core/utils/redact-secrets");
|
|
14
15
|
const feature_flags_1 = require("../../config/feature-flags");
|
|
15
16
|
const email_service_1 = require("../../services/email-service");
|
|
16
17
|
const dashboard_access_1 = require("../../services/dashboard-access");
|
|
@@ -273,7 +274,7 @@ async function handleSubscriptionCreatedOrUpdated(subscription, dbService) {
|
|
|
273
274
|
expiresAt: null, // Subscriptions don't have fixed expiration
|
|
274
275
|
cancelAt: subscription.cancel_at ? new Date(subscription.cancel_at * 1000) : null
|
|
275
276
|
});
|
|
276
|
-
console.log(`✅ Upgraded trial key to paid subscription: ${apiKey.key}`);
|
|
277
|
+
console.log(`✅ Upgraded trial key to paid subscription: ${(0, redact_secrets_1.redactApiKey)(apiKey.key)}`);
|
|
277
278
|
}
|
|
278
279
|
else {
|
|
279
280
|
// Create new API key for subscription (shouldn't happen in normal flow)
|
|
@@ -295,7 +296,7 @@ async function handleSubscriptionCreatedOrUpdated(subscription, dbService) {
|
|
|
295
296
|
apiCallCount: 0,
|
|
296
297
|
personaSystemActive: (0, feature_flags_1.isPersonaEntitlementsEnabled)() || undefined
|
|
297
298
|
});
|
|
298
|
-
console.log(`✅ Created new API key for subscription: ${newKey}`);
|
|
299
|
+
console.log(`✅ Created new API key for subscription: ${(0, redact_secrets_1.redactApiKey)(newKey)}`);
|
|
299
300
|
apiKey = { key: newKey, userId: email };
|
|
300
301
|
}
|
|
301
302
|
}
|
|
@@ -358,7 +359,7 @@ async function handleSubscriptionDeleted(subscription, dbService) {
|
|
|
358
359
|
// Send cancellation email
|
|
359
360
|
const emailService = new email_service_1.EmailService();
|
|
360
361
|
await emailService.sendSubscriptionCancelled(apiKey.userId, new Date());
|
|
361
|
-
console.log(`✅ API key expired due to subscription cancellation: ${apiKey.key}`);
|
|
362
|
+
console.log(`✅ API key expired due to subscription cancellation: ${(0, redact_secrets_1.redactApiKey)(apiKey.key)}`);
|
|
362
363
|
}
|
|
363
364
|
async function handleInvoicePaid(invoice, dbService) {
|
|
364
365
|
console.log(`✅ Invoice paid: ${invoice.id}`);
|
|
@@ -438,7 +439,7 @@ async function handleInvoicePaid(invoice, dbService) {
|
|
|
438
439
|
suspendedAt: null,
|
|
439
440
|
suspensionReason: null
|
|
440
441
|
});
|
|
441
|
-
console.log(`✅ API key restored after payment: ${apiKey.key}`);
|
|
442
|
+
console.log(`✅ API key restored after payment: ${(0, redact_secrets_1.redactApiKey)(apiKey.key)}`);
|
|
442
443
|
// Send restoration email
|
|
443
444
|
const emailService = new email_service_1.EmailService();
|
|
444
445
|
const restorationBillingDate = nextBillingDate || new Date();
|
|
@@ -467,7 +468,7 @@ async function handleInvoicePaid(invoice, dbService) {
|
|
|
467
468
|
}
|
|
468
469
|
// Regular renewal
|
|
469
470
|
{
|
|
470
|
-
console.log(`✅ Subscription renewed: ${apiKey.key}`);
|
|
471
|
+
console.log(`✅ Subscription renewed: ${(0, redact_secrets_1.redactApiKey)(apiKey.key)}`);
|
|
471
472
|
if (nextBillingDate) {
|
|
472
473
|
await dbService.updateApiKey(apiKey.key, buildCurrentPeriodEndUpdate(nextBillingDate));
|
|
473
474
|
}
|
|
@@ -532,7 +533,7 @@ async function handleInvoicePaymentFailed(invoice, dbService) {
|
|
|
532
533
|
suspendedAt: new Date(),
|
|
533
534
|
suspensionReason: 'payment_failed'
|
|
534
535
|
});
|
|
535
|
-
console.log(`✅ API key suspended due to payment failure: ${apiKey.key}`);
|
|
536
|
+
console.log(`✅ API key suspended due to payment failure: ${(0, redact_secrets_1.redactApiKey)(apiKey.key)}`);
|
|
536
537
|
// Send payment failure email with 7-day grace period
|
|
537
538
|
const emailService = new email_service_1.EmailService();
|
|
538
539
|
const gracePeriodEndsAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
|
@@ -34,7 +34,7 @@ function openBrowser(url) {
|
|
|
34
34
|
}
|
|
35
35
|
const runFirstRun = async (options) => {
|
|
36
36
|
// Issue #646: the key is optional. When launched by the no-terminal macOS
|
|
37
|
-
// installer (.
|
|
37
|
+
// installer (.dmg), no key is passed — the wizard prompts the user to paste it.
|
|
38
38
|
const key = options.key || process.env.FRAIM_API_KEY || process.env.FRAIM_SETUP_KEY || process.env.FRAIM_INSTALL_KEY || '';
|
|
39
39
|
const sessionService = new session_service_1.FirstRunSessionService({
|
|
40
40
|
key,
|
|
@@ -376,9 +376,18 @@ const runSync = async (options) => {
|
|
|
376
376
|
await refreshOrgCache(remoteUrl, apiKey);
|
|
377
377
|
// 1d. Refresh manager home
|
|
378
378
|
await refreshManagerCache(remoteUrl, apiKey);
|
|
379
|
-
// 1e. Refresh MCP proxy launcher
|
|
380
|
-
|
|
379
|
+
// 1e. Refresh MCP proxy launcher, and record the version this sync is running as the one
|
|
380
|
+
// the launcher should use next. Issue #1354: the launcher reuses its recorded version inside
|
|
381
|
+
// a freshness window instead of resolving npm on every launch, so without this a published
|
|
382
|
+
// fix reaches an installed machine up to a window late and nothing the user can run shortens
|
|
383
|
+
// that. Writing the record here makes `npx fraim@latest sync` the deliberate way to pull a
|
|
384
|
+
// release through immediately: it resolves latest, runs as latest, and records latest, which
|
|
385
|
+
// the next MCP server spawn picks up at once.
|
|
386
|
+
const { ensureFraimMcpLatestLauncher, recordFraimMcpLauncherVersion } = await Promise.resolve().then(() => __importStar(require('../mcp/fraim-mcp-latest-launcher')));
|
|
381
387
|
ensureFraimMcpLatestLauncher();
|
|
388
|
+
if (recordFraimMcpLauncherVersion((0, version_utils_1.getFraimVersion)())) {
|
|
389
|
+
console.log(chalk_1.default.green(` MCP launcher will use fraim@${(0, version_utils_1.getFraimVersion)()} on its next launch`));
|
|
390
|
+
}
|
|
382
391
|
// 1f. Ensure user-level directories and runtime dependencies (Issue #1508:
|
|
383
392
|
// synced scripts like the DOCX review layer require npm deps such as
|
|
384
393
|
// adm-zip; a plain sync must install them, not just create directories)
|
|
@@ -12,15 +12,14 @@ exports.buildAgentDebugSteps = buildAgentDebugSteps;
|
|
|
12
12
|
exports.interpretationForClassification = interpretationForClassification;
|
|
13
13
|
exports.analyzeFraimProxyLog = analyzeFraimProxyLog;
|
|
14
14
|
exports.diagnoseFraimServerConfigConsistency = diagnoseFraimServerConfigConsistency;
|
|
15
|
-
const crypto_1 = __importDefault(require("crypto"));
|
|
16
15
|
const fs_1 = __importDefault(require("fs"));
|
|
16
|
+
const redact_secrets_1 = require("../../../core/utils/redact-secrets");
|
|
17
17
|
exports.DEFAULT_FRAIM_REMOTE_URL = 'https://fraim.wellnessatwork.me';
|
|
18
18
|
exports.FRAIM_PROXY_LOG_FILE = 'fraim-mcp-proxy.log';
|
|
19
19
|
function fingerprintSecret(secret) {
|
|
20
20
|
return {
|
|
21
|
-
|
|
22
|
-
length: secret.length
|
|
23
|
-
sha256: crypto_1.default.createHash('sha256').update(secret).digest('hex').slice(0, 16)
|
|
21
|
+
ref: (0, redact_secrets_1.redactApiKey)(secret),
|
|
22
|
+
length: secret.length
|
|
24
23
|
};
|
|
25
24
|
}
|
|
26
25
|
function normalizeUrl(url) {
|
|
@@ -33,9 +32,11 @@ function appendLimited(current, chunk, maxChars = 8000) {
|
|
|
33
32
|
function sanitizeDiagnosticText(text, knownSecrets = []) {
|
|
34
33
|
let sanitized = text;
|
|
35
34
|
for (const secret of knownSecrets.filter(Boolean)) {
|
|
36
|
-
sanitized = sanitized.split(secret).join(
|
|
35
|
+
sanitized = sanitized.split(secret).join((0, redact_secrets_1.redactApiKey)(secret));
|
|
37
36
|
}
|
|
38
|
-
|
|
37
|
+
// Issue #1701: a truncated key is still disclosed key material. Substitute the
|
|
38
|
+
// same reference the rest of FRAIM logs so diagnostics stay correlatable.
|
|
39
|
+
return sanitized.replace(/fraim_[A-Za-z0-9_-]+/g, (match) => (0, redact_secrets_1.redactApiKey)(match));
|
|
39
40
|
}
|
|
40
41
|
function readFileTailUtf8(filePath, maxBytes = 256 * 1024) {
|
|
41
42
|
const stat = fs_1.default.statSync(filePath);
|
|
@@ -133,7 +134,13 @@ function interpretationForClassification(classification) {
|
|
|
133
134
|
return 'The proxy log did not match a known FRAIM MCP startup pattern.';
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
|
-
function analyzeFraimProxyLog(logContent,
|
|
137
|
+
function analyzeFraimProxyLog(logContent,
|
|
138
|
+
/**
|
|
139
|
+
* `expectedKeyRef` is the redacted reference the proxy writes in its per-line
|
|
140
|
+
* `[...]` segment (issue #1701), not a raw key. Callers holding a raw key must
|
|
141
|
+
* pass `redactApiKey(key)`.
|
|
142
|
+
*/
|
|
143
|
+
options = {}) {
|
|
137
144
|
const maxLines = options.maxLines ?? 300;
|
|
138
145
|
const allLines = logContent.split(/\r?\n/).map(line => line.trim()).filter(Boolean).slice(-maxLines);
|
|
139
146
|
if (allLines.length === 0) {
|
|
@@ -152,10 +159,10 @@ function analyzeFraimProxyLog(logContent, options = {}) {
|
|
|
152
159
|
};
|
|
153
160
|
}
|
|
154
161
|
const keyLineRegex = /^\S+\s+\[([^\]]+)\]\s+(.*)$/;
|
|
155
|
-
const matchingLines = options.
|
|
156
|
-
? allLines.filter(line => keyLineRegex.exec(line)?.[1] === options.
|
|
162
|
+
const matchingLines = options.expectedKeyRef
|
|
163
|
+
? allLines.filter(line => keyLineRegex.exec(line)?.[1] === options.expectedKeyRef)
|
|
157
164
|
: allLines;
|
|
158
|
-
if (options.
|
|
165
|
+
if (options.expectedKeyRef && matchingLines.length === 0) {
|
|
159
166
|
const classification = 'no-log-lines-for-expected-key';
|
|
160
167
|
return {
|
|
161
168
|
classification,
|
|
@@ -55,6 +55,7 @@ const child_process_1 = require("child_process");
|
|
|
55
55
|
const win32_cmd_spawn_1 = require("../../utils/win32-cmd-spawn");
|
|
56
56
|
const axios_1 = __importDefault(require("axios"));
|
|
57
57
|
const toml = __importStar(require("toml"));
|
|
58
|
+
const redact_secrets_1 = require("../../../core/utils/redact-secrets");
|
|
58
59
|
const ide_detector_1 = require("../../setup/ide-detector");
|
|
59
60
|
const fraim_mcp_latest_launcher_1 = require("../../mcp/fraim-mcp-latest-launcher");
|
|
60
61
|
const fraim_mcp_diagnostics_1 = require("./fraim-mcp-diagnostics");
|
|
@@ -150,6 +151,22 @@ function diagnoseFraimMcpLaunchPlan(fraimServer, platform = process.platform, np
|
|
|
150
151
|
}
|
|
151
152
|
};
|
|
152
153
|
}
|
|
154
|
+
const shimSource = fs_1.default.readFileSync(command, 'utf8');
|
|
155
|
+
if (/ELECTRON_RUN_AS_NODE|app\.asar/i.test(shimSource)) {
|
|
156
|
+
return {
|
|
157
|
+
status: 'error',
|
|
158
|
+
message: 'FRAIM MCP config uses an obsolete Hub/Electron-routed stable shim',
|
|
159
|
+
suggestion: 'Run: fraim doctor --fix-mcp to replace it with the stable node/npx launcher',
|
|
160
|
+
details: {
|
|
161
|
+
launchPlanSource: 'legacy-hub-routed-shim',
|
|
162
|
+
phase: 'preflight',
|
|
163
|
+
command,
|
|
164
|
+
args,
|
|
165
|
+
expectedCommand: fraimMcpShimPath,
|
|
166
|
+
companionNpxShim: fraimNpxShimPath
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
153
170
|
return {
|
|
154
171
|
status: 'passed',
|
|
155
172
|
message: 'FRAIM MCP config uses the stable FRAIM shim',
|
|
@@ -286,7 +303,7 @@ async function testFraimConnectivity() {
|
|
|
286
303
|
if (process.env.DEBUG_DOCTOR) {
|
|
287
304
|
console.log('[DOCTOR DEBUG] Testing FRAIM connectivity:');
|
|
288
305
|
console.log(' URL:', remoteUrl);
|
|
289
|
-
console.log(' API Key:',
|
|
306
|
+
console.log(' API Key:', (0, redact_secrets_1.redactApiKey)(apiKey));
|
|
290
307
|
console.log(' NODE_ENV:', process.env.NODE_ENV || 'not set');
|
|
291
308
|
}
|
|
292
309
|
// Warn if testing against localhost (might be in test mode)
|
|
@@ -615,7 +632,8 @@ async function testFraimLocalProxyTroubleshooting() {
|
|
|
615
632
|
}
|
|
616
633
|
const logTail = (0, fraim_mcp_diagnostics_1.readFileTailUtf8)(logPath);
|
|
617
634
|
const analysis = (0, fraim_mcp_diagnostics_1.analyzeFraimProxyLog)(logTail.content, {
|
|
618
|
-
|
|
635
|
+
// The proxy tags each line with the key's reference, never the key (issue #1701).
|
|
636
|
+
expectedKeyRef: (0, redact_secrets_1.redactApiKey)(keyDiagnostic.apiKey),
|
|
619
637
|
maxLines: 300
|
|
620
638
|
});
|
|
621
639
|
const errorClassifications = [
|
|
@@ -8,15 +8,58 @@ exports.getFraimMcpShimPath = getFraimMcpShimPath;
|
|
|
8
8
|
exports.getFraimNpxShimPath = getFraimNpxShimPath;
|
|
9
9
|
exports.getFraimCliShimPath = getFraimCliShimPath;
|
|
10
10
|
exports.getPackagedFraimRuntimePath = getPackagedFraimRuntimePath;
|
|
11
|
-
exports.
|
|
12
|
-
exports.
|
|
11
|
+
exports.getFraimMcpLauncherStatePath = getFraimMcpLauncherStatePath;
|
|
12
|
+
exports.recordFraimMcpLauncherVersion = recordFraimMcpLauncherVersion;
|
|
13
13
|
exports.ensureFraimMcpLatestLauncher = ensureFraimMcpLatestLauncher;
|
|
14
14
|
const fs_1 = __importDefault(require("fs"));
|
|
15
15
|
const os_1 = __importDefault(require("os"));
|
|
16
16
|
const path_1 = __importDefault(require("path"));
|
|
17
17
|
const windows_shim_path_search_1 = require("../../core/utils/windows-shim-path-search");
|
|
18
|
-
const LAUNCHER_VERSION =
|
|
19
|
-
|
|
18
|
+
const LAUNCHER_VERSION = 2;
|
|
19
|
+
/**
|
|
20
|
+
* Issue #1354: how long a resolved version stays good before the launcher asks npm again.
|
|
21
|
+
*
|
|
22
|
+
* Resolving "latest" is a registry round trip by definition, so a launcher that resolves on
|
|
23
|
+
* every invocation pays that cost on every invocation - and pays a full connection timeout
|
|
24
|
+
* when the registry is unreachable, which is the silent-startup-failure this issue reports.
|
|
25
|
+
* Bounding it to one resolve per window is what makes the offline path cheap: with a fresh
|
|
26
|
+
* record there is no reason to touch the network at all.
|
|
27
|
+
*
|
|
28
|
+
* The tradeoff is explicit: inside the window the launcher runs the last version it resolved,
|
|
29
|
+
* so "always latest" is really "latest as of at most one window ago". 24h is not a new number -
|
|
30
|
+
* it matches `FraimLocalMCPServer.DEFAULT_CONNECT_SYNC_MAX_AGE_MS`, the connect-time catalog
|
|
31
|
+
* freshness window, rather than introducing a second unrelated one.
|
|
32
|
+
*/
|
|
33
|
+
const STALENESS_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
34
|
+
/**
|
|
35
|
+
* How long the resolve may spend talking to the registry before giving up.
|
|
36
|
+
*
|
|
37
|
+
* Paired with `--fetch-retries=0`: the resolve is always optional, because a failure falls back
|
|
38
|
+
* to the recorded version, so the only thing a long wait buys is the difference between a brief
|
|
39
|
+
* pause and an apparent hang at process spawn. 5s is comfortably above a healthy registry round
|
|
40
|
+
* trip (measured at roughly 0.7-1.4s on this machine) and well inside an MCP client's startup
|
|
41
|
+
* window, which is the budget this issue reports blowing.
|
|
42
|
+
*/
|
|
43
|
+
const RESOLVE_TIMEOUT_MS = 5000;
|
|
44
|
+
/** Per-version npm caches to keep. Older ones are pruned so the directory stays bounded. */
|
|
45
|
+
const CACHE_GENERATIONS_KEPT = 2;
|
|
46
|
+
/**
|
|
47
|
+
* A per-version cache is only ever a prune candidate once it is this old.
|
|
48
|
+
*
|
|
49
|
+
* The generation count alone is not a safe rule here, because these directories are not inert
|
|
50
|
+
* caches: `runPackage` points `npm_config_cache` at one, and npx installs the package *inside*
|
|
51
|
+
* it at `_npx/<hash>/node_modules`. An MCP stdio server lives as long as its agent session and
|
|
52
|
+
* resolves dynamic imports from that tree for its whole life, so removing the cache of a version
|
|
53
|
+
* still being served would pull the module tree out from under a running process. On Windows it
|
|
54
|
+
* is worse than a clean failure: `rmSync` deletes whatever is not currently open and throws on
|
|
55
|
+
* the rest, leaving a half-gutted tree.
|
|
56
|
+
*
|
|
57
|
+
* An age floor is the cheap correct guard, and matches how this repository already bounds
|
|
58
|
+
* on-disk growth elsewhere (`artifact-retention-cleanup` retires by age in days, not by count).
|
|
59
|
+
* Growth stays bounded - everything past the newest few ages out - while any cache a plausibly
|
|
60
|
+
* live session could still be reading is off limits.
|
|
61
|
+
*/
|
|
62
|
+
const CACHE_MIN_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
63
|
const launcherSource = `#!/usr/bin/env node
|
|
21
64
|
const fs = require('fs');
|
|
22
65
|
const os = require('os');
|
|
@@ -24,6 +67,10 @@ const path = require('path');
|
|
|
24
67
|
const { spawnSync } = require('child_process');
|
|
25
68
|
|
|
26
69
|
const LAUNCHER_VERSION = ${LAUNCHER_VERSION};
|
|
70
|
+
const STALENESS_WINDOW_MS = ${STALENESS_WINDOW_MS};
|
|
71
|
+
const RESOLVE_TIMEOUT_MS = ${RESOLVE_TIMEOUT_MS};
|
|
72
|
+
const CACHE_GENERATIONS_KEPT = ${CACHE_GENERATIONS_KEPT};
|
|
73
|
+
const CACHE_MIN_AGE_MS = ${CACHE_MIN_AGE_MS};
|
|
27
74
|
const USER_DIR = process.env.FRAIM_USER_DIR || path.join(os.homedir(), '.fraim');
|
|
28
75
|
const STATE_DIR = path.join(USER_DIR, 'mcp-launcher');
|
|
29
76
|
const STATE_PATH = path.join(STATE_DIR, 'latest.json');
|
|
@@ -32,7 +79,9 @@ const CACHE_ROOT = path.join(USER_DIR, 'npm-cache', 'mcp');
|
|
|
32
79
|
const commandName = (name) => process.platform === 'win32' ? name + '.cmd' : name;
|
|
33
80
|
const npmCommand = commandName('npm');
|
|
34
81
|
const npxCommand = commandName('npx');
|
|
35
|
-
const
|
|
82
|
+
const rawArgs = process.argv.slice(2);
|
|
83
|
+
const cliMode = rawArgs[0] === '--fraim-cli';
|
|
84
|
+
const cliArgs = cliMode ? rawArgs.slice(1) : (rawArgs.length > 0 ? rawArgs : ['mcp']);
|
|
36
85
|
|
|
37
86
|
function quoteCmdArg(arg) {
|
|
38
87
|
const value = String(arg);
|
|
@@ -65,10 +114,14 @@ function writeState(state) {
|
|
|
65
114
|
}
|
|
66
115
|
|
|
67
116
|
function resolveLatest(packageName) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
117
|
+
// Fail fast rather than waiting out npm's default retry ladder. This resolve is always
|
|
118
|
+
// optional: a failure falls back to the recorded version, so a long wait buys nothing and is
|
|
119
|
+
// the difference between a brief pause and an apparent hang when the registry is unreachable.
|
|
120
|
+
const result = runCommand(
|
|
121
|
+
npmCommand,
|
|
122
|
+
['view', packageName, 'version', '--silent', '--fetch-retries=0', '--fetch-timeout=' + RESOLVE_TIMEOUT_MS],
|
|
123
|
+
{ encoding: 'utf8', env: process.env }
|
|
124
|
+
);
|
|
72
125
|
|
|
73
126
|
if (result.status !== 0) {
|
|
74
127
|
return null;
|
|
@@ -78,7 +131,47 @@ function resolveLatest(packageName) {
|
|
|
78
131
|
return /^\\d+\\.\\d+\\.\\d+/.test(version) ? version : null;
|
|
79
132
|
}
|
|
80
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Whether a record names a version this launcher can run.
|
|
136
|
+
*
|
|
137
|
+
* Deliberately not gated on launcherVersion. The record's shape is what matters and that has
|
|
138
|
+
* not changed, so a record written by an earlier launcher is still a usable fallback. Rejecting
|
|
139
|
+
* one would mean the first launch after a launcher upgrade fails outright when the registry is
|
|
140
|
+
* unreachable, turning a working offline start into an error for no benefit.
|
|
141
|
+
*/
|
|
142
|
+
function isUsableState(state) {
|
|
143
|
+
// The version shape is re-checked here, not only where npm answers. This record is the common
|
|
144
|
+
// path now rather than a rare fallback, and its value reaches both an npx --package argument
|
|
145
|
+
// and a cache directory name, so it is validated at the point of use rather than trusted
|
|
146
|
+
// because writeState happened to produce it.
|
|
147
|
+
return Boolean(
|
|
148
|
+
state &&
|
|
149
|
+
state.packageName === 'fraim' &&
|
|
150
|
+
state.binName === 'fraim' &&
|
|
151
|
+
typeof state.version === 'string' &&
|
|
152
|
+
/^\\d+\\.\\d+\\.\\d+/.test(state.version)
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** True while the recorded resolve is recent enough to reuse without asking npm. */
|
|
157
|
+
function isFresh(state) {
|
|
158
|
+
if (!isUsableState(state)) return false;
|
|
159
|
+
const recordedAt = Date.parse(state.updatedAt);
|
|
160
|
+
if (!Number.isFinite(recordedAt)) return false;
|
|
161
|
+
const age = Date.now() - recordedAt;
|
|
162
|
+
// A record stamped in the future is a clock change, not freshness.
|
|
163
|
+
return age >= 0 && age < STALENESS_WINDOW_MS;
|
|
164
|
+
}
|
|
165
|
+
|
|
81
166
|
function resolvePackage() {
|
|
167
|
+
// Read the freshness record before reaching for the network. This ordering is the fix: the
|
|
168
|
+
// record used to be consulted only after the resolve had already failed, so the happy path
|
|
169
|
+
// was gated on npm reachability on every single launch.
|
|
170
|
+
const cached = readState();
|
|
171
|
+
if (isFresh(cached)) {
|
|
172
|
+
return { packageName: 'fraim', binName: 'fraim', version: cached.version, fromCache: true };
|
|
173
|
+
}
|
|
174
|
+
|
|
82
175
|
const fraimVersion = resolveLatest('fraim');
|
|
83
176
|
if (fraimVersion) {
|
|
84
177
|
const state = { packageName: 'fraim', binName: 'fraim', version: fraimVersion, fromCache: false };
|
|
@@ -86,8 +179,7 @@ function resolvePackage() {
|
|
|
86
179
|
return state;
|
|
87
180
|
}
|
|
88
181
|
|
|
89
|
-
|
|
90
|
-
if (cached && cached.packageName === 'fraim' && cached.binName === 'fraim' && cached.version) {
|
|
182
|
+
if (isUsableState(cached)) {
|
|
91
183
|
console.error('[fraim-mcp-launcher] Could not resolve npm latest; using cached fraim@' + cached.version + '.');
|
|
92
184
|
return { packageName: 'fraim', binName: 'fraim', version: cached.version, fromCache: true };
|
|
93
185
|
}
|
|
@@ -96,6 +188,37 @@ function resolvePackage() {
|
|
|
96
188
|
process.exit(1);
|
|
97
189
|
}
|
|
98
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Keep the newest few per-version caches and age out the rest. Each entry is a full npm cache
|
|
193
|
+
* for one version (~80MB), plus one retry-<timestamp>-<pid> directory per Windows launch
|
|
194
|
+
* failure, so without a bound the directory grows once per release forever.
|
|
195
|
+
*
|
|
196
|
+
* A directory is removed only when it is BOTH outside the newest CACHE_GENERATIONS_KEPT and
|
|
197
|
+
* older than CACHE_MIN_AGE_MS. The age floor is what keeps this safe: the package is installed
|
|
198
|
+
* inside the cache directory, so a recent one may still be backing a running MCP server.
|
|
199
|
+
*
|
|
200
|
+
* Best effort throughout: a cache that cannot be removed is not a reason to fail the launch.
|
|
201
|
+
*/
|
|
202
|
+
function pruneCaches(keepDir, nowMs) {
|
|
203
|
+
try {
|
|
204
|
+
const entries = fs.readdirSync(CACHE_ROOT, { withFileTypes: true })
|
|
205
|
+
.filter(function (entry) { return entry.isDirectory(); })
|
|
206
|
+
.map(function (entry) {
|
|
207
|
+
const full = path.join(CACHE_ROOT, entry.name);
|
|
208
|
+
let mtime = 0;
|
|
209
|
+
try { mtime = fs.statSync(full).mtimeMs; } catch (error) { mtime = 0; }
|
|
210
|
+
return { full: full, mtime: mtime };
|
|
211
|
+
})
|
|
212
|
+
.filter(function (entry) { return entry.full !== keepDir; })
|
|
213
|
+
.sort(function (a, b) { return b.mtime - a.mtime; });
|
|
214
|
+
|
|
215
|
+
for (const entry of entries.slice(Math.max(0, CACHE_GENERATIONS_KEPT - 1))) {
|
|
216
|
+
if (nowMs - entry.mtime < CACHE_MIN_AGE_MS) continue;
|
|
217
|
+
try { fs.rmSync(entry.full, { recursive: true, force: true }); } catch (error) { /* leave it */ }
|
|
218
|
+
}
|
|
219
|
+
} catch (error) { /* nothing to prune */ }
|
|
220
|
+
}
|
|
221
|
+
|
|
99
222
|
function runPackage(plan, cacheDir) {
|
|
100
223
|
ensureDir(cacheDir);
|
|
101
224
|
const env = { ...process.env, npm_config_cache: cacheDir };
|
|
@@ -108,6 +231,7 @@ function runPackage(plan, cacheDir) {
|
|
|
108
231
|
|
|
109
232
|
const plan = resolvePackage();
|
|
110
233
|
const versionCacheDir = path.join(CACHE_ROOT, plan.packageName + '-' + plan.version.replace(/[^a-zA-Z0-9._-]/g, '_'));
|
|
234
|
+
pruneCaches(versionCacheDir, Date.now());
|
|
111
235
|
let status = runPackage(plan, versionCacheDir);
|
|
112
236
|
|
|
113
237
|
if (status !== 0 && process.platform === 'win32') {
|
|
@@ -133,75 +257,50 @@ function getFraimCliShimPath() {
|
|
|
133
257
|
function getPackagedFraimRuntimePath() {
|
|
134
258
|
return path_1.default.join(process.env.FRAIM_USER_DIR || path_1.default.join(os_1.default.homedir(), '.fraim'), 'bin', 'packaged-fraim-runtime.json');
|
|
135
259
|
}
|
|
136
|
-
function
|
|
137
|
-
return env.
|
|
138
|
-
? { executable: env.FRAIM_PACKAGED_CLI_EXECUTABLE, script: env.FRAIM_PACKAGED_CLI_SCRIPT }
|
|
139
|
-
: null;
|
|
260
|
+
function getFraimMcpLauncherStatePath() {
|
|
261
|
+
return path_1.default.join(process.env.FRAIM_USER_DIR || path_1.default.join(os_1.default.homedir(), '.fraim'), 'mcp-launcher', 'latest.json');
|
|
140
262
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Record `version` as the version the launcher should run, resetting the freshness window.
|
|
265
|
+
*
|
|
266
|
+
* This is the deliberate escape hatch from `STALENESS_WINDOW_MS`. The window is what keeps
|
|
267
|
+
* ordinary launches off the network, but it also means a published fix reaches an installed
|
|
268
|
+
* machine up to a window late. `fraim sync` calls this with the version it is itself running, so
|
|
269
|
+
* `npx fraim@latest sync` resolves latest, runs as latest, and records latest - which the next
|
|
270
|
+
* MCP server spawn then uses immediately instead of waiting out the window.
|
|
271
|
+
*
|
|
272
|
+
* The semantics are "the version you just ran is the version recorded", which is why the caller
|
|
273
|
+
* passes its own version rather than this function consulting the registry. A sync that ran an
|
|
274
|
+
* older package must not claim to have recorded a newer one.
|
|
275
|
+
*
|
|
276
|
+
* The record's shape is duplicated from the generated launcher's `writeState`, deliberately and
|
|
277
|
+
* unavoidably: that script executes standalone from `~/.fraim/bin` with only Node built-ins and
|
|
278
|
+
* cannot import this module. The two writers must stay in step, and
|
|
279
|
+
* `test-1354-mcp-launcher-freshness-window.ts` asserts that a record written here is accepted by
|
|
280
|
+
* the launcher rather than merely that the fields look alike.
|
|
281
|
+
*/
|
|
282
|
+
function recordFraimMcpLauncherVersion(version) {
|
|
283
|
+
if (!/^\d+\.\d+\.\d+/.test(version))
|
|
284
|
+
return false;
|
|
285
|
+
const statePath = getFraimMcpLauncherStatePath();
|
|
155
286
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
287
|
+
fs_1.default.mkdirSync(path_1.default.dirname(statePath), { recursive: true });
|
|
288
|
+
fs_1.default.writeFileSync(statePath, JSON.stringify({
|
|
289
|
+
packageName: 'fraim',
|
|
290
|
+
binName: 'fraim',
|
|
291
|
+
version,
|
|
292
|
+
fromCache: false,
|
|
293
|
+
launcherVersion: LAUNCHER_VERSION,
|
|
294
|
+
updatedAt: new Date().toISOString()
|
|
295
|
+
}, null, 2));
|
|
296
|
+
return true;
|
|
162
297
|
}
|
|
163
298
|
catch {
|
|
164
|
-
|
|
299
|
+
// Best effort. A sync that cannot write this record has still synced the catalog, and the
|
|
300
|
+
// launcher resolves normally when the window expires; failing the sync would be worse.
|
|
301
|
+
return false;
|
|
165
302
|
}
|
|
166
303
|
}
|
|
167
|
-
function hasPackagedFraimRuntime(env = process.env) {
|
|
168
|
-
return getPackagedFraimRuntime(env) !== null;
|
|
169
|
-
}
|
|
170
|
-
function posixSingleQuote(value) {
|
|
171
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
172
|
-
}
|
|
173
|
-
function windowsBatchPath(value) {
|
|
174
|
-
return value.replace(/%/g, '%%').replace(/"/g, '""');
|
|
175
|
-
}
|
|
176
|
-
function writePackagedRuntimeMetadata(runtime) {
|
|
177
|
-
writeFileIfChanged(getPackagedFraimRuntimePath(), JSON.stringify({
|
|
178
|
-
version: PACKAGED_RUNTIME_VERSION,
|
|
179
|
-
executable: runtime.executable,
|
|
180
|
-
script: runtime.script,
|
|
181
|
-
updatedAt: new Date().toISOString(),
|
|
182
|
-
}, null, 2) + '\n');
|
|
183
|
-
}
|
|
184
|
-
function packagedFraimShimSource(command, runtime) {
|
|
185
|
-
const { executable, script } = runtime;
|
|
186
|
-
const commandArg = command === 'mcp' ? ' mcp' : '';
|
|
187
|
-
if (process.platform === 'win32') {
|
|
188
|
-
return [
|
|
189
|
-
'@echo off',
|
|
190
|
-
'setlocal',
|
|
191
|
-
'set "ELECTRON_RUN_AS_NODE=1"',
|
|
192
|
-
`"${windowsBatchPath(executable)}" "${windowsBatchPath(script)}"${commandArg} %*`,
|
|
193
|
-
'exit /b %ERRORLEVEL%',
|
|
194
|
-
'',
|
|
195
|
-
].join('\r\n');
|
|
196
|
-
}
|
|
197
|
-
return [
|
|
198
|
-
'#!/usr/bin/env sh',
|
|
199
|
-
'set -eu',
|
|
200
|
-
'export ELECTRON_RUN_AS_NODE=1',
|
|
201
|
-
`exec ${posixSingleQuote(executable)} ${posixSingleQuote(script)}${commandArg} "$@"`,
|
|
202
|
-
'',
|
|
203
|
-
].join('\n');
|
|
204
|
-
}
|
|
205
304
|
const windowsFraimMcpShimSource = `@echo off
|
|
206
305
|
setlocal
|
|
207
306
|
for %%I in ("%~dp0..") do set "FRAIM_USER_DIR_VALUE=%%~fI"
|
|
@@ -225,6 +324,9 @@ for /f "delims=" %%D in ('dir /b /ad /o-n "%FRAIM_USER_DIR_VALUE%\\node\\node-v*
|
|
|
225
324
|
echo [fraim-mcp] Could not find node on PATH or in "%FRAIM_USER_DIR_VALUE%\\node" 1>&2
|
|
226
325
|
exit /b 1
|
|
227
326
|
`;
|
|
327
|
+
const windowsFraimCliShimSource = windowsFraimMcpShimSource
|
|
328
|
+
.replace(/fraim-mcp-latest\.js" %\*/g, 'fraim-mcp-latest.js" --fraim-cli %*')
|
|
329
|
+
.replace(/\[fraim-mcp\]/g, '[fraim]');
|
|
228
330
|
const windowsFraimNpxShimSource = `@echo off
|
|
229
331
|
setlocal
|
|
230
332
|
for %%I in ("%~dp0..") do set "FRAIM_USER_DIR_VALUE=%%~fI"
|
|
@@ -246,47 +348,31 @@ for /f "delims=" %%D in ('dir /b /ad /o-n "%FRAIM_USER_DIR_VALUE%\\node\\node-v*
|
|
|
246
348
|
echo [fraim-npx] Could not find npx on PATH or in "%FRAIM_USER_DIR_VALUE%\\node" 1>&2
|
|
247
349
|
exit /b 1
|
|
248
350
|
`;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
LAUNCHER="$FRAIM_USER_DIR_VALUE/bin/fraim-mcp-latest.js"
|
|
254
|
-
|
|
255
|
-
if command -v node >/dev/null 2>&1; then
|
|
256
|
-
exec node "$LAUNCHER" "$@"
|
|
257
|
-
fi
|
|
258
|
-
if [ -x "$FRAIM_USER_DIR_VALUE/node/bin/node" ]; then
|
|
259
|
-
PATH="$FRAIM_USER_DIR_VALUE/node/bin:$PATH"
|
|
260
|
-
export PATH
|
|
261
|
-
exec "$FRAIM_USER_DIR_VALUE/node/bin/node" "$LAUNCHER" "$@"
|
|
262
|
-
fi
|
|
263
|
-
if [ -x "$FRAIM_USER_DIR_VALUE/node/node" ]; then
|
|
264
|
-
PATH="$FRAIM_USER_DIR_VALUE/node:$PATH"
|
|
265
|
-
export PATH
|
|
266
|
-
exec "$FRAIM_USER_DIR_VALUE/node/node" "$LAUNCHER" "$@"
|
|
267
|
-
fi
|
|
268
|
-
|
|
269
|
-
echo "[fraim-mcp] Could not find node on PATH or in $FRAIM_USER_DIR_VALUE/node" >&2
|
|
270
|
-
exit 1
|
|
271
|
-
`;
|
|
272
|
-
const posixFraimNpxShimSource = `#!/usr/bin/env sh
|
|
351
|
+
function posixFraimShimSource(executable) {
|
|
352
|
+
const args = executable === 'node' ? '"$FRAIM_USER_DIR_VALUE/bin/fraim-mcp-latest.js" "$@"' : '"$@"';
|
|
353
|
+
const label = executable === 'node' ? 'fraim-mcp' : 'fraim-npx';
|
|
354
|
+
return `#!/usr/bin/env sh
|
|
273
355
|
set -eu
|
|
274
356
|
SHIM_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
275
357
|
FRAIM_USER_DIR_VALUE=$(dirname "$SHIM_DIR")
|
|
276
358
|
|
|
277
|
-
if command -v
|
|
278
|
-
exec
|
|
279
|
-
fi
|
|
280
|
-
if [ -x "$FRAIM_USER_DIR_VALUE/node/bin/npx" ]; then
|
|
281
|
-
exec "$FRAIM_USER_DIR_VALUE/node/bin/npx" "$@"
|
|
282
|
-
fi
|
|
283
|
-
if [ -x "$FRAIM_USER_DIR_VALUE/node/npx" ]; then
|
|
284
|
-
exec "$FRAIM_USER_DIR_VALUE/node/npx" "$@"
|
|
359
|
+
if command -v ${executable} >/dev/null 2>&1; then
|
|
360
|
+
exec ${executable} ${args}
|
|
285
361
|
fi
|
|
362
|
+
# Match the generating host's distribution layout; staging and other architectures
|
|
363
|
+
# cannot match this suffix. Legacy installs remain valid fallback locations.
|
|
364
|
+
for NODE_BIN in "$FRAIM_USER_DIR_VALUE/node"/node-v*-${process.platform}-${process.arch}/bin "$FRAIM_USER_DIR_VALUE/node/bin" "$FRAIM_USER_DIR_VALUE/node"; do
|
|
365
|
+
if [ -x "$NODE_BIN/node" ] && [ -x "$NODE_BIN/${executable}" ]; then
|
|
366
|
+
PATH="$NODE_BIN:$PATH"
|
|
367
|
+
export PATH
|
|
368
|
+
exec "$NODE_BIN/${executable}" ${args}
|
|
369
|
+
fi
|
|
370
|
+
done
|
|
286
371
|
|
|
287
|
-
echo "[
|
|
372
|
+
echo "[${label}] Could not find ${executable} on PATH or in $FRAIM_USER_DIR_VALUE/node" >&2
|
|
288
373
|
exit 1
|
|
289
374
|
`;
|
|
375
|
+
}
|
|
290
376
|
const writeFileIfChanged = (filePath, content, mode) => {
|
|
291
377
|
if (!fs_1.default.existsSync(filePath) || fs_1.default.readFileSync(filePath, 'utf8') !== content) {
|
|
292
378
|
fs_1.default.writeFileSync(filePath, content, 'utf8');
|
|
@@ -306,30 +392,14 @@ function ensureFraimMcpLatestLauncher() {
|
|
|
306
392
|
fs_1.default.mkdirSync(launcherDir, { recursive: true });
|
|
307
393
|
const fraimMcpShimPath = getFraimMcpShimPath();
|
|
308
394
|
const fraimNpxShimPath = getFraimNpxShimPath();
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
const fraimCliShimPath = getFraimCliShimPath();
|
|
312
|
-
if (runtimeFromEnv()) {
|
|
313
|
-
writePackagedRuntimeMetadata(packagedRuntime);
|
|
314
|
-
}
|
|
315
|
-
writeFileIfChanged(fraimMcpShimPath, packagedFraimShimSource('mcp', packagedRuntime), process.platform === 'win32' ? undefined : 0o755);
|
|
316
|
-
writeFileIfChanged(fraimCliShimPath, packagedFraimShimSource('cli', packagedRuntime), process.platform === 'win32' ? undefined : 0o755);
|
|
317
|
-
// Issue #1474: keep the npx companion shim in parity with the non-packaged branch below.
|
|
318
|
-
// The packaged fraim-mcp shim itself never invokes npx, but git/playwright base MCP servers
|
|
319
|
-
// (added later via the connect-mcp skill once a system npm is available) and doctor's
|
|
320
|
-
// launch-plan diagnostic both expect this file to exist alongside the stable fraim shim.
|
|
321
|
-
// Without it, doctor's "stable shim missing on disk" check could never be satisfied on a
|
|
322
|
-
// packaged install, and --fix-mcp/add-ide (which call this same function) could never fix it.
|
|
323
|
-
writeFileIfChanged(fraimNpxShimPath, process.platform === 'win32' ? windowsFraimNpxShimSource : posixFraimNpxShimSource, process.platform === 'win32' ? undefined : 0o755);
|
|
324
|
-
return {
|
|
325
|
-
command: fraimMcpShimPath,
|
|
326
|
-
args: [],
|
|
327
|
-
path: packagedRuntime.script,
|
|
328
|
-
};
|
|
329
|
-
}
|
|
395
|
+
const fraimCliShimPath = getFraimCliShimPath();
|
|
396
|
+
fs_1.default.rmSync(getPackagedFraimRuntimePath(), { force: true });
|
|
330
397
|
writeFileIfChanged(launcherPath, launcherSource, process.platform === 'win32' ? undefined : 0o755);
|
|
331
|
-
writeFileIfChanged(fraimMcpShimPath, process.platform === 'win32' ? windowsFraimMcpShimSource :
|
|
332
|
-
writeFileIfChanged(fraimNpxShimPath, process.platform === 'win32' ? windowsFraimNpxShimSource :
|
|
398
|
+
writeFileIfChanged(fraimMcpShimPath, process.platform === 'win32' ? windowsFraimMcpShimSource : posixFraimShimSource('node'), process.platform === 'win32' ? undefined : 0o755);
|
|
399
|
+
writeFileIfChanged(fraimNpxShimPath, process.platform === 'win32' ? windowsFraimNpxShimSource : posixFraimShimSource('npx'), process.platform === 'win32' ? undefined : 0o755);
|
|
400
|
+
writeFileIfChanged(fraimCliShimPath, process.platform === 'win32' ? windowsFraimCliShimSource : posixFraimShimSource('node')
|
|
401
|
+
.replace(/fraim-mcp-latest\.js" "\$@"/g, 'fraim-mcp-latest.js" --fraim-cli "$@"')
|
|
402
|
+
.replace(/\[fraim-mcp\]/g, '[fraim]'), process.platform === 'win32' ? undefined : 0o755);
|
|
333
403
|
return {
|
|
334
404
|
command: fraimMcpShimPath,
|
|
335
405
|
args: [],
|
|
@@ -56,12 +56,7 @@ exports.BASE_MCP_SERVERS = [
|
|
|
56
56
|
}
|
|
57
57
|
];
|
|
58
58
|
function activeBaseMCPServers() {
|
|
59
|
-
|
|
60
|
-
// npm. Keep third-party npx MCP servers out of fresh desktop-generated config;
|
|
61
|
-
// users can add them later through connect-mcp when a system npm is available.
|
|
62
|
-
return (0, fraim_mcp_latest_launcher_1.hasPackagedFraimRuntime)()
|
|
63
|
-
? exports.BASE_MCP_SERVERS.filter((server) => server.id === 'fraim')
|
|
64
|
-
: exports.BASE_MCP_SERVERS;
|
|
59
|
+
return exports.BASE_MCP_SERVERS;
|
|
65
60
|
}
|
|
66
61
|
// ============================================================================
|
|
67
62
|
// PROVIDER MCP SERVER BUILDER (uses provider registry)
|