fraim 2.0.278 → 2.0.279

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.
@@ -15,6 +15,7 @@ const workflow_checks_1 = require("../doctor/checks/workflow-checks");
15
15
  const ide_config_checks_1 = require("../doctor/checks/ide-config-checks");
16
16
  const mcp_connectivity_checks_1 = require("../doctor/checks/mcp-connectivity-checks");
17
17
  const scripts_checks_1 = require("../doctor/checks/scripts-checks");
18
+ const agent_cli_health_checks_1 = require("../doctor/checks/agent-cli-health-checks");
18
19
  const add_ide_1 = require("./add-ide");
19
20
  // Read version from package.json
20
21
  const getFramVersion = () => {
@@ -58,7 +59,8 @@ function getAllChecks() {
58
59
  ...(0, workflow_checks_1.getJobChecks)(),
59
60
  ...(0, ide_config_checks_1.getIDEConfigChecks)(),
60
61
  ...(0, mcp_connectivity_checks_1.getMCPConnectivityChecks)(),
61
- ...(0, scripts_checks_1.getScriptsChecks)()
62
+ ...(0, scripts_checks_1.getScriptsChecks)(),
63
+ ...(0, agent_cli_health_checks_1.getAgentCliHealthChecks)()
62
64
  ];
63
65
  }
64
66
  async function runFixMcpRepair(options, repair = add_ide_1.runAddIDE) {
@@ -10,6 +10,10 @@ const CHECK_TIMEOUT = 2000; // 2 seconds per check
10
10
  // Issue #532: stdio MCP servers need up to 15s for handshake + npm version resolution
11
11
  // on first call via fraim-mcp-latest-launcher; 20s gives a 5s buffer.
12
12
  const MCP_CHECK_TIMEOUT = 20000; // 20 seconds for MCP connectivity checks
13
+ // Issue #1285: this check shells out to `npm prefix -g`, up to three CLI
14
+ // `--version` probes, and (Windows) a PowerShell registry read — more real
15
+ // process spawns than the default budget comfortably covers.
16
+ const AGENT_CLI_HEALTH_CHECK_TIMEOUT = 10000; // 10 seconds for agent CLI health checks
13
17
  const TOTAL_TIMEOUT = 30000; // 30 seconds total
14
18
  // Simple logger for doctor command (optional, falls back to no-op)
15
19
  const logger = {
@@ -39,8 +43,12 @@ const trackMetric = (name, value) => {
39
43
  */
40
44
  async function runCheckWithTimeout(check, timeout = CHECK_TIMEOUT) {
41
45
  const checkStartTime = Date.now();
42
- // Use longer timeout for MCP connectivity checks
43
- const effectiveTimeout = check.category === 'mcpConnectivity' ? MCP_CHECK_TIMEOUT : timeout;
46
+ // Use longer timeouts for checks that shell out to real CLI/registry probes.
47
+ const effectiveTimeout = check.category === 'mcpConnectivity'
48
+ ? MCP_CHECK_TIMEOUT
49
+ : check.category === 'agentCliHealth'
50
+ ? AGENT_CLI_HEALTH_CHECK_TIMEOUT
51
+ : timeout;
44
52
  try {
45
53
  logger.debug(`Running check: ${check.name}`, { category: check.category });
46
54
  const result = await Promise.race([
@@ -132,7 +140,8 @@ async function runChecks(checks, options, version) {
132
140
  jobs: { checks: [] },
133
141
  ideConfiguration: { checks: [] },
134
142
  mcpConnectivity: { checks: [] },
135
- scripts: { checks: [] }
143
+ scripts: { checks: [] },
144
+ agentCliHealth: { checks: [] }
136
145
  };
137
146
  let passed = 0;
138
147
  let warnings = 0;
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ /**
3
+ * Agent CLI PATH/version health checks for FRAIM doctor command
4
+ * Issue #1284/#1285: detect drift between FRAIM's own managed-PATH resolution,
5
+ * the ambient shell PATH, and the actual npm-global install for agent CLIs
6
+ * FRAIM installs through its managed-Node fallback.
7
+ */
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.getAgentCliHealthChecks = getAgentCliHealthChecks;
13
+ exports.checkAgentCliHealthByCommand = checkAgentCliHealthByCommand;
14
+ const child_process_1 = require("child_process");
15
+ const path_1 = __importDefault(require("path"));
16
+ const managed_agent_paths_1 = require("../../utils/managed-agent-paths");
17
+ const command_resolution_1 = require("../../mcp/command-resolution");
18
+ // Codex is FRAIM's first Hub-compatible CLI with a managed-install fallback
19
+ // (npm install -g into FRAIM's portable Node when no system install is
20
+ // found). Extend this list as claude/gemini/copilot gain the same fallback.
21
+ const MANAGED_CLIS = [
22
+ { id: 'codex', label: 'Codex', command: 'codex' },
23
+ ];
24
+ // Windows cannot CreateProcess a `.cmd`/`.bat` file directly (spawnSync on a
25
+ // resolved absolute `.cmd` path throws EINVAL) — it must go through cmd.exe,
26
+ // matching the same wrapping `hosts.ts`'s `resolveHostInvocation()` and
27
+ // `server.ts`'s `hubCommandVersion()` already use. Duplicated here (not
28
+ // imported from `hosts.ts`) because `src/cli` is a pure layer that cannot
29
+ // import the server-layer `src/ai-hub` module (`scripts/validate-purity.ts`).
30
+ function quoteWindowsArg(value) {
31
+ if (value.length === 0)
32
+ return '""';
33
+ return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1')}"`;
34
+ }
35
+ function escapeWindowsArg(value) {
36
+ return /[\s"]/u.test(value) ? quoteWindowsArg(value) : value;
37
+ }
38
+ function probeVersion(commandPath) {
39
+ try {
40
+ const executable = process.platform === 'win32' ? 'cmd.exe' : commandPath;
41
+ const args = process.platform === 'win32'
42
+ ? ['/d', '/s', '/c', `${escapeWindowsArg(commandPath)} --version`]
43
+ : ['--version'];
44
+ const result = (0, child_process_1.spawnSync)(executable, args, { encoding: 'utf8', timeout: 5000 });
45
+ if (result.status !== 0 || result.error)
46
+ return null;
47
+ return (result.stdout || result.stderr || '').trim() || null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ // The persisted Windows User PATH (what a brand-new terminal reads) can
54
+ // disagree with this process's own inherited `process.env.PATH` (what a
55
+ // shell already open before the fix landed still uses) — issue #1285's
56
+ // exact "update said success but --version looked wrong" ambiguity.
57
+ // Windows-only; there is no equivalent persisted-PATH registry on macOS/Linux.
58
+ function readPersistedUserPath() {
59
+ if (process.platform !== 'win32')
60
+ return null;
61
+ try {
62
+ const result = (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', "[Environment]::GetEnvironmentVariable('PATH','User')"], { encoding: 'utf8', timeout: 5000 });
63
+ if (result.status !== 0 || result.error)
64
+ return null;
65
+ return (result.stdout || '').trim() || null;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ async function runAgentCliHealthCheck(cli) {
72
+ // "Ambient" is this process's own raw, unmodified PATH — exactly what a
73
+ // shell already open before a PATH fix landed still sees (issue #1285's
74
+ // "any shell the user already has open keeps its own stale in-memory copy
75
+ // until restarted" applies to the process running this check too, not
76
+ // only the user's terminal). "Managed" strips any stale managed directory
77
+ // already sitting on that ambient PATH and re-appends FRAIM's current,
78
+ // correctly-ordered managed dirs — so it always reflects the current
79
+ // versioned build, independent of whatever the ambient PATH happens to
80
+ // still contain. Deliberately NOT `resolveManagedCommand()`: that helper
81
+ // is system-PATH-first for launch purposes and would silently agree with
82
+ // a stale ambient entry instead of surfacing the drift this check exists
83
+ // to catch.
84
+ const ambientPath = (0, command_resolution_1.getSystemCommandPath)(cli.command);
85
+ const managedSearchPath = (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH);
86
+ const managedPath = (0, command_resolution_1.getSystemCommandPath)(cli.command, managedSearchPath);
87
+ if (!ambientPath && !managedPath) {
88
+ return {
89
+ status: 'passed',
90
+ message: `${cli.label} is not installed; nothing to check.`,
91
+ };
92
+ }
93
+ const ambientVersion = ambientPath ? probeVersion(ambientPath) : null;
94
+ const managedVersion = !managedPath
95
+ ? null
96
+ : managedPath === ambientPath
97
+ ? ambientVersion
98
+ : probeVersion(managedPath);
99
+ const npmGlobalBinDirs = (0, managed_agent_paths_1.resolveNpmGlobalBinDirs)();
100
+ const npmGlobalPath = npmGlobalBinDirs.length > 0
101
+ ? (0, command_resolution_1.getSystemCommandPath)(cli.command, npmGlobalBinDirs.join(path_1.default.delimiter))
102
+ : null;
103
+ const npmGlobalVersion = npmGlobalPath ? probeVersion(npmGlobalPath) : null;
104
+ // Drift means either: the ambient shell resolves a different version than
105
+ // the actual npm-global install, or this process's own ambient PATH
106
+ // resolves a *different file* than FRAIM's freshly-recomputed managed-dir
107
+ // resolution would — the exact "stale shim still shadows the current one"
108
+ // signature from #1285.
109
+ const versionMismatch = Boolean((ambientVersion && npmGlobalVersion && ambientVersion !== npmGlobalVersion)
110
+ || (ambientPath && managedPath && ambientPath !== managedPath));
111
+ if (!versionMismatch) {
112
+ return {
113
+ status: 'passed',
114
+ message: `${cli.label} is consistent (${ambientVersion || managedVersion || 'unknown version'}).`,
115
+ details: { ambientPath, managedPath, npmGlobalPath },
116
+ };
117
+ }
118
+ const persistedUserPath = readPersistedUserPath();
119
+ const persistedResolved = persistedUserPath ? (0, command_resolution_1.getSystemCommandPath)(cli.command, persistedUserPath) : null;
120
+ const persistedVersion = persistedResolved ? probeVersion(persistedResolved) : null;
121
+ const persistedMatchesManaged = Boolean(persistedResolved && managedPath && persistedResolved === managedPath);
122
+ const suggestion = persistedUserPath === null
123
+ ? `Restart your terminal, then run "${cli.command} --version" again to confirm.`
124
+ : persistedMatchesManaged
125
+ ? `Your saved PATH is already correct — open a new terminal window so it takes effect, then run "${cli.command} --version" again.`
126
+ : `Your saved PATH still resolves the old ${cli.label} build. Run "${cli.command} update" (or reinstall ${cli.label}), then open a new terminal.`;
127
+ return {
128
+ status: 'warning',
129
+ message: `${cli.label} PATH drift detected: ambient ${ambientVersion || 'not found'} vs managed ${managedVersion || 'not found'} vs npm-global ${npmGlobalVersion || 'not found'}.`,
130
+ suggestion,
131
+ details: {
132
+ ambientPath, ambientVersion,
133
+ managedPath, managedVersion,
134
+ npmGlobalPath, npmGlobalVersion,
135
+ persistedUserPathResolvedTo: persistedResolved,
136
+ persistedUserPathVersion: persistedVersion,
137
+ },
138
+ };
139
+ }
140
+ function agentCliHealthCheck(cli) {
141
+ return {
142
+ name: `${cli.label} PATH/version health`,
143
+ category: 'agentCliHealth',
144
+ critical: false,
145
+ run: () => runAgentCliHealthCheck(cli),
146
+ };
147
+ }
148
+ function getAgentCliHealthChecks() {
149
+ return MANAGED_CLIS.map(agentCliHealthCheck);
150
+ }
151
+ // Reused by the Hub's `/api/ai-hub/check-agent` route (issue #1285 §4) so a
152
+ // manager who clicks "Check if Ready" right after `codex update` sees PATH
153
+ // drift immediately instead of a bare pass/fail. Returns null for a CLI this
154
+ // module does not track.
155
+ async function checkAgentCliHealthByCommand(command) {
156
+ const cli = MANAGED_CLIS.find((entry) => entry.command === command);
157
+ if (!cli)
158
+ return null;
159
+ try {
160
+ return await runAgentCliHealthCheck(cli);
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }
@@ -16,7 +16,8 @@ const CATEGORY_NAMES = {
16
16
  jobs: 'Jobs',
17
17
  ideConfiguration: 'IDE Configuration',
18
18
  mcpConnectivity: 'MCP Server Connectivity',
19
- scripts: 'Scripts'
19
+ scripts: 'Scripts',
20
+ agentCliHealth: 'Agent CLI Health'
20
21
  };
21
22
  function getStatusIcon(status) {
22
23
  switch (status) {
@@ -3,47 +3,24 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.resolveManagedCommand = exports.getSystemCommandPath = exports.getPortableNpxCommand = void 0;
6
+ exports.resolveManagedCommand = exports.getPortableManagedCommandPath = exports.getSystemCommandPath = void 0;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
- const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
10
- const getPortableNodeRoot = () => path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'node');
11
- const getPortableNpxCandidates = () => {
12
- const nodeRoot = getPortableNodeRoot();
13
- if (process.platform === 'win32') {
14
- const candidates = [path_1.default.join(nodeRoot, 'npx.cmd')];
15
- if (fs_1.default.existsSync(nodeRoot)) {
16
- const extractedDirs = fs_1.default.readdirSync(nodeRoot, { withFileTypes: true })
17
- .filter((entry) => entry.isDirectory() && entry.name.startsWith('node-v'))
18
- .sort((a, b) => b.name.localeCompare(a.name));
19
- for (const entry of extractedDirs) {
20
- candidates.push(path_1.default.join(nodeRoot, entry.name, 'npx.cmd'));
21
- }
22
- }
23
- return candidates;
24
- }
25
- return [
26
- path_1.default.join(nodeRoot, 'bin', 'npx'),
27
- path_1.default.join(nodeRoot, 'npx')
28
- ];
29
- };
30
- const getPortableNpxCommand = () => {
31
- for (const candidate of getPortableNpxCandidates()) {
32
- if (fs_1.default.existsSync(candidate)) {
33
- return candidate;
34
- }
35
- }
36
- return null;
37
- };
38
- exports.getPortableNpxCommand = getPortableNpxCommand;
39
- const getPathEntries = () => {
40
- const rawPath = process.env.PATH || '';
9
+ const managed_agent_paths_1 = require("../utils/managed-agent-paths");
10
+ // Issue #1284/#1285 (Implementation Strategy §0): commands FRAIM resolves
11
+ // fresh at launch time instead of trusting a persisted PATH string. Started
12
+ // as `npx`-only; generalized to every agent CLI FRAIM itself launches
13
+ // (Hub-driven runs, version probes, install/check-agent routes) so those
14
+ // launches are immune to PATH-order drift structurally.
15
+ const MANAGED_COMMANDS = new Set(['npx', 'codex', 'claude', 'gemini', 'copilot']);
16
+ const getPathEntries = (basePath) => {
17
+ const rawPath = (0, managed_agent_paths_1.stripProjectLocalNodeBinDirs)(basePath ?? process.env.PATH ?? '');
41
18
  return rawPath
42
19
  .split(path_1.default.delimiter)
43
20
  .map((entry) => entry.trim())
44
21
  .filter(Boolean);
45
22
  };
46
- const getSystemCommandCandidates = (command) => {
23
+ const getSystemCommandCandidates = (command, basePath) => {
47
24
  if (!command || path_1.default.isAbsolute(command)) {
48
25
  return command ? [command] : [];
49
26
  }
@@ -52,10 +29,14 @@ const getSystemCommandCandidates = (command) => {
52
29
  ? [command]
53
30
  : [command, `${command}.cmd`, `${command}.exe`, `${command}.bat`, `${command}.com`]
54
31
  : [command];
55
- return getPathEntries().flatMap((entry) => commandNames.map((name) => path_1.default.join(entry, name)));
32
+ return getPathEntries(basePath).flatMap((entry) => commandNames.map((name) => path_1.default.join(entry, name)));
56
33
  };
57
- const getSystemCommandPath = (command) => {
58
- for (const candidate of getSystemCommandCandidates(command)) {
34
+ // `basePath` defaults to the current process PATH; pass an explicit PATH
35
+ // string to resolve a command against a different candidate list (e.g. the
36
+ // npm-global bin dirs computed by `resolveNpmGlobalBinDirs`) without mutating
37
+ // `process.env.PATH`.
38
+ const getSystemCommandPath = (command, basePath) => {
39
+ for (const candidate of getSystemCommandCandidates(command, basePath)) {
59
40
  try {
60
41
  const stats = fs_1.default.statSync(candidate);
61
42
  if (stats.isFile()) {
@@ -69,13 +50,32 @@ const getSystemCommandPath = (command) => {
69
50
  return null;
70
51
  };
71
52
  exports.getSystemCommandPath = getSystemCommandPath;
53
+ // Mirrors the shape of the old `getPortableNpxCandidates()`, but checks only
54
+ // the current versioned `getPortableNodeBinPath()` directory — never the
55
+ // legacy flat directory — so a shim orphaned there (Defect A) can never be
56
+ // selected here regardless of what's on disk.
57
+ const getPortableManagedCommandCandidates = (command) => {
58
+ const versionedDir = (0, managed_agent_paths_1.getPortableNodeBinPath)();
59
+ if (process.platform === 'win32') {
60
+ return [`${command}.cmd`, `${command}.exe`].map((name) => path_1.default.join(versionedDir, name));
61
+ }
62
+ return [path_1.default.join(versionedDir, command)];
63
+ };
64
+ const getPortableManagedCommandPath = (command) => {
65
+ for (const candidate of getPortableManagedCommandCandidates(command)) {
66
+ if (fs_1.default.existsSync(candidate)) {
67
+ return candidate;
68
+ }
69
+ }
70
+ return null;
71
+ };
72
+ exports.getPortableManagedCommandPath = getPortableManagedCommandPath;
72
73
  const resolveManagedCommand = (command) => {
73
- if (command !== 'npx') {
74
+ if (!MANAGED_COMMANDS.has(command))
74
75
  return command;
75
- }
76
- // Prefer system-installed npx so we don't install our own Node when the
77
- // machine already has one. Fall back to the FRAIM-managed portable copy
78
- // only when no system npx is found. Last resort: bare command name.
79
- return (0, exports.getSystemCommandPath)(command) || (0, exports.getPortableNpxCommand)() || command;
76
+ // Prefer a system-installed CLI so FRAIM doesn't install its own copy when
77
+ // the machine already has one. Fall back to the FRAIM-managed portable copy
78
+ // only when no system install is found. Last resort: bare command name.
79
+ return (0, exports.getSystemCommandPath)(command) || (0, exports.getPortableManagedCommandPath)(command) || command;
80
80
  };
81
81
  exports.resolveManagedCommand = resolveManagedCommand;
@@ -0,0 +1,55 @@
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.installManagedAgent = installManagedAgent;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const managed_agent_paths_1 = require("./managed-agent-paths");
9
+ // Issue #1284/#1285 (Implementation Strategy §2): the exact "standard npm
10
+ // global install, then fall back to FRAIM's managed prefix" sequence used to
11
+ // be duplicated verbatim in `server.ts` (Hub's install-agent route) and
12
+ // `session-service.ts` (first-run's installAgent), including Defect A — both
13
+ // pointed `npm_config_prefix` at the flat `nodeRoot` dir instead of the
14
+ // versioned dir that actually contains the Node/npm binaries running the
15
+ // install. Extracted so the prefix fix (and any future fix to this sequence)
16
+ // lives in one place. Callers are responsible for the "already installed"
17
+ // check before invoking this — it only covers the install-then-fallback path.
18
+ async function installManagedAgent(option, systemPath, deps) {
19
+ let standardInstallError = null;
20
+ try {
21
+ await deps.runProcess('npm', ['install', '-g', option.installPackage], {
22
+ PATH: systemPath,
23
+ npm_config_prefix: undefined,
24
+ NPM_CONFIG_PREFIX: undefined,
25
+ });
26
+ const standardVersion = deps.commandVersion(option.launchCommand, undefined, systemPath);
27
+ const npmGlobalBinDirs = standardVersion
28
+ ? []
29
+ : (0, managed_agent_paths_1.resolveNpmGlobalBinDirs)(systemPath, {
30
+ npm_config_prefix: undefined,
31
+ NPM_CONFIG_PREFIX: undefined,
32
+ });
33
+ const standardVersionWithNpmBin = standardVersion
34
+ || (npmGlobalBinDirs.length > 0
35
+ ? deps.commandVersion(option.launchCommand, npmGlobalBinDirs, systemPath)
36
+ : null);
37
+ if (standardVersionWithNpmBin) {
38
+ return { outcome: 'standard', npmGlobalBinDirs };
39
+ }
40
+ standardInstallError = `${option.label} standard install completed, but the CLI is not runnable from the user PATH.`;
41
+ }
42
+ catch (error) {
43
+ standardInstallError = error instanceof Error ? error.message : 'Unknown error';
44
+ }
45
+ // Defect A fix: co-locate npm-global shims with the node.exe/npm.cmd that
46
+ // actually runs the install, instead of the flat legacy directory.
47
+ const prefix = (0, managed_agent_paths_1.getPortableNodeBinPath)();
48
+ fs_1.default.mkdirSync(prefix, { recursive: true });
49
+ await deps.runProcess('npm', ['install', '-g', option.installPackage], { npm_config_prefix: prefix });
50
+ const ver = deps.commandVersion(option.launchCommand, (0, managed_agent_paths_1.getManagedAgentBinDirs)());
51
+ if (!ver) {
52
+ throw new Error(`${option.label} install completed, but the CLI is not runnable from FRAIM's managed PATH. Standard install failure: ${standardInstallError}`);
53
+ }
54
+ return { outcome: 'managed' };
55
+ }
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.getManagedNodeRoot = getManagedNodeRoot;
7
7
  exports.getPortableNodeBinPath = getPortableNodeBinPath;
8
8
  exports.getManagedAgentBinDirs = getManagedAgentBinDirs;
9
+ exports.stripProjectLocalNodeBinDirs = stripProjectLocalNodeBinDirs;
10
+ exports.cleanupOrphanedManagedShims = cleanupOrphanedManagedShims;
9
11
  exports.stripManagedAgentBinDirsFromPath = stripManagedAgentBinDirsFromPath;
10
12
  exports.appendBinDirsToPath = appendBinDirsToPath;
11
13
  exports.getNpmGlobalBinDirsFromPrefix = getNpmGlobalBinDirsFromPrefix;
@@ -37,11 +39,62 @@ function getPortableNodeBinPath() {
37
39
  function getManagedAgentBinDirs() {
38
40
  const nodeRoot = getManagedNodeRoot();
39
41
  const portableNodeBin = getPortableNodeBinPath();
42
+ // The current versioned Node directory must outrank the legacy flat directory:
43
+ // a shim orphaned in the flat directory by an older FRAIM version must never
44
+ // shadow a newer bundled build (issue #1285). uniquePathEntries'/Set-based dedup
45
+ // collapses this to a single entry when no versioned subfolder exists yet.
40
46
  const candidates = process.platform === 'win32'
41
- ? [nodeRoot, portableNodeBin]
42
- : [nodeRoot, path_1.default.join(nodeRoot, 'bin'), portableNodeBin];
47
+ ? [portableNodeBin, nodeRoot]
48
+ : [portableNodeBin, nodeRoot, path_1.default.join(nodeRoot, 'bin')];
43
49
  return [...new Set(candidates.filter(Boolean))];
44
50
  }
51
+ // Issue #1284/#1285: strips PATH entries that point at a project-local
52
+ // `node_modules/.bin` directory. Relocated from `src/ai-hub/hosts.ts` (Defect D)
53
+ // so the pure `src/cli` layer (which `command-resolution.ts` belongs to, and
54
+ // which cannot import from the server-layer `hosts.ts`) can apply the same
55
+ // filtering when resolving a managed command, not just when probing agent
56
+ // versions. A devDependency's own CLI shim (e.g. a stale `@openai/codex-sdk`
57
+ // pulled in transitively) must never shadow the real global install.
58
+ function stripProjectLocalNodeBinDirs(basePath) {
59
+ return (basePath ?? '')
60
+ .split(path_1.default.delimiter)
61
+ .filter(Boolean)
62
+ .filter((entry) => {
63
+ const normalized = path_1.default.normalize(entry).toLowerCase();
64
+ return !normalized.endsWith(`${path_1.default.sep}node_modules${path_1.default.sep}.bin`);
65
+ })
66
+ .join(path_1.default.delimiter);
67
+ }
68
+ // Issue #1285 (Implementation Strategy §3): after (re)ordering the managed
69
+ // candidates above, nothing new is ever written to the flat, legacy `nodeRoot`
70
+ // directory — but installs made before this fix left real shims sitting there.
71
+ // Scoped strictly to filenames npm itself would have generated for a managed
72
+ // agent CLI install (never a wildcard sweep), so this can run unconditionally
73
+ // and idempotently on every FRAIM startup.
74
+ const MANAGED_AGENT_COMMANDS = ['codex', 'claude', 'gemini', 'copilot'];
75
+ function cleanupOrphanedManagedShims() {
76
+ const nodeRoot = getManagedNodeRoot();
77
+ const versionedDir = getPortableNodeBinPath();
78
+ if (versionedDir === nodeRoot || !fs_1.default.existsSync(nodeRoot))
79
+ return [];
80
+ const basenames = process.platform === 'win32'
81
+ ? MANAGED_AGENT_COMMANDS.flatMap((command) => [`${command}.cmd`, `${command}.ps1`])
82
+ : MANAGED_AGENT_COMMANDS;
83
+ const removed = [];
84
+ for (const basename of basenames) {
85
+ const candidate = path_1.default.join(nodeRoot, basename);
86
+ try {
87
+ if (fs_1.default.statSync(candidate).isFile()) {
88
+ fs_1.default.unlinkSync(candidate);
89
+ removed.push(candidate);
90
+ }
91
+ }
92
+ catch {
93
+ // Not present or inaccessible — nothing to clean up.
94
+ }
95
+ }
96
+ return removed;
97
+ }
45
98
  function normalizePathEntry(entry) {
46
99
  const resolved = path_1.default.resolve(entry.trim());
47
100
  return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.FIRST_RUN_ROW_IDS = exports.FirstRunSessionService = void 0;
40
+ exports.buildPersistShellPathWindowsCommand = buildPersistShellPathWindowsCommand;
40
41
  exports.buildFirstRunHubLaunchArgs = buildFirstRunHubLaunchArgs;
41
42
  const fs_1 = __importDefault(require("fs"));
42
43
  const os_1 = __importDefault(require("os"));
@@ -49,6 +50,7 @@ const auto_mcp_setup_1 = require("../cli/setup/auto-mcp-setup");
49
50
  const setup_1 = require("../cli/commands/setup");
50
51
  const script_sync_utils_1 = require("../cli/utils/script-sync-utils");
51
52
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
53
+ const managed_agent_install_1 = require("../cli/utils/managed-agent-install");
52
54
  const types_1 = require("./types");
53
55
  Object.defineProperty(exports, "FIRST_RUN_ROW_IDS", { enumerable: true, get: function () { return types_1.FIRST_RUN_ROW_IDS; } });
54
56
  const install_state_1 = require("./install-state");
@@ -89,26 +91,40 @@ function ensureOutputDirs() {
89
91
  // installed there without shadowing user/system installs.
90
92
  (function bootstrapFraimNodeBin() {
91
93
  (0, managed_agent_paths_1.appendManagedAgentBinDirsToProcessPath)();
94
+ // Issue #1285 (Implementation Strategy §3): remove any managed-agent shim
95
+ // orphaned in the legacy flat directory by an older FRAIM version, so it
96
+ // can no longer shadow the current versioned build.
97
+ const removedShims = (0, managed_agent_paths_1.cleanupOrphanedManagedShims)();
98
+ if (removedShims.length > 0) {
99
+ appendInstallLog(`orphaned-managed-shims-removed ${removedShims.join(', ')}`);
100
+ }
92
101
  })();
102
+ // Issue #1285: pure command-string builder extracted from persistShellPath()
103
+ // so the generated PowerShell command's directory order (the current
104
+ // versioned dir added before the legacy flat dir, per `bins`' own order —
105
+ // see getManagedAgentBinDirs()) can be asserted directly, without mutating
106
+ // the real registry-backed Windows User PATH in a test.
107
+ function buildPersistShellPathWindowsCommand(bins) {
108
+ const assignments = bins.map((entry, index) => `$bin${index} = '${entry.replace(/'/g, "''")}'`);
109
+ const removals = bins.map((_, index) => `$parts = @($parts | Where-Object { $_ -ne $bin${index} })`);
110
+ const additions = bins.map((_, index) => `$parts += $bin${index}`);
111
+ return [
112
+ ...assignments,
113
+ `$cur = [Environment]::GetEnvironmentVariable('PATH', 'User')`,
114
+ `$parts = @($cur -split ';' | Where-Object { $_ })`,
115
+ ...removals,
116
+ ...additions,
117
+ `$cur = ($parts | Select-Object -Unique) -join ';'`,
118
+ `[Environment]::SetEnvironmentVariable('PATH', $cur, 'User')`,
119
+ ].join('; ');
120
+ }
93
121
  function persistShellPath() {
94
122
  const marker = '# FRAIM managed binaries';
95
123
  const legacyExportLine = 'export PATH="$HOME/.fraim/node/bin:$PATH"';
96
124
  const exportLine = 'export PATH="$PATH:$HOME/.fraim/node/bin"';
97
125
  const stanza = `\n${marker}\n${exportLine}\n`;
98
126
  if (process.platform === 'win32') {
99
- const bins = (0, managed_agent_paths_1.getManagedAgentBinDirs)();
100
- const assignments = bins.map((entry, index) => `$bin${index} = '${entry.replace(/'/g, "''")}'`);
101
- const removals = bins.map((_, index) => `$parts = @($parts | Where-Object { $_ -ne $bin${index} })`);
102
- const additions = bins.map((_, index) => `$parts += $bin${index}`);
103
- const psCmd = [
104
- ...assignments,
105
- `$cur = [Environment]::GetEnvironmentVariable('PATH', 'User')`,
106
- `$parts = @($cur -split ';' | Where-Object { $_ })`,
107
- ...removals,
108
- ...additions,
109
- `$cur = ($parts | Select-Object -Unique) -join ';'`,
110
- `[Environment]::SetEnvironmentVariable('PATH', $cur, 'User')`,
111
- ].join('; ');
127
+ const psCmd = buildPersistShellPathWindowsCommand((0, managed_agent_paths_1.getManagedAgentBinDirs)());
112
128
  (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCmd], { encoding: 'utf8' });
113
129
  return;
114
130
  }
@@ -782,53 +798,12 @@ class FirstRunSessionService {
782
798
  loginHint: `Sign in to ${option.label} to activate it. A terminal window will open with the sign-in command — complete sign-in there, then return here and click "Check if Ready".`,
783
799
  };
784
800
  }
785
- let standardInstallError = null;
786
- try {
787
- await runProcess('npm', ['install', '-g', option.installPackage], {
788
- PATH: systemPath,
789
- npm_config_prefix: undefined,
790
- NPM_CONFIG_PREFIX: undefined,
791
- });
792
- const standardVersion = commandVersion(option.launchCommand, undefined, systemPath);
793
- const npmGlobalBinDirs = standardVersion
794
- ? []
795
- : (0, managed_agent_paths_1.resolveNpmGlobalBinDirs)(systemPath, {
796
- npm_config_prefix: undefined,
797
- NPM_CONFIG_PREFIX: undefined,
798
- });
799
- const standardVersionWithNpmBin = standardVersion
800
- || (npmGlobalBinDirs.length > 0
801
- ? commandVersion(option.launchCommand, npmGlobalBinDirs, systemPath)
802
- : null);
803
- if (standardVersionWithNpmBin) {
804
- if (npmGlobalBinDirs.length > 0) {
805
- process.env.PATH = (0, managed_agent_paths_1.appendBinDirsToPath)(systemPath, npmGlobalBinDirs);
806
- }
807
- this.setAgentInstallStatus(agentId, 'needs-sign-in', `Sign in to ${option.label} to activate it.`);
808
- appendInstallLog(`agent-installed-standard ${agentId}`);
809
- this.persist();
810
- return {
811
- ok: true,
812
- message: `${option.label} installed successfully.`,
813
- needsLogin: true,
814
- loginCommand: option.loginCommand,
815
- loginHint: `Sign in to ${option.label} to activate it. A terminal window will open with the sign-in command — complete sign-in there, then return here and click "Check if Ready".`,
816
- };
817
- }
818
- standardInstallError = `${option.label} standard install completed, but the CLI is not runnable from the user PATH.`;
819
- }
820
- catch (error) {
821
- standardInstallError = error instanceof Error ? error.message : 'Unknown error';
822
- }
823
- const prefix = path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
824
- fs_1.default.mkdirSync(prefix, { recursive: true });
825
- await runProcess('npm', ['install', '-g', option.installPackage], { npm_config_prefix: prefix });
826
- const ver = commandVersion(option.launchCommand, (0, managed_agent_paths_1.getManagedAgentBinDirs)());
827
- if (!ver) {
828
- throw new Error(`${option.label} install completed, but the CLI is not runnable from FRAIM's managed PATH. Standard install failure: ${standardInstallError}`);
801
+ const outcome = await (0, managed_agent_install_1.installManagedAgent)({ label: option.label, installPackage: option.installPackage, launchCommand: option.launchCommand }, systemPath, { runProcess, commandVersion });
802
+ if (outcome.outcome === 'standard' && outcome.npmGlobalBinDirs.length > 0) {
803
+ process.env.PATH = (0, managed_agent_paths_1.appendBinDirsToPath)(systemPath, outcome.npmGlobalBinDirs);
829
804
  }
830
805
  this.setAgentInstallStatus(agentId, 'needs-sign-in', `Sign in to ${option.label} to activate it.`);
831
- appendInstallLog(`agent-installed-managed ${agentId}`);
806
+ appendInstallLog(outcome.outcome === 'standard' ? `agent-installed-standard ${agentId}` : `agent-installed-managed ${agentId}`);
832
807
  this.persist();
833
808
  return {
834
809
  ok: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.278",
3
+ "version": "2.0.279",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {