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.
@@ -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 prefix = (0, managed_agent_paths_1.getPortableNodeBinPath)();
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
  }
@@ -36,12 +36,12 @@ Object.defineProperty(exports, "resolveMachinePathCached", { enumerable: true, g
36
36
  function getManagedNodeRoot() {
37
37
  return path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
38
38
  }
39
- function getPortableNodeBinPath() {
39
+ function getPortableNodeBinPath(platform = process.platform, arch = process.arch) {
40
40
  const nodeRoot = getManagedNodeRoot();
41
- if (process.platform === 'win32') {
41
+ if (platform === 'win32') {
42
42
  if (fs_1.default.existsSync(nodeRoot)) {
43
43
  const extractedDir = fs_1.default.readdirSync(nodeRoot, { withFileTypes: true })
44
- .filter((entry) => entry.isDirectory() && entry.name.startsWith('node-v'))
44
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith('node-v') && !entry.name.includes('.staging-'))
45
45
  .sort((a, b) => b.name.localeCompare(a.name))[0];
46
46
  if (extractedDir) {
47
47
  return path_1.default.join(nodeRoot, extractedDir.name);
@@ -49,6 +49,17 @@ function getPortableNodeBinPath() {
49
49
  }
50
50
  return nodeRoot;
51
51
  }
52
+ if (fs_1.default.existsSync(nodeRoot)) {
53
+ const versioned = fs_1.default.readdirSync(nodeRoot, { withFileTypes: true })
54
+ .filter(entry => entry.isDirectory() && /^node-v\d+\.\d+\.\d+-/.test(entry.name)
55
+ && entry.name.endsWith(`-${platform}-${arch}`))
56
+ .sort((a, b) => b.name.localeCompare(a.name, undefined, { numeric: true }));
57
+ for (const entry of versioned) {
58
+ const bin = path_1.default.join(nodeRoot, entry.name, 'bin');
59
+ if (fs_1.default.existsSync(path_1.default.join(bin, 'node')) && fs_1.default.existsSync(path_1.default.join(bin, 'npm')))
60
+ return bin;
61
+ }
62
+ }
52
63
  return path_1.default.join(nodeRoot, 'bin');
53
64
  }
54
65
  function getManagedAgentBinDirs() {
@@ -17,16 +17,11 @@ const path_1 = __importDefault(require("path"));
17
17
  const https_1 = __importDefault(require("https"));
18
18
  const crypto_1 = __importDefault(require("crypto"));
19
19
  const managed_agent_paths_1 = require("./managed-agent-paths");
20
- // Issue #1415 (extended): a genuinely GUI-only FRAIM Hub install — the native installer
21
- // downloaded from the website, never a CLI — has no working `npm`/`npx` anywhere. First-run's
22
- // `embeddedDesktop` branch (session-service.ts) deliberately skips installing one (it only needs
23
- // Electron's own bundled Node for FRAIM's own CLI/MCP shim, via ELECTRON_RUN_AS_NODE), and the
24
- // separate CLI installer's portable-Node bootstrap (scripts/installer/fraim-install-win.template.cmd)
25
- // never runs for a desktop-only user. That left two features silently unable to do their real job
26
- // on such a machine: `hub-app-materializer.ts`'s npm install (this issue), and agent-CLI
27
- // installation (`managed-agent-install.ts`), which also shells out to a bare `npm`.
20
+ // Desktop-only users may have no working system npm/npx. Installer setup, Hub
21
+ // materialization, first-run setup, and agent installation share this managed
22
+ // runtime resolver so each can prepare or reuse standalone Node and npm.
28
23
  //
29
- // This module is the one place that guarantees a real, usable `npm`/`node` exists, for both
24
+ // This module is the one place that guarantees a real, usable `npm`/`node` exists for its
30
25
  // consumers: prefer whatever is already on system PATH (common for technical users), then an
31
26
  // already-downloaded managed runtime (shared with the CLI installer's `~/.fraim/node/` convention
32
27
  // via `managed-agent-paths.ts` — reused, not duplicated, so a user who has ever run the `fraim` CLI
@@ -65,11 +60,14 @@ function findExecutableOnPath(name, pathValue = process.env.PATH) {
65
60
  function hasWorkingNodeAndNpm(dir, platform = process.platform) {
66
61
  return fs_1.default.existsSync(path_1.default.join(dir, npmExecutableName(platform))) && fs_1.default.existsSync(path_1.default.join(dir, nodeExecutableName(platform)));
67
62
  }
68
- /** Whether `dir` (a specific versioned bin dir) holds a complete extraction for exactly `version`. */
63
+ function nodeBinDir(root, platform) {
64
+ return platform === 'win32' ? root : path_1.default.join(root, 'bin');
65
+ }
66
+ /** Whether `dir` (a versioned distribution root) holds a complete extraction for `version`. */
69
67
  function isManagedNodeVersionComplete(dir, version, platform = process.platform) {
70
68
  try {
71
69
  const recorded = fs_1.default.readFileSync(path_1.default.join(dir, 'fraim-managed-node-version'), 'utf8').trim();
72
- return recorded === version && hasWorkingNodeAndNpm(dir, platform);
70
+ return recorded === version && hasWorkingNodeAndNpm(nodeBinDir(dir, platform), platform);
73
71
  }
74
72
  catch {
75
73
  return false;
@@ -99,6 +97,9 @@ function assertPathSafeNodeVersion(version) {
99
97
  }
100
98
  /** The exact folder name Node's own official archive extracts as — also this module's versioned subfolder name under the shared `~/.fraim/node/` root. */
101
99
  function nodeVersionedDirName(request) {
100
+ if (!/^[a-z0-9_]+$/.test(request.arch)) {
101
+ throw new Error('Refusing to use an invalid managed Node.js architecture.');
102
+ }
102
103
  return `node-v${assertPathSafeNodeVersion(request.version)}-${nodePlatformName(request.platform)}-${request.arch}`;
103
104
  }
104
105
  function nodeArchiveFileName(request) {
@@ -203,15 +204,16 @@ const defaultExtractNodeArchive = async (archivePath, destDir, request) => {
203
204
  async function downloadAndPromoteVersionedNodeDir(args) {
204
205
  const { nodeRoot, request, downloadArchive, extractArchive } = args;
205
206
  const destDir = path_1.default.join(nodeRoot, nodeVersionedDirName(request));
206
- const stagingDir = `${destDir}.staging-${process.pid}`;
207
207
  fs_1.default.mkdirSync(nodeRoot, { recursive: true });
208
- fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
209
208
  const archivePath = await downloadArchive(request);
209
+ let stagingDir;
210
210
  try {
211
+ // tar requires an existing cwd. Each invocation owns a separate directory,
212
+ // including simultaneous requests within the same process.
213
+ stagingDir = fs_1.default.mkdtempSync(`${destDir}.staging-${process.pid}-`);
211
214
  await extractArchive(archivePath, stagingDir, request);
212
- // The archive's own top-level folder (matching nodeVersionedDirName) may land either as the
213
- // extraction root itself (tar) or as one nested level inside it (some zip extractors preserve
214
- // the archive's internal folder) — handle both without assuming either.
215
+ // Official tar and ZIP archives preserve their top-level distribution folder.
216
+ // Also accept a flattened root from a supplied extractor.
215
217
  const nestedCandidate = path_1.default.join(stagingDir, nodeVersionedDirName(request));
216
218
  const extractedRoot = fs_1.default.existsSync(nestedCandidate) ? nestedCandidate : stagingDir;
217
219
  fs_1.default.writeFileSync(path_1.default.join(extractedRoot, 'fraim-managed-node-version'), request.version, 'utf8');
@@ -233,9 +235,10 @@ async function downloadAndPromoteVersionedNodeDir(args) {
233
235
  }
234
236
  finally {
235
237
  fs_1.default.rmSync(archivePath, { force: true });
236
- fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
238
+ if (stagingDir)
239
+ fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
237
240
  }
238
- return destDir;
241
+ return nodeBinDir(destDir, request.platform);
239
242
  }
240
243
  /**
241
244
  * Resolve a working `npm` executable, preferring (in order): system PATH, an already-downloaded
@@ -252,7 +255,7 @@ async function ensureManagedNpm(options = {}) {
252
255
  if (systemNpm) {
253
256
  return { npmPath: systemNpm, binDir: path_1.default.dirname(systemNpm), source: 'system' };
254
257
  }
255
- const existingBinDir = (0, managed_agent_paths_1.getPortableNodeBinPath)();
258
+ const existingBinDir = (0, managed_agent_paths_1.getPortableNodeBinPath)(platform, arch);
256
259
  if (hasWorkingNodeAndNpm(existingBinDir, platform)) {
257
260
  return { npmPath: path_1.default.join(existingBinDir, npmExecutableName(platform)), binDir: existingBinDir, source: 'managed-existing' };
258
261
  }
@@ -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
  }
@@ -323,7 +209,7 @@ function buildFirstRunHubLaunchArgs() {
323
209
  class FirstRunSessionService {
324
210
  constructor(options) {
325
211
  // Issue #646: the key may be absent when first-run is launched by the macOS
326
- // installer (.pkg) instead of `fraim first-run --key=…`. In that case the
212
+ // installer (.dmg) instead of `fraim first-run --key=…`. In that case the
327
213
  // wizard prompts the user to paste their key (see setKey / needsKey).
328
214
  this.key = options.key || '';
329
215
  this.headless = options.headless === true;
@@ -393,13 +279,15 @@ class FirstRunSessionService {
393
279
  // Real detection — populate row statuses from `command -v` style probes.
394
280
  const nodeRow = this.getRow('node');
395
281
  const nodeVer = this.embeddedDesktop ? process.version : commandVersion('node');
396
- if (nodeVer) {
282
+ const embeddedLaunchersReady = !this.embeddedDesktop
283
+ || (fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)()) && fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)()));
284
+ if (nodeVer && embeddedLaunchersReady) {
397
285
  nodeRow.status = 'ok';
398
286
  nodeRow.verb = this.embeddedDesktop ? `${nodeVer} bundled with FRAIM` : `${nodeVer} detected`;
399
287
  }
400
288
  else {
401
289
  nodeRow.status = 'pending';
402
- nodeRow.verb = "we'll install";
290
+ nodeRow.verb = this.embeddedDesktop ? 'installer repair required' : "we'll install";
403
291
  }
404
292
  const gitRow = this.getRow('git');
405
293
  const gitVer = commandVersion('git');
@@ -424,7 +312,7 @@ class FirstRunSessionService {
424
312
  fraimRow.status = 'pending';
425
313
  fraimRow.verb = "we'll configure your IDEs";
426
314
  }
427
- persistShellPath(this.embeddedDesktop);
315
+ (0, persist_shell_path_1.persistShellPath)();
428
316
  }
429
317
  else {
430
318
  fraimRow.status = 'pending';
@@ -669,17 +557,22 @@ class FirstRunSessionService {
669
557
  async runNodeRow() {
670
558
  const row = this.getRow('node');
671
559
  if (this.embeddedDesktop) {
672
- persistShellPath(this.embeddedDesktop);
560
+ if (!this.fakeMode && (!fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)()) || !fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)()))) {
561
+ this.setRowError(row, 'Checking the native installer setup', 'Stable FRAIM CLI/MCP launchers are missing. Re-run the native installer in repair mode.', [{ id: 'retry', label: 'Check again', variant: 'primary' }]);
562
+ this.persist();
563
+ return this.respond('FRAIM installer repair is required.', false);
564
+ }
565
+ (0, persist_shell_path_1.persistShellPath)();
673
566
  row.status = 'ok';
674
- row.verb = `${process.version} bundled with FRAIM`;
567
+ row.verb = 'Node and FRAIM launchers ready';
675
568
  this.persist();
676
- return this.respond(`FRAIM includes its own runtime (${process.version}).`, true);
569
+ return this.respond('FRAIM command-line tools are ready.', true);
677
570
  }
678
571
  // Node is bootstrapped by the install template before the wizard starts.
679
572
  // If node is missing here something is severely wrong; fall through to error.
680
573
  const ver = commandVersion('node');
681
574
  if (ver) {
682
- persistShellPath(this.embeddedDesktop);
575
+ (0, persist_shell_path_1.persistShellPath)();
683
576
  row.status = 'ok';
684
577
  row.verb = `${ver} installed`;
685
578
  this.persist();
@@ -711,7 +604,7 @@ class FirstRunSessionService {
711
604
  this.persist();
712
605
  return this.respond('git not found — continuing without it. Install git later if you plan to do code delivery work.', true);
713
606
  }
714
- async runFraimRow(forceConfigure = false) {
607
+ async runFraimRow() {
715
608
  const row = this.getRow('fraim');
716
609
  if (this.fakeMode) {
717
610
  row.status = 'ok';
@@ -737,24 +630,12 @@ class FirstRunSessionService {
737
630
  return this.respond('FRAIM configured.', true);
738
631
  }
739
632
  try {
740
- if (this.embeddedDesktop) {
741
- (0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
742
- }
743
- else if (!forceConfigure && !commandVersion('fraim')) {
744
- const prefix = path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
745
- fs_1.default.mkdirSync(prefix, { recursive: true });
746
- row.streamOutput = 'Installing FRAIM on this machine...';
747
- this.persist();
748
- await runProcess('npm', ['install', '-g', 'fraim@latest'], { npm_config_prefix: prefix });
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)()))) {
634
+ throw new Error('Stable FRAIM launchers are missing. Re-run the native installer in repair mode.');
749
635
  }
750
- else if (forceConfigure && !commandVersion('fraim')) {
751
- const prefix = path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
752
- fs_1.default.mkdirSync(prefix, { recursive: true });
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
- persistShellPath(this.embeddedDesktop);
636
+ if (!this.embeddedDesktop)
637
+ (0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
638
+ (0, persist_shell_path_1.persistShellPath)();
758
639
  const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
759
640
  if (detectedIDEs.length > 0) {
760
641
  await (0, auto_mcp_setup_1.autoConfigureMCP)(this.key, detectedIDEs.map((ide) => ide.name));
@@ -1002,7 +883,7 @@ class FirstRunSessionService {
1002
883
  fraimRow.verb = 'configuring FRAIM for local AI agents';
1003
884
  this.persist();
1004
885
  appendInstallLog(`agent-recruiting-complete ready=${readyCount}`);
1005
- return this.runFraimRow(true);
886
+ return this.runFraimRow();
1006
887
  }
1007
888
  openTerminalWithCommand(command) {
1008
889
  if (process.platform === 'win32') {
@@ -1051,12 +932,11 @@ class FirstRunSessionService {
1051
932
  };
1052
933
  }
1053
934
  if (this.embeddedDesktop) {
1054
- (0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
1055
- if (!fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)())) {
935
+ if (!fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)()) || !fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)())) {
1056
936
  return {
1057
937
  ok: false,
1058
938
  needsAgentSetup: true,
1059
- message: 'FRAIM CLI was not installed into the packaged desktop profile. Re-run FRAIM setup before opening Hub.',
939
+ message: 'FRAIM CLI/MCP launchers are missing. Re-run the native installer in repair mode before opening Hub.',
1060
940
  };
1061
941
  }
1062
942
  appendInstallLog('hub-opened embedded-desktop');