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.
@@ -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 (.pkg), no key is passed — the wizard prompts the user to paste it.
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
- const { ensureFraimMcpLatestLauncher } = await Promise.resolve().then(() => __importStar(require('../mcp/fraim-mcp-latest-launcher')));
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
- prefix: secret.slice(0, 10),
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(`${secret.slice(0, 10)}...`);
35
+ sanitized = sanitized.split(secret).join((0, redact_secrets_1.redactApiKey)(secret));
37
36
  }
38
- return sanitized.replace(/fraim_[A-Za-z0-9_-]+/g, (match) => `${match.slice(0, 10)}...`);
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, options = {}) {
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.expectedKey
156
- ? allLines.filter(line => keyLineRegex.exec(line)?.[1] === options.expectedKey)
162
+ const matchingLines = options.expectedKeyRef
163
+ ? allLines.filter(line => keyLineRegex.exec(line)?.[1] === options.expectedKeyRef)
157
164
  : allLines;
158
- if (options.expectedKey && matchingLines.length === 0) {
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:', apiKey.substring(0, 15) + '...');
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
- expectedKey: keyDiagnostic.apiKey,
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.getPackagedFraimRuntime = getPackagedFraimRuntime;
12
- exports.hasPackagedFraimRuntime = hasPackagedFraimRuntime;
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 = 1;
19
- const PACKAGED_RUNTIME_VERSION = 1;
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 cliArgs = process.argv.slice(2).length > 0 ? process.argv.slice(2) : ['mcp'];
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
- const result = runCommand(npmCommand, ['view', packageName, 'version', '--silent'], {
69
- encoding: 'utf8',
70
- env: process.env
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
- const cached = readState();
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 runtimeFromEnv(env = process.env) {
137
- return env.FRAIM_PACKAGED_CLI_EXECUTABLE && env.FRAIM_PACKAGED_CLI_SCRIPT
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
- // Regular Node.js (and ELECTRON_RUN_AS_NODE) cannot see inside Electron's
142
- // .asar archives. For paths like …/app.asar/node_modules/…, verify the
143
- // archive file itself rather than the internal entry.
144
- function existsOrAsarExists(filePath) {
145
- const normalized = filePath.replace(/\\/g, '/');
146
- const asarSep = normalized.indexOf('.asar/');
147
- if (asarSep === -1)
148
- return fs_1.default.existsSync(filePath);
149
- return fs_1.default.existsSync(filePath.slice(0, asarSep + '.asar'.length));
150
- }
151
- function getPackagedFraimRuntime(env = process.env) {
152
- const envRuntime = runtimeFromEnv(env);
153
- if (envRuntime)
154
- return envRuntime;
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
- const raw = JSON.parse(fs_1.default.readFileSync(getPackagedFraimRuntimePath(), 'utf8'));
157
- if (!raw.executable || !raw.script)
158
- return null;
159
- if (!fs_1.default.existsSync(raw.executable) || !existsOrAsarExists(raw.script))
160
- return null;
161
- return { executable: raw.executable, script: raw.script };
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
- return null;
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
- const posixFraimMcpShimSource = `#!/usr/bin/env sh
250
- set -eu
251
- SHIM_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
252
- FRAIM_USER_DIR_VALUE=$(dirname "$SHIM_DIR")
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 npx >/dev/null 2>&1; then
278
- exec npx "$@"
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 "[fraim-npx] Could not find npx on PATH or in $FRAIM_USER_DIR_VALUE/node" >&2
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 packagedRuntime = getPackagedFraimRuntime();
310
- if (packagedRuntime) {
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 : posixFraimMcpShimSource, process.platform === 'win32' ? undefined : 0o755);
332
- writeFileIfChanged(fraimNpxShimPath, process.platform === 'win32' ? windowsFraimNpxShimSource : posixFraimNpxShimSource, process.platform === 'win32' ? undefined : 0o755);
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
- // The packaged desktop owns FRAIM's runtime but intentionally does not bundle
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)