fraim-hub 2.0.327 → 2.0.329
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/ai-hub/server.js +33 -24
- package/dist/src/api/payment/webhook.js +7 -6
- package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +176 -7
- package/dist/src/cli/utils/managed-agent-install.js +5 -1
- 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 +9 -135
- package/package.json +4 -2
|
@@ -41,7 +41,7 @@ exports.selfHealBrokenManagedAgents = selfHealBrokenManagedAgents;
|
|
|
41
41
|
exports.configureFraimForHubAgent = configureFraimForHubAgent;
|
|
42
42
|
exports.installAgentAndRefreshDetection = installAgentAndRefreshDetection;
|
|
43
43
|
exports.hubCommandVersion = hubCommandVersion;
|
|
44
|
-
exports.
|
|
44
|
+
exports.__setCatalogSyncSpawnForTests = __setCatalogSyncSpawnForTests;
|
|
45
45
|
exports.__setAgentInstallTimeoutMsForTests = __setAgentInstallTimeoutMsForTests;
|
|
46
46
|
exports.hubRunProcess = hubRunProcess;
|
|
47
47
|
exports.buildOpenFileInvocation = buildOpenFileInvocation;
|
|
@@ -2088,12 +2088,10 @@ function hubRunSync(command, args, env) {
|
|
|
2088
2088
|
// (typically seconds) while still bounding the failure the way AC-C1 requires.
|
|
2089
2089
|
const DEFAULT_AGENT_INSTALL_TIMEOUT_MS = 90_000;
|
|
2090
2090
|
let agentInstallTimeoutMs = DEFAULT_AGENT_INSTALL_TIMEOUT_MS;
|
|
2091
|
-
//
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
function __setStartupSyncSpawnForTests(fn) {
|
|
2096
|
-
startupSyncSpawnFn = fn ?? child_process_1.spawn;
|
|
2091
|
+
// Intercept only the catalog subprocess in Hub tests.
|
|
2092
|
+
let catalogSyncSpawn = child_process_1.spawn;
|
|
2093
|
+
function __setCatalogSyncSpawnForTests(fn) {
|
|
2094
|
+
catalogSyncSpawn = fn ?? child_process_1.spawn;
|
|
2097
2095
|
}
|
|
2098
2096
|
/** Test seam only. Mirrors __setEmployeeDetectionTtlForTests in hosts.ts — proves the bound
|
|
2099
2097
|
* actually fires without a real 90s hang. Pass null to restore the production value. */
|
|
@@ -2904,6 +2902,7 @@ class AiHubServer {
|
|
|
2904
2902
|
// request or window — see the comment on BADGE_POLL_INTERVAL_MS for why this can't just
|
|
2905
2903
|
// live on the client or the relaunch-decision path.
|
|
2906
2904
|
this.latestVersionPollTimer = null;
|
|
2905
|
+
this.catalogSyncChild = null;
|
|
2907
2906
|
this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
|
|
2908
2907
|
const launchResolution = options.projectPath
|
|
2909
2908
|
? { projectPath: path_1.default.resolve(options.projectPath) }
|
|
@@ -3174,6 +3173,30 @@ class AiHubServer {
|
|
|
3174
3173
|
resolveRunProjectPath(bodyProjectPath, scope) {
|
|
3175
3174
|
return (0, run_working_directory_1.resolveRunWorkingDirectory)(scope, bodyProjectPath, this.defaultProjectPath());
|
|
3176
3175
|
}
|
|
3176
|
+
// Resolve latest outside the Hub process; sync also updates the MCP launcher record (#1354).
|
|
3177
|
+
refreshLocalCatalogInBackground(global, projectRoot) {
|
|
3178
|
+
if (process.env.NODE_ENV === 'test' || this.catalogSyncChild)
|
|
3179
|
+
return;
|
|
3180
|
+
const args = ['--yes', 'fraim@latest', 'sync', ...(global ? ['--global'] : [])];
|
|
3181
|
+
try {
|
|
3182
|
+
// Windows npx is a .cmd shim. Only fixed arguments enter cmd; projectRoot stays in cwd.
|
|
3183
|
+
const child = catalogSyncSpawn(process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'npx', process.platform === 'win32' ? ['/d', '/s', '/c', 'npx', ...args] : args, { cwd: projectRoot, detached: true, stdio: 'ignore', windowsHide: true });
|
|
3184
|
+
this.catalogSyncChild = child;
|
|
3185
|
+
const release = () => {
|
|
3186
|
+
if (this.catalogSyncChild === child)
|
|
3187
|
+
this.catalogSyncChild = null;
|
|
3188
|
+
};
|
|
3189
|
+
child.once('error', (error) => {
|
|
3190
|
+
release();
|
|
3191
|
+
console.warn('[ai-hub] catalog sync could not start:', error.message);
|
|
3192
|
+
});
|
|
3193
|
+
child.once('close', release);
|
|
3194
|
+
child.unref();
|
|
3195
|
+
}
|
|
3196
|
+
catch (error) {
|
|
3197
|
+
console.warn('[ai-hub] catalog sync could not start:', error.message);
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3177
3200
|
async start(port) {
|
|
3178
3201
|
this.httpPort = port;
|
|
3179
3202
|
await new Promise((resolve, reject) => {
|
|
@@ -3216,17 +3239,8 @@ class AiHubServer {
|
|
|
3216
3239
|
console.warn('[ai-hub] self-heal pass failed:', error?.message || error);
|
|
3217
3240
|
});
|
|
3218
3241
|
}
|
|
3219
|
-
//
|
|
3220
|
-
|
|
3221
|
-
// real processes in the test suite's many short-lived Hub instances.
|
|
3222
|
-
if (process.env.NODE_ENV !== 'test') {
|
|
3223
|
-
const syncChild = startupSyncSpawnFn('npx', ['fraim', 'sync', '--global'], {
|
|
3224
|
-
detached: true,
|
|
3225
|
-
stdio: 'ignore',
|
|
3226
|
-
});
|
|
3227
|
-
syncChild.on('error', () => { });
|
|
3228
|
-
syncChild.unref();
|
|
3229
|
-
}
|
|
3242
|
+
// Refresh the machine-level job list without delaying Hub startup.
|
|
3243
|
+
this.refreshLocalCatalogInBackground(true);
|
|
3230
3244
|
// Update-badge freshness: refresh the npm-latest cache on our own clock, not just when
|
|
3231
3245
|
// a page load or relaunch decision happens to ask for it. Without this, a Hub that's
|
|
3232
3246
|
// resident in the tray for days with no window reload and no tray re-click never learns
|
|
@@ -7036,12 +7050,7 @@ class AiHubServer {
|
|
|
7036
7050
|
res.status(201).json(responsePayload);
|
|
7037
7051
|
// Background sync: refresh the local FRAIM catalog so the next job
|
|
7038
7052
|
// picker load sees any jobs that were added or updated since last run.
|
|
7039
|
-
|
|
7040
|
-
const syncChild = (0, child_process_1.spawn)('npx', ['fraim', 'sync'], { cwd: projectPath, detached: true, stdio: 'ignore' });
|
|
7041
|
-
syncChild.on('error', () => { });
|
|
7042
|
-
syncChild.unref();
|
|
7043
|
-
}
|
|
7044
|
-
catch { /* ignore if spawn itself throws synchronously */ }
|
|
7053
|
+
this.refreshLocalCatalogInBackground(false, projectPath);
|
|
7045
7054
|
}
|
|
7046
7055
|
catch (error) {
|
|
7047
7056
|
for (const spawningRun of pendingSpawnRuns) {
|
|
@@ -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
|
|
@@ -8,12 +8,58 @@ exports.getFraimMcpShimPath = getFraimMcpShimPath;
|
|
|
8
8
|
exports.getFraimNpxShimPath = getFraimNpxShimPath;
|
|
9
9
|
exports.getFraimCliShimPath = getFraimCliShimPath;
|
|
10
10
|
exports.getPackagedFraimRuntimePath = getPackagedFraimRuntimePath;
|
|
11
|
+
exports.getFraimMcpLauncherStatePath = getFraimMcpLauncherStatePath;
|
|
12
|
+
exports.recordFraimMcpLauncherVersion = recordFraimMcpLauncherVersion;
|
|
11
13
|
exports.ensureFraimMcpLatestLauncher = ensureFraimMcpLatestLauncher;
|
|
12
14
|
const fs_1 = __importDefault(require("fs"));
|
|
13
15
|
const os_1 = __importDefault(require("os"));
|
|
14
16
|
const path_1 = __importDefault(require("path"));
|
|
15
17
|
const windows_shim_path_search_1 = require("../../core/utils/windows-shim-path-search");
|
|
16
|
-
const LAUNCHER_VERSION =
|
|
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;
|
|
17
63
|
const launcherSource = `#!/usr/bin/env node
|
|
18
64
|
const fs = require('fs');
|
|
19
65
|
const os = require('os');
|
|
@@ -21,6 +67,10 @@ const path = require('path');
|
|
|
21
67
|
const { spawnSync } = require('child_process');
|
|
22
68
|
|
|
23
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};
|
|
24
74
|
const USER_DIR = process.env.FRAIM_USER_DIR || path.join(os.homedir(), '.fraim');
|
|
25
75
|
const STATE_DIR = path.join(USER_DIR, 'mcp-launcher');
|
|
26
76
|
const STATE_PATH = path.join(STATE_DIR, 'latest.json');
|
|
@@ -64,10 +114,14 @@ function writeState(state) {
|
|
|
64
114
|
}
|
|
65
115
|
|
|
66
116
|
function resolveLatest(packageName) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
+
);
|
|
71
125
|
|
|
72
126
|
if (result.status !== 0) {
|
|
73
127
|
return null;
|
|
@@ -77,7 +131,47 @@ function resolveLatest(packageName) {
|
|
|
77
131
|
return /^\\d+\\.\\d+\\.\\d+/.test(version) ? version : null;
|
|
78
132
|
}
|
|
79
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
|
+
|
|
80
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
|
+
|
|
81
175
|
const fraimVersion = resolveLatest('fraim');
|
|
82
176
|
if (fraimVersion) {
|
|
83
177
|
const state = { packageName: 'fraim', binName: 'fraim', version: fraimVersion, fromCache: false };
|
|
@@ -85,8 +179,7 @@ function resolvePackage() {
|
|
|
85
179
|
return state;
|
|
86
180
|
}
|
|
87
181
|
|
|
88
|
-
|
|
89
|
-
if (cached && cached.packageName === 'fraim' && cached.binName === 'fraim' && cached.version) {
|
|
182
|
+
if (isUsableState(cached)) {
|
|
90
183
|
console.error('[fraim-mcp-launcher] Could not resolve npm latest; using cached fraim@' + cached.version + '.');
|
|
91
184
|
return { packageName: 'fraim', binName: 'fraim', version: cached.version, fromCache: true };
|
|
92
185
|
}
|
|
@@ -95,6 +188,37 @@ function resolvePackage() {
|
|
|
95
188
|
process.exit(1);
|
|
96
189
|
}
|
|
97
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
|
+
|
|
98
222
|
function runPackage(plan, cacheDir) {
|
|
99
223
|
ensureDir(cacheDir);
|
|
100
224
|
const env = { ...process.env, npm_config_cache: cacheDir };
|
|
@@ -107,6 +231,7 @@ function runPackage(plan, cacheDir) {
|
|
|
107
231
|
|
|
108
232
|
const plan = resolvePackage();
|
|
109
233
|
const versionCacheDir = path.join(CACHE_ROOT, plan.packageName + '-' + plan.version.replace(/[^a-zA-Z0-9._-]/g, '_'));
|
|
234
|
+
pruneCaches(versionCacheDir, Date.now());
|
|
110
235
|
let status = runPackage(plan, versionCacheDir);
|
|
111
236
|
|
|
112
237
|
if (status !== 0 && process.platform === 'win32') {
|
|
@@ -132,6 +257,50 @@ function getFraimCliShimPath() {
|
|
|
132
257
|
function getPackagedFraimRuntimePath() {
|
|
133
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');
|
|
134
259
|
}
|
|
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');
|
|
262
|
+
}
|
|
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();
|
|
286
|
+
try {
|
|
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;
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
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;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
135
304
|
const windowsFraimMcpShimSource = `@echo off
|
|
136
305
|
setlocal
|
|
137
306
|
for %%I in ("%~dp0..") do set "FRAIM_USER_DIR_VALUE=%%~fI"
|
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.installManagedAgent = installManagedAgent;
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const persist_shell_path_1 = require("./persist-shell-path");
|
|
8
9
|
const path_1 = __importDefault(require("path"));
|
|
9
10
|
const managed_agent_paths_1 = require("./managed-agent-paths");
|
|
10
11
|
function errorMessage(error) {
|
|
@@ -104,7 +105,9 @@ async function runInstallWithStaleCleanupRetry(option, prefix, runInstall) {
|
|
|
104
105
|
// check before invoking this — it only covers the install-then-fallback path.
|
|
105
106
|
async function installManagedAgent(option, systemPath, deps) {
|
|
106
107
|
let standardInstallError = null;
|
|
107
|
-
const
|
|
108
|
+
const portableBin = (0, managed_agent_paths_1.getPortableNodeBinPath)();
|
|
109
|
+
// npm writes Windows shims at the prefix root, but POSIX executables in prefix/bin.
|
|
110
|
+
const prefix = process.platform === 'win32' ? portableBin : path_1.default.dirname(portableBin);
|
|
108
111
|
try {
|
|
109
112
|
await deps.runProcess('npm', ['install', '-g', option.installPackage], {
|
|
110
113
|
PATH: systemPath,
|
|
@@ -145,5 +148,6 @@ async function installManagedAgent(option, systemPath, deps) {
|
|
|
145
148
|
if (!ver) {
|
|
146
149
|
throw new Error(`${option.label} install completed, but the CLI is not runnable from FRAIM's managed PATH. Standard install failure: ${standardInstallError}`);
|
|
147
150
|
}
|
|
151
|
+
(0, persist_shell_path_1.persistShellPath)();
|
|
148
152
|
return { outcome: 'managed' };
|
|
149
153
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.buildPersistShellPathWindowsCommand = buildPersistShellPathWindowsCommand;
|
|
7
|
+
exports.persistShellPath = persistShellPath;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const os_1 = __importDefault(require("os"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const child_process_1 = require("child_process");
|
|
12
|
+
const script_sync_utils_1 = require("./script-sync-utils");
|
|
13
|
+
const managed_agent_paths_1 = require("./managed-agent-paths");
|
|
14
|
+
// Issue #1285: pure command-string builder extracted from persistShellPath()
|
|
15
|
+
// so the generated PowerShell command's directory order (the current
|
|
16
|
+
// versioned dir added before the legacy flat dir, per `bins`' own order —
|
|
17
|
+
// see getManagedAgentBinDirs()) can be asserted directly, without mutating
|
|
18
|
+
// the real registry-backed Windows User PATH in a test.
|
|
19
|
+
function buildPersistShellPathWindowsCommand(bins) {
|
|
20
|
+
const assignments = bins.map((entry, index) => `$bin${index} = '${entry.replace(/'/g, "''")}'`);
|
|
21
|
+
const removals = bins.map((_, index) => `$parts = @($parts | Where-Object { $_ -ne $bin${index} })`);
|
|
22
|
+
const additions = bins.map((_, index) => `$parts += $bin${index}`);
|
|
23
|
+
return [
|
|
24
|
+
`$ErrorActionPreference = 'Stop'`,
|
|
25
|
+
...assignments,
|
|
26
|
+
`$cur = [Environment]::GetEnvironmentVariable('PATH', 'User')`,
|
|
27
|
+
`$parts = @($cur -split ';' | Where-Object { $_ })`,
|
|
28
|
+
...removals,
|
|
29
|
+
...additions,
|
|
30
|
+
`$cur = ($parts | Select-Object -Unique) -join ';'`,
|
|
31
|
+
`[Environment]::SetEnvironmentVariable('PATH', $cur, 'User')`,
|
|
32
|
+
].join('; ');
|
|
33
|
+
}
|
|
34
|
+
function persistShellPath() {
|
|
35
|
+
const marker = '# FRAIM managed binaries';
|
|
36
|
+
const legacyExportLine = 'export PATH=”$HOME/.fraim/node/bin:$PATH”';
|
|
37
|
+
// The stable CLI shim must outrank old prefix installs already on inherited PATH.
|
|
38
|
+
// Agent bins still follow the user's PATH, preserving system-agent precedence.
|
|
39
|
+
const bins = [path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'bin'), ...(0, managed_agent_paths_1.getManagedAgentBinDirs)()];
|
|
40
|
+
const shellBins = bins.map(bin => {
|
|
41
|
+
const relative = path_1.default.relative((0, script_sync_utils_1.getUserFraimDir)(), bin).split(path_1.default.sep).join('/');
|
|
42
|
+
return '$HOME/.fraim/' + relative.replace(/[\\"$`]/g, '\\$&');
|
|
43
|
+
});
|
|
44
|
+
const exportLine = `export PATH="${shellBins[0]}:$PATH:${shellBins.slice(1).join(':')}"`;
|
|
45
|
+
// Guard against editor-autocorrect curly-quote regression (issue #1688):
|
|
46
|
+
// U+201C/201D/2018/2019 in exportLine would be written verbatim to shell
|
|
47
|
+
// profiles and break PATH resolution for all pre-existing PATH entries.
|
|
48
|
+
if (/[“”‘’]/.test(exportLine)) {
|
|
49
|
+
throw new Error(`persistShellPath: exportLine contains curly quotes. Writing this to a shell profile would corrupt PATH resolution. Check for editor autocorrect in persist-shell-path.ts. Line: ${JSON.stringify(exportLine)}`);
|
|
50
|
+
}
|
|
51
|
+
const stanza = `\n${marker}\n${exportLine}\n`;
|
|
52
|
+
if (process.platform === 'win32') {
|
|
53
|
+
const psCmd = buildPersistShellPathWindowsCommand(bins);
|
|
54
|
+
// Issue #1692: a sandboxed test run (row detection on load, node row
|
|
55
|
+
// success, fraim row success all reach this function) must never write
|
|
56
|
+
// its temp fake-home bin dirs into the real, registry-backed Windows
|
|
57
|
+
// User PATH. FRAIM_TEST_SANDBOX is already the canonical "this process
|
|
58
|
+
// is a sandboxed test run" signal set by every test runner (see
|
|
59
|
+
// getUserFraimDirPath() in project-fraim-paths.ts) rather than a
|
|
60
|
+
// dedicated flag for this call site.
|
|
61
|
+
if (process.env.FRAIM_TEST_SANDBOX !== '1') {
|
|
62
|
+
const result = (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCmd], { encoding: 'utf8', timeout: 10000 });
|
|
63
|
+
if (result.error || result.status !== 0) {
|
|
64
|
+
throw new Error(`Could not register managed binaries on the User PATH: ${result.error?.message || result.stderr}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Fall through to write POSIX profile files for Git Bash (VS Code
|
|
68
|
+
// integrated terminal, Claude Code, etc.) which do not inherit the
|
|
69
|
+
// Windows user PATH written above.
|
|
70
|
+
}
|
|
71
|
+
// Test runners isolate FRAIM_USER_DIR but may leave os.homedir() unchanged.
|
|
72
|
+
const sandboxDir = process.env.FRAIM_USER_DIR ? path_1.default.resolve(process.env.FRAIM_USER_DIR) : null;
|
|
73
|
+
const profileHome = process.env.FRAIM_TEST_SANDBOX === '1'
|
|
74
|
+
? (sandboxDir && (path_1.default.basename(sandboxDir) === '.fraim' ? path_1.default.dirname(sandboxDir) : sandboxDir))
|
|
75
|
+
: os_1.default.homedir();
|
|
76
|
+
if (!profileHome)
|
|
77
|
+
return;
|
|
78
|
+
fs_1.default.mkdirSync(profileHome, { recursive: true });
|
|
79
|
+
const profiles = ['.zshrc', '.bash_profile', '.bashrc'].map(name => path_1.default.join(profileHome, name));
|
|
80
|
+
// macOS defaults to zsh since Catalina — create .zshrc if it doesn't exist.
|
|
81
|
+
if (process.platform === 'darwin' && !fs_1.default.existsSync(profiles[0])) {
|
|
82
|
+
fs_1.default.writeFileSync(profiles[0], stanza, 'utf8');
|
|
83
|
+
}
|
|
84
|
+
// On Windows, Git Bash reads .bash_profile (login) and .bashrc (interactive).
|
|
85
|
+
// Neither is created by the OS or Git Bash installer, so create .bashrc if
|
|
86
|
+
// neither exists — otherwise the PATH entry is never written and Git Bash
|
|
87
|
+
// (VS Code integrated terminal, Claude Code) never sees managed node dirs.
|
|
88
|
+
if (process.platform !== 'darwin') {
|
|
89
|
+
const bashProfile = profiles[1]; // .bash_profile
|
|
90
|
+
const bashrc = profiles[2]; // .bashrc
|
|
91
|
+
if (!fs_1.default.existsSync(bashProfile) && !fs_1.default.existsSync(bashrc)) {
|
|
92
|
+
fs_1.default.writeFileSync(bashrc, stanza, 'utf8');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const profile of profiles) {
|
|
96
|
+
// .zshrc is macOS/Linux only; Git Bash on Windows uses .bash_profile/.bashrc.
|
|
97
|
+
if (process.platform === 'win32' && profile.endsWith('.zshrc'))
|
|
98
|
+
continue;
|
|
99
|
+
try {
|
|
100
|
+
if (!fs_1.default.existsSync(profile))
|
|
101
|
+
continue;
|
|
102
|
+
const content = fs_1.default.readFileSync(profile, 'utf8');
|
|
103
|
+
if (content.includes(legacyExportLine)) {
|
|
104
|
+
fs_1.default.writeFileSync(profile, content.replace(legacyExportLine, exportLine), 'utf8');
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (content.includes(marker)) {
|
|
108
|
+
if (!content.includes(exportLine)) {
|
|
109
|
+
// Replace stale runtime directories or an older shim ordering.
|
|
110
|
+
fs_1.default.writeFileSync(profile, content.replace(/export PATH=[\u201D"][^\u201D"]*\.fraim[^\u201D"]*[\u201D"]/, exportLine), 'utf8');
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
fs_1.default.appendFileSync(profile, stanza, 'utf8');
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Ignore unreadable or missing profiles.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
(0, managed_agent_paths_1.invalidateMachinePathCache)();
|
|
121
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Credential redaction for log sinks (issue #1701, SEC-LEAK `63bc2c102800`).
|
|
4
|
+
*
|
|
5
|
+
* A FRAIM API key is a bearer credential: anything that writes one to a console,
|
|
6
|
+
* a log file, or a structured log record has disclosed it to everyone who can
|
|
7
|
+
* read that sink. Every log statement that wants to identify a caller must go
|
|
8
|
+
* through `redactApiKey` (for a known key) or `apiKeyRefFromRequest` (for an
|
|
9
|
+
* HTTP request) instead of interpolating the key itself.
|
|
10
|
+
*
|
|
11
|
+
* The reference is an unsalted SHA-256 prefix rather than a last-four suffix:
|
|
12
|
+
* - it leaks no key material at all, where a suffix leaks 16 bits;
|
|
13
|
+
* - it is stable, so the same caller correlates across the server log, the MCP
|
|
14
|
+
* error log, and the local proxy's own log on the user's machine;
|
|
15
|
+
* - it is unsalted deliberately, because the local proxy runs in a different
|
|
16
|
+
* process from the server and must derive the same reference without sharing
|
|
17
|
+
* a secret. Keys carry 128 bits of entropy (`generateApiKey` in
|
|
18
|
+
* `src/fraim/db-service.ts`), so the digest is not brute-forceable.
|
|
19
|
+
*
|
|
20
|
+
* Fingerprinting every value, including one an unauthenticated caller supplied,
|
|
21
|
+
* also removes CRLF log-forging: no caller-controlled bytes reach a log line.
|
|
22
|
+
*/
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.NO_CREDENTIAL_REF = void 0;
|
|
25
|
+
exports.redactApiKey = redactApiKey;
|
|
26
|
+
exports.redactSecretsInText = redactSecretsInText;
|
|
27
|
+
exports.apiKeyRefFromRequest = apiKeyRefFromRequest;
|
|
28
|
+
const crypto_1 = require("crypto");
|
|
29
|
+
/** Prefix every FRAIM-issued API key carries. */
|
|
30
|
+
const FRAIM_KEY_PREFIX = 'fraim_';
|
|
31
|
+
/** Hex characters kept from the digest. 48 bits: collision-free at any realistic key population. */
|
|
32
|
+
const REFERENCE_HEX_CHARS = 12;
|
|
33
|
+
/** What a log line shows when the request carried no credential at all. */
|
|
34
|
+
exports.NO_CREDENTIAL_REF = 'key_none';
|
|
35
|
+
/**
|
|
36
|
+
* A FRAIM key embedded in free-form text. Matched loosely on the character class
|
|
37
|
+
* so a truncated, uppercased, or future-format key is still caught.
|
|
38
|
+
*/
|
|
39
|
+
const EMBEDDED_FRAIM_KEY = /fraim_[A-Za-z0-9]{16,}/g;
|
|
40
|
+
/**
|
|
41
|
+
* Non-reversible, stable reference for an API key, safe to write to any log sink.
|
|
42
|
+
* Returns `NO_CREDENTIAL_REF` when there is no credential to reference.
|
|
43
|
+
*/
|
|
44
|
+
function redactApiKey(value) {
|
|
45
|
+
if (typeof value !== 'string')
|
|
46
|
+
return exports.NO_CREDENTIAL_REF;
|
|
47
|
+
const trimmed = value.trim();
|
|
48
|
+
if (trimmed.length === 0)
|
|
49
|
+
return exports.NO_CREDENTIAL_REF;
|
|
50
|
+
const digest = (0, crypto_1.createHash)('sha256').update(trimmed, 'utf8').digest('hex');
|
|
51
|
+
return `key_${digest.slice(0, REFERENCE_HEX_CHARS)}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Replace every credential-shaped value embedded in free-form text with its
|
|
55
|
+
* reference. Use on text that is logged but not authored here: request bodies,
|
|
56
|
+
* error messages, and stack traces. Idempotent.
|
|
57
|
+
*/
|
|
58
|
+
function redactSecretsInText(text) {
|
|
59
|
+
if (typeof text !== 'string' || !text.includes(FRAIM_KEY_PREFIX))
|
|
60
|
+
return text;
|
|
61
|
+
return text.replace(EMBEDDED_FRAIM_KEY, (match) => redactApiKey(match));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Reference for whichever credential a request carried, in precedence order:
|
|
65
|
+
* the value auth resolved, then the `x-api-key` header, then the `api-key` query
|
|
66
|
+
* parameter. The authenticated value wins so a forged header cannot rewrite the
|
|
67
|
+
* identity a log line reports.
|
|
68
|
+
*
|
|
69
|
+
* Shared by the request logger and the MCP error handler so both report the same
|
|
70
|
+
* reference for the same caller.
|
|
71
|
+
*/
|
|
72
|
+
function apiKeyRefFromRequest(req) {
|
|
73
|
+
if (!req)
|
|
74
|
+
return exports.NO_CREDENTIAL_REF;
|
|
75
|
+
const fromContext = req.apiKeyData?.key;
|
|
76
|
+
if (typeof fromContext === 'string' && fromContext.trim().length > 0) {
|
|
77
|
+
return redactApiKey(fromContext);
|
|
78
|
+
}
|
|
79
|
+
const headerValue = req.headers?.['x-api-key'];
|
|
80
|
+
const fromHeader = Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
81
|
+
if (typeof fromHeader === 'string' && fromHeader.trim().length > 0) {
|
|
82
|
+
return redactApiKey(fromHeader);
|
|
83
|
+
}
|
|
84
|
+
const fromQuery = req.query?.['api-key'];
|
|
85
|
+
if (typeof fromQuery === 'string' && fromQuery.trim().length > 0) {
|
|
86
|
+
return redactApiKey(fromQuery);
|
|
87
|
+
}
|
|
88
|
+
return exports.NO_CREDENTIAL_REF;
|
|
89
|
+
}
|
|
@@ -38,7 +38,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.FIRST_RUN_ROW_IDS = exports.FirstRunSessionService = void 0;
|
|
40
40
|
exports.commandVersion = commandVersion;
|
|
41
|
-
exports.buildPersistShellPathWindowsCommand = buildPersistShellPathWindowsCommand;
|
|
42
41
|
exports.buildFirstRunHubLaunchArgs = buildFirstRunHubLaunchArgs;
|
|
43
42
|
const fs_1 = __importDefault(require("fs"));
|
|
44
43
|
const os_1 = __importDefault(require("os"));
|
|
@@ -52,6 +51,7 @@ const setup_1 = require("../cli/commands/setup");
|
|
|
52
51
|
const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launcher");
|
|
53
52
|
const script_sync_utils_1 = require("../cli/utils/script-sync-utils");
|
|
54
53
|
const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
|
|
54
|
+
const persist_shell_path_1 = require("../cli/utils/persist-shell-path");
|
|
55
55
|
const managed_agent_install_1 = require("../cli/utils/managed-agent-install");
|
|
56
56
|
const managed_node_runtime_1 = require("../cli/utils/managed-node-runtime");
|
|
57
57
|
const types_1 = require("./types");
|
|
@@ -109,120 +109,6 @@ function ensureOutputDirs() {
|
|
|
109
109
|
appendInstallLog(`orphaned-managed-shims-removed ${removedShims.join(', ')}`);
|
|
110
110
|
}
|
|
111
111
|
})();
|
|
112
|
-
// Issue #1285: pure command-string builder extracted from persistShellPath()
|
|
113
|
-
// so the generated PowerShell command's directory order (the current
|
|
114
|
-
// versioned dir added before the legacy flat dir, per `bins`' own order —
|
|
115
|
-
// see getManagedAgentBinDirs()) can be asserted directly, without mutating
|
|
116
|
-
// the real registry-backed Windows User PATH in a test.
|
|
117
|
-
function buildPersistShellPathWindowsCommand(bins) {
|
|
118
|
-
const assignments = bins.map((entry, index) => `$bin${index} = '${entry.replace(/'/g, "''")}'`);
|
|
119
|
-
const removals = bins.map((_, index) => `$parts = @($parts | Where-Object { $_ -ne $bin${index} })`);
|
|
120
|
-
const additions = bins.map((_, index) => `$parts += $bin${index}`);
|
|
121
|
-
return [
|
|
122
|
-
...assignments,
|
|
123
|
-
`$cur = [Environment]::GetEnvironmentVariable('PATH', 'User')`,
|
|
124
|
-
`$parts = @($cur -split ';' | Where-Object { $_ })`,
|
|
125
|
-
...removals,
|
|
126
|
-
...additions,
|
|
127
|
-
`$cur = ($parts | Select-Object -Unique) -join ';'`,
|
|
128
|
-
`[Environment]::SetEnvironmentVariable('PATH', $cur, 'User')`,
|
|
129
|
-
].join('; ');
|
|
130
|
-
}
|
|
131
|
-
function persistShellPath(includePackagedFraimBin = false) {
|
|
132
|
-
const marker = '# FRAIM managed binaries';
|
|
133
|
-
const legacyExportLine = 'export PATH=”$HOME/.fraim/node/bin:$PATH”';
|
|
134
|
-
// On Windows/Git Bash: add both the flat dir (bare POSIX agent scripts npm
|
|
135
|
-
// generates) and the versioned node subdir (node.exe). $HOME in Git Bash
|
|
136
|
-
// resolves to the Windows home directory, matching the registry paths.
|
|
137
|
-
let exportLine;
|
|
138
|
-
if (process.platform === 'win32') {
|
|
139
|
-
const portableNodeBin = (0, managed_agent_paths_1.getPortableNodeBinPath)();
|
|
140
|
-
const nodeRoot = (0, managed_agent_paths_1.getManagedNodeRoot)();
|
|
141
|
-
const fraimBinEntry = includePackagedFraimBin ? ':$HOME/.fraim/bin' : '';
|
|
142
|
-
// Versioned dir first (has fresh installs, matches getManagedAgentBinDirs()
|
|
143
|
-
// order), flat dir second (retains bare POSIX agent scripts as fallback).
|
|
144
|
-
exportLine = portableNodeBin !== nodeRoot
|
|
145
|
-
? `export PATH="$PATH:$HOME/.fraim/node/${path_1.default.basename(portableNodeBin)}:$HOME/.fraim/node${fraimBinEntry}"`
|
|
146
|
-
: `export PATH="$PATH:$HOME/.fraim/node${fraimBinEntry}"`;
|
|
147
|
-
}
|
|
148
|
-
else {
|
|
149
|
-
exportLine = includePackagedFraimBin
|
|
150
|
-
? 'export PATH="$PATH:$HOME/.fraim/bin:$HOME/.fraim/node/bin"'
|
|
151
|
-
: 'export PATH="$PATH:$HOME/.fraim/node/bin"';
|
|
152
|
-
}
|
|
153
|
-
// Guard against editor-autocorrect curly-quote regression (issue #1688):
|
|
154
|
-
// U+201C/201D/2018/2019 in exportLine would be written verbatim to shell
|
|
155
|
-
// profiles and break PATH resolution for all pre-existing PATH entries.
|
|
156
|
-
if (/[“”‘’]/.test(exportLine)) {
|
|
157
|
-
throw new Error(`persistShellPath: exportLine contains curly quotes. Writing this to a shell profile would corrupt PATH resolution. Check for editor autocorrect in session-service.ts. Line: ${JSON.stringify(exportLine)}`);
|
|
158
|
-
}
|
|
159
|
-
const stanza = `\n${marker}\n${exportLine}\n`;
|
|
160
|
-
if (process.platform === 'win32') {
|
|
161
|
-
const bins = includePackagedFraimBin
|
|
162
|
-
? [path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'bin'), ...(0, managed_agent_paths_1.getManagedAgentBinDirs)()]
|
|
163
|
-
: (0, managed_agent_paths_1.getManagedAgentBinDirs)();
|
|
164
|
-
const psCmd = buildPersistShellPathWindowsCommand(bins);
|
|
165
|
-
// Issue #1692: a sandboxed test run (row detection on load, node row
|
|
166
|
-
// success, fraim row success all reach this function) must never write
|
|
167
|
-
// its temp fake-home bin dirs into the real, registry-backed Windows
|
|
168
|
-
// User PATH. FRAIM_TEST_SANDBOX is already the canonical "this process
|
|
169
|
-
// is a sandboxed test run" signal set by every test runner (see
|
|
170
|
-
// getUserFraimDirPath() in project-fraim-paths.ts) rather than a
|
|
171
|
-
// dedicated flag for this call site.
|
|
172
|
-
if (process.env.FRAIM_TEST_SANDBOX !== '1') {
|
|
173
|
-
(0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCmd], { encoding: 'utf8' });
|
|
174
|
-
}
|
|
175
|
-
// Fall through to write POSIX profile files for Git Bash (VS Code
|
|
176
|
-
// integrated terminal, Claude Code, etc.) which do not inherit the
|
|
177
|
-
// Windows user PATH written above.
|
|
178
|
-
}
|
|
179
|
-
const profiles = [
|
|
180
|
-
path_1.default.join(os_1.default.homedir(), '.zshrc'),
|
|
181
|
-
path_1.default.join(os_1.default.homedir(), '.bash_profile'),
|
|
182
|
-
path_1.default.join(os_1.default.homedir(), '.bashrc'),
|
|
183
|
-
];
|
|
184
|
-
// macOS defaults to zsh since Catalina — create .zshrc if it doesn't exist.
|
|
185
|
-
if (process.platform === 'darwin' && !fs_1.default.existsSync(profiles[0])) {
|
|
186
|
-
fs_1.default.writeFileSync(profiles[0], stanza, 'utf8');
|
|
187
|
-
}
|
|
188
|
-
// On Windows, Git Bash reads .bash_profile (login) and .bashrc (interactive).
|
|
189
|
-
// Neither is created by the OS or Git Bash installer, so create .bashrc if
|
|
190
|
-
// neither exists — otherwise the PATH entry is never written and Git Bash
|
|
191
|
-
// (VS Code integrated terminal, Claude Code) never sees managed node dirs.
|
|
192
|
-
if (process.platform === 'win32') {
|
|
193
|
-
const bashProfile = profiles[1]; // .bash_profile
|
|
194
|
-
const bashrc = profiles[2]; // .bashrc
|
|
195
|
-
if (!fs_1.default.existsSync(bashProfile) && !fs_1.default.existsSync(bashrc)) {
|
|
196
|
-
fs_1.default.writeFileSync(bashrc, stanza, 'utf8');
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
for (const profile of profiles) {
|
|
200
|
-
// .zshrc is macOS/Linux only; Git Bash on Windows uses .bash_profile/.bashrc.
|
|
201
|
-
if (process.platform === 'win32' && profile.endsWith('.zshrc'))
|
|
202
|
-
continue;
|
|
203
|
-
try {
|
|
204
|
-
if (!fs_1.default.existsSync(profile))
|
|
205
|
-
continue;
|
|
206
|
-
const content = fs_1.default.readFileSync(profile, 'utf8');
|
|
207
|
-
if (content.includes(legacyExportLine)) {
|
|
208
|
-
fs_1.default.writeFileSync(profile, content.replace(legacyExportLine, exportLine), 'utf8');
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
if (content.includes(marker)) {
|
|
212
|
-
if (!content.includes(exportLine)) {
|
|
213
|
-
// Stale export line: versioned dir changed, includePackagedFraimBin
|
|
214
|
-
// toggled, or platform-specific line was never written. Replace it.
|
|
215
|
-
fs_1.default.writeFileSync(profile, content.replace(/export PATH=[\u201D"][^\u201D"]*\.fraim[^\u201D"]*[\u201D"]/, exportLine), 'utf8');
|
|
216
|
-
}
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
fs_1.default.appendFileSync(profile, stanza, 'utf8');
|
|
220
|
-
}
|
|
221
|
-
catch {
|
|
222
|
-
// Ignore unreadable or missing profiles.
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
112
|
function getNextPromptPath() {
|
|
227
113
|
return path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'last-install', 'next-prompt.txt');
|
|
228
114
|
}
|
|
@@ -426,7 +312,7 @@ class FirstRunSessionService {
|
|
|
426
312
|
fraimRow.status = 'pending';
|
|
427
313
|
fraimRow.verb = "we'll configure your IDEs";
|
|
428
314
|
}
|
|
429
|
-
|
|
315
|
+
(0, persist_shell_path_1.persistShellPath)();
|
|
430
316
|
}
|
|
431
317
|
else {
|
|
432
318
|
fraimRow.status = 'pending';
|
|
@@ -676,7 +562,7 @@ class FirstRunSessionService {
|
|
|
676
562
|
this.persist();
|
|
677
563
|
return this.respond('FRAIM installer repair is required.', false);
|
|
678
564
|
}
|
|
679
|
-
|
|
565
|
+
(0, persist_shell_path_1.persistShellPath)();
|
|
680
566
|
row.status = 'ok';
|
|
681
567
|
row.verb = 'Node and FRAIM launchers ready';
|
|
682
568
|
this.persist();
|
|
@@ -686,7 +572,7 @@ class FirstRunSessionService {
|
|
|
686
572
|
// If node is missing here something is severely wrong; fall through to error.
|
|
687
573
|
const ver = commandVersion('node');
|
|
688
574
|
if (ver) {
|
|
689
|
-
|
|
575
|
+
(0, persist_shell_path_1.persistShellPath)();
|
|
690
576
|
row.status = 'ok';
|
|
691
577
|
row.verb = `${ver} installed`;
|
|
692
578
|
this.persist();
|
|
@@ -718,7 +604,7 @@ class FirstRunSessionService {
|
|
|
718
604
|
this.persist();
|
|
719
605
|
return this.respond('git not found — continuing without it. Install git later if you plan to do code delivery work.', true);
|
|
720
606
|
}
|
|
721
|
-
async runFraimRow(
|
|
607
|
+
async runFraimRow() {
|
|
722
608
|
const row = this.getRow('fraim');
|
|
723
609
|
if (this.fakeMode) {
|
|
724
610
|
row.status = 'ok';
|
|
@@ -747,21 +633,9 @@ class FirstRunSessionService {
|
|
|
747
633
|
if (this.embeddedDesktop && (!fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)()) || !fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)()))) {
|
|
748
634
|
throw new Error('Stable FRAIM launchers are missing. Re-run the native installer in repair mode.');
|
|
749
635
|
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
row.streamOutput = 'Installing FRAIM on this machine...';
|
|
754
|
-
this.persist();
|
|
755
|
-
await runProcess('npm', ['install', '-g', 'fraim@latest'], { npm_config_prefix: prefix });
|
|
756
|
-
}
|
|
757
|
-
else if (forceConfigure && !commandVersion('fraim')) {
|
|
758
|
-
const prefix = path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
|
|
759
|
-
fs_1.default.mkdirSync(prefix, { recursive: true });
|
|
760
|
-
row.streamOutput = 'Installing FRAIM on this machine...';
|
|
761
|
-
this.persist();
|
|
762
|
-
await runProcess('npm', ['install', '-g', 'fraim@latest'], { npm_config_prefix: prefix });
|
|
763
|
-
}
|
|
764
|
-
persistShellPath(this.embeddedDesktop);
|
|
636
|
+
if (!this.embeddedDesktop)
|
|
637
|
+
(0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
|
|
638
|
+
(0, persist_shell_path_1.persistShellPath)();
|
|
765
639
|
const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
|
|
766
640
|
if (detectedIDEs.length > 0) {
|
|
767
641
|
await (0, auto_mcp_setup_1.autoConfigureMCP)(this.key, detectedIDEs.map((ide) => ide.name));
|
|
@@ -1009,7 +883,7 @@ class FirstRunSessionService {
|
|
|
1009
883
|
fraimRow.verb = 'configuring FRAIM for local AI agents';
|
|
1010
884
|
this.persist();
|
|
1011
885
|
appendInstallLog(`agent-recruiting-complete ready=${readyCount}`);
|
|
1012
|
-
return this.runFraimRow(
|
|
886
|
+
return this.runFraimRow();
|
|
1013
887
|
}
|
|
1014
888
|
openTerminalWithCommand(command) {
|
|
1015
889
|
if (process.platform === 'win32') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.329",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"author": "Sid Mathur <sid.mathur@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/mathursrus/FRAIM#readme",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"dist/src/cli/utils/machine-path.js",
|
|
50
50
|
"dist/src/cli/utils/managed-agent-install.js",
|
|
51
51
|
"dist/src/cli/utils/managed-agent-paths.js",
|
|
52
|
+
"dist/src/cli/utils/persist-shell-path.js",
|
|
52
53
|
"dist/src/cli/utils/managed-node-runtime.js",
|
|
53
54
|
"dist/src/cli/utils/org-publish.js",
|
|
54
55
|
"dist/src/cli/utils/pack-git-publish.js",
|
|
@@ -77,6 +78,7 @@
|
|
|
77
78
|
"dist/src/core/utils/local-registry-resolver.js",
|
|
78
79
|
"dist/src/core/utils/ports.js",
|
|
79
80
|
"dist/src/core/utils/project-fraim-paths.js",
|
|
81
|
+
"dist/src/core/utils/redact-secrets.js",
|
|
80
82
|
"dist/src/core/utils/setup-preferences.js",
|
|
81
83
|
"dist/src/core/utils/windows-shim-path-search.js",
|
|
82
84
|
"dist/src/db/payment-repository.js",
|
|
@@ -214,7 +216,7 @@
|
|
|
214
216
|
"electron-updater": "^6.8.9",
|
|
215
217
|
"express": "^5.2.1",
|
|
216
218
|
"extract-zip": "^2.0.1",
|
|
217
|
-
"fraim": "2.0.
|
|
219
|
+
"fraim": "2.0.329",
|
|
218
220
|
"mongodb": "^7.0.0",
|
|
219
221
|
"node-cron": "4.2.1",
|
|
220
222
|
"node-edge-tts": "^1.2.10",
|