CVE-2026-2178
Description
A vulnerability was found in r-huijts xcode-mcp-server up to f3419f00117aa9949e326f78cc940166c88f18cb. This affects the function registerXcodeTools of the file src/tools/xcode/index.ts of the component run_lldb. The manipulation of the argument args results in command injection. It is possible to launch the attack remotely. The exploit has been made public and could be used. This product takes the approach of rolling releases to provide continious delivery. Therefore, version details for affected and updated releases are not available. The patch is identified as 11f8d6bacadd153beee649f92a78a9dad761f56f. Applying a patch is advised to resolve this issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
xcode-mcp-servernpm | <= 1.0.3 | — |
Affected products
1Patches
111f8d6bacaddRefactor subprocess invocations to use runExecFile for improved security
11 files changed · +478 −844
docs/exec-safety-audit.md+34 −15 modified@@ -1,16 +1,35 @@ -# execAsync Injection Review - -This document summarizes each usage of `execAsync` within `src/tools` and whether -user supplied input is passed to the shell. When a potential injection vector is -found, a mitigation strategy is noted. - -| File & Line | Parameters | Status | -|-------------|-----------|--------| -| `project/index.ts` lines around 337 | `cleanedPath` from user path | Switched to `execFile` to avoid shell injection | -| `simulator/index.ts` boot/install/etc | `udid`, `bundleId`, `url` parameters | Potential injection; consider validating against `[0-9A-F-]+` and using `execFile` | -| `file/index.ts` copy/info operations | validated file paths | Quoting with `"` may allow injection if path contains quotes. Use `execFile` or sanitize path. | -| `build/index.ts` build/clean/archive commands | `scheme`, `configuration`, paths | Strings passed directly to shell; sanitize or use `execFile`. | -| `spm/index.ts` package commands | package names, options | Strings interpolated; validate or use `execFile`. | -| `cocoapods/index.ts` | boolean flags only | Low risk. | -| `xcode/index.ts` various commands | parameters like `command`, `developerDir` | Validate or use `execFile`. | +# execAsync Injection Review (CWE-78 Remediation) +## Policy + +**All subprocess invocations must use `runExecFile` (from `src/utils/execFile.js`) with an argument array.** Do not use `child_process.exec()` or interpolate user-controlled or path-derived strings into a shell command string. This prevents CWE-78 OS Command Injection. + +- **Safe:** `runExecFile("xcrun", ["lldb", "-o", userCommand, "-b"])` — arguments are passed literally to the process; no shell is invoked. +- **Unsafe:** `exec(\`xcrun lldb ${userArgs}\`)` — a shell parses `userArgs`, so e.g. `& ping -c 2 evil.com &` runs arbitrary commands. + +Use `options.cwd` for working directory instead of `cd "${dir}" && ...`. + +## Remediation Status (2026) + +All previously identified injection vectors have been addressed: + +| Module | Change | +|--------|--------| +| **xcode/index.ts** | `run_xcrun`, `run_lldb`, `validate_app`, `compile_asset_catalog`, `trace_app`, `switch_xcode`, `export_archive`, `generate_icon_set`, and internal helpers now use `runExecFile` with argument arrays. Credentials and user args are never passed through a shell. | +| **build/index.ts** | All `xcodebuild` and `swift build` invocations use `runExecFile("xcodebuild", args, { cwd })` or `runExecFile("swift", args, { cwd })` with args built as arrays. | +| **simulator/index.ts** | All `xcrun simctl` calls use `runExecFile("xcrun", ["simctl", subcommand, ...args])`. UDID format is validated with a strict regex before use. | +| **file/index.ts** | `cp`, `ls`, `wc`, `file`, `find`, `pgrep` use `runExecFile` with argument arrays; no shell interpolation. | +| **spm/index.ts** | All `swift` package/build/test and `which`/`open` calls use `runExecFile` with argument arrays. | +| **cocoapods/index.ts** | All `pod` and helper commands (`which`, `ruby`, `gem`) use `runExecFile` with argument arrays. | +| **project/index.ts** | `xcodebuild -list`, `find`, `osascript`, and `xcrun swift package` invocations use `runExecFile` or existing `execFile` with arrays. | +| **server.ts** | Project detection uses `runExecFile("osascript", ["-e", script])` and `runExecFile("defaults", ["read", ...])`. | +| **utils/project.ts** | `getProjectInfo` / `getWorkspaceInfo` use `runExecFile("xcodebuild", args)`. `find` uses `runExecFile`. | + +## Adding New Tools + +When adding or modifying code that runs external commands: + +1. Use `runExecFile(binary, argsArray, options?)` from `src/utils/execFile.js`. +2. Build `argsArray` by appending literal strings (e.g. `args.push("-scheme", scheme)`). Never concatenate user input into a single string that is then passed to a shell. +3. For working directory, pass `{ cwd: resolvedPath }` in options. +4. Do not use `child_process.exec` or construct shell command strings with user or path-derived input.
src/server.ts+12 −15 modified@@ -1,8 +1,7 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { exec } from "child_process"; -import { promisify } from "util"; import * as fs from "fs/promises"; +import { runExecFile } from "./utils/execFile.js"; import * as fsSync from "fs"; import * as path from "path"; import * as os from "os"; @@ -20,8 +19,6 @@ import { ProjectDirectoryState } from "./utils/projectDirectoryState.js"; // Load environment variables from .env file dotenv.config(); -const execAsync = promisify(exec); - // Tool registration functions import { registerProjectTools } from "./tools/project/index.js"; import { registerFileTools } from "./tools/file/index.js"; @@ -173,16 +170,10 @@ export class XcodeServer { try { // Attempt to get the frontmost Xcode project via AppleScript. try { - const { stdout: frontmostProject } = await execAsync(` - osascript -e ' - tell application "Xcode" - if it is running then - set projectFile to path of document 1 - return POSIX path of projectFile - end if - end tell - ' - `); + const { stdout: frontmostProject } = await runExecFile("osascript", [ + "-e", + "tell application \"Xcode\"\n if it is running then\n set projectFile to path of document 1\n return POSIX path of projectFile\n end if\nend tell" + ]); if (frontmostProject && frontmostProject.trim()) { const projectPath = frontmostProject.trim(); @@ -276,7 +267,13 @@ export class XcodeServer { // Further fallback: try reading recent projects from Xcode defaults. try { - const { stdout: recentProjects } = await execAsync('defaults read com.apple.dt.Xcode IDERecentWorkspaceDocuments || true'); + let recentProjects = ""; + try { + const result = await runExecFile("defaults", ["read", "com.apple.dt.Xcode", "IDERecentWorkspaceDocuments"]); + recentProjects = result.stdout; + } catch { + // defaults read exits 1 if key missing + } if (recentProjects) { const projectMatch = recentProjects.match(/= \\"([^"]+)"/); if (projectMatch) {
src/tools/build/index.ts+86 −224 modified@@ -1,13 +1,17 @@ import { z } from "zod"; -import { promisify } from "util"; -import { exec } from "child_process"; import * as fs from "fs/promises"; import * as path from "path"; import { XcodeServer } from "../../server.js"; import { ProjectNotFoundError, XcodeServerError, CommandExecutionError, PathAccessError } from "../../utils/errors.js"; import { getProjectInfo, getWorkspaceInfo } from "../../utils/project.js"; +import { runExecFile } from "../../utils/execFile.js"; -const execAsync = promisify(exec); +function xcodeBuildArgs(projectPath: string, isWorkspace: boolean): string[] { + if (projectPath.endsWith(".xcworkspace") || isWorkspace) { + return ["-workspace", projectPath]; + } + return ["-project", projectPath]; +} /** * Register build and testing tools @@ -149,27 +153,12 @@ export function registerBuildTools(server: XcodeServer) { try { const workingDir = server.directoryState.getActiveDirectory(); - - // Construct the base command - let projectFlag; const projectPath = server.activeProject.path; + const args = [...xcodeBuildArgs(projectPath, server.activeProject.isWorkspace ?? false)]; + if (scheme) args.push("-scheme", scheme); + args.push("-showdestinations", "-json"); - // Check if the path ends with .xcworkspace (workspace) or .xcodeproj (project) - if (projectPath.endsWith('.xcworkspace')) { - projectFlag = `-workspace "${projectPath}"`; - } else if (projectPath.endsWith('.xcodeproj')) { - projectFlag = `-project "${projectPath}"`; - } else { - // Fall back to the server's detection mechanism - projectFlag = server.activeProject.isWorkspace ? `-workspace "${projectPath}"` : `-project "${projectPath}"`; - } - - const schemeArg = scheme ? `-scheme "${scheme}"` : ""; - - // Request destinations in a more structured format - const cmd = `cd "${workingDir}" && xcodebuild ${projectFlag} ${schemeArg} -showdestinations -json`; - - const { stdout, stderr } = await execAsync(cmd); + const { stdout, stderr } = await runExecFile("xcodebuild", args, { cwd: workingDir }); // Parse destinations from JSON output let destinations; @@ -217,25 +206,10 @@ export function registerBuildTools(server: XcodeServer) { try { const workingDir = server.directoryState.getActiveDirectory(); - - // Construct the base command - let projectFlag; const projectPath = server.activeProject.path; + const args = [...xcodeBuildArgs(projectPath, server.activeProject.isWorkspace ?? false), "-list", "-json"]; - // Check if the path ends with .xcworkspace (workspace) or .xcodeproj (project) - if (projectPath.endsWith('.xcworkspace')) { - projectFlag = `-workspace "${projectPath}"`; - } else if (projectPath.endsWith('.xcodeproj')) { - projectFlag = `-project "${projectPath}"`; - } else { - // Fall back to the server's detection mechanism - projectFlag = server.activeProject.isWorkspace ? `-workspace "${projectPath}"` : `-project "${projectPath}"`; - } - - // Request schemes in a more structured format - const cmd = `cd "${workingDir}" && xcodebuild ${projectFlag} -list -json`; - - const { stdout, stderr } = await execAsync(cmd); + const { stdout, stderr } = await runExecFile("xcodebuild", args, { cwd: workingDir }); // Parse schemes from JSON output let schemeInfo; @@ -335,37 +309,19 @@ export function registerBuildTools(server: XcodeServer) { throw new XcodeServerError("No scheme specified and no schemes found in the project"); } - // Construct the base command - let projectFlag; const projectPath = server.activeProject.path; - - // Check if the path ends with .xcworkspace (workspace) or .xcodeproj (project) - if (projectPath.endsWith('.xcworkspace')) { - projectFlag = `-workspace "${projectPath}"`; - } else if (projectPath.endsWith('.xcodeproj')) { - projectFlag = `-project "${projectPath}"`; - } else { - // Fall back to the server's detection mechanism - projectFlag = server.activeProject.isWorkspace ? `-workspace "${projectPath}"` : `-project "${projectPath}"`; - } - - // Add configuration if provided - const configFlag = configuration ? `-configuration "${configuration}"` : ""; - + const args = [...xcodeBuildArgs(projectPath, server.activeProject.isWorkspace ?? false), "-scheme", schemeToUse]; + if (configuration) args.push("-configuration", configuration); let sanitizedDerivedDataPath: string | undefined; if (derivedDataPath) { const resolvedPath = server.pathManager.normalizePath(derivedDataPath); server.pathManager.validatePathForWriting(resolvedPath); sanitizedDerivedDataPath = resolvedPath; } + if (sanitizedDerivedDataPath) args.push("-derivedDataPath", sanitizedDerivedDataPath); + args.push("clean"); - // Add derived data path if provided - const derivedDataFlag = sanitizedDerivedDataPath ? `-derivedDataPath "${sanitizedDerivedDataPath}"` : ""; - - // Build the clean command - const cmd = `cd "${workingDir}" && xcodebuild ${projectFlag} -scheme "${schemeToUse}" ${configFlag} ${derivedDataFlag} clean`; - - const { stdout, stderr } = await execAsync(cmd); + const { stdout, stderr } = await runExecFile("xcodebuild", args, { cwd: workingDir }); return { content: [{ @@ -442,27 +398,16 @@ export function registerBuildTools(server: XcodeServer) { throw new XcodeServerError(`Invalid configuration "${configuration}". Available configurations: ${projectInfo.configurations.join(", ")}`); } - // Construct the base command - let projectFlag; const projectPath = server.activeProject.path; + const args = [ + ...xcodeBuildArgs(projectPath, server.activeProject.isWorkspace ?? false), + "-scheme", scheme, + "-configuration", configuration, + ]; + if (destination) args.push("-destination", destination); + args.push("-archivePath", resolvedArchivePath, "archive"); - // Check if the path ends with .xcworkspace (workspace) or .xcodeproj (project) - if (projectPath.endsWith('.xcworkspace')) { - projectFlag = `-workspace "${projectPath}"`; - } else if (projectPath.endsWith('.xcodeproj')) { - projectFlag = `-project "${projectPath}"`; - } else { - // Fall back to the server's detection mechanism - projectFlag = server.activeProject.isWorkspace ? `-workspace "${projectPath}"` : `-project "${projectPath}"`; - } - - // Add destination if provided - const destinationFlag = destination ? `-destination "${destination}"` : ""; - - // Build the archive command - const cmd = `cd "${workingDir}" && xcodebuild ${projectFlag} -scheme "${scheme}" -configuration "${configuration}" ${destinationFlag} -archivePath "${resolvedArchivePath}" archive`; - - const { stdout, stderr } = await execAsync(cmd); + const { stdout, stderr } = await runExecFile("xcodebuild", args, { cwd: workingDir }); // Check if export options plist is provided for follow-up instructions let followUpInstructions = ""; @@ -523,8 +468,7 @@ export function registerBuildTools(server: XcodeServer) { */ async function findBestAvailableSimulator(runtimePrefix = 'iOS') { try { - // Get list of all simulators in JSON format - const { stdout: simulatorList } = await execAsync('xcrun simctl list --json'); + const { stdout: simulatorList } = await runExecFile("xcrun", ["simctl", "list", "--json"]); const simulators = JSON.parse(simulatorList); // Find the latest runtime that matches our prefix @@ -606,49 +550,30 @@ async function analyzeFile(server: XcodeServer, filePath: string, options: { sdk throw new XcodeServerError(`Scheme '${options.scheme}' not found. Available schemes: ${info.schemes.join(', ')}`); } - let destinationFlag = ''; + const projectPath = server.activeProject.path; + const workingDir = server.directoryState.getActiveDirectory(); + const relativeFilePath = path.relative(workingDir, filePath); + if (relativeFilePath.startsWith("..")) { + throw new XcodeServerError(`File is outside the project directory: ${filePath}`); + } - // If SDK is provided, use it directly + const args = [ + ...xcodeBuildArgs(projectPath, server.activeProject.isWorkspace ?? false), + "-scheme", scheme, + ]; if (options.sdk) { - destinationFlag = `-sdk ${options.sdk}`; + args.push("-sdk", options.sdk); } else { - // Otherwise, find a suitable simulator const simulator = await findBestAvailableSimulator(); if (!simulator) { throw new XcodeServerError("No available iOS simulators found. Please install at least one iOS simulator or specify an SDK."); } - - destinationFlag = `-destination "platform=iOS Simulator,id=${simulator.udid}"`; - } - - // Build the analyze command - let projectFlag; - const projectPath = server.activeProject.path; - - // Check if the path ends with .xcworkspace (workspace) or .xcodeproj (project) - if (projectPath.endsWith('.xcworkspace')) { - projectFlag = `-workspace "${projectPath}"`; - } else if (projectPath.endsWith('.xcodeproj')) { - projectFlag = `-project "${projectPath}"`; - } else { - // Fall back to the server's detection mechanism - projectFlag = server.activeProject.isWorkspace ? `-workspace "${projectPath}"` : `-project "${projectPath}"`; - } - - // Get the active directory from ProjectDirectoryState for the working directory - const workingDir = server.directoryState.getActiveDirectory(); - - // Get the relative path to the file from the working directory - const relativeFilePath = path.relative(workingDir, filePath); - if (relativeFilePath.startsWith('..')) { - throw new XcodeServerError(`File is outside the project directory: ${filePath}`); + args.push("-destination", `platform=iOS Simulator,id=${simulator.udid}`); } - - // Build the analyze command with more options for better analysis - const cmd = `cd "${workingDir}" && xcodebuild ${projectFlag} -scheme "${scheme}" ${destinationFlag} analyze -quiet -analyzer-output html "${relativeFilePath}"`; + args.push("analyze", "-quiet", "-analyzer-output", "html", relativeFilePath); try { - const { stdout, stderr } = await execAsync(cmd); + const { stdout, stderr } = await runExecFile("xcodebuild", args, { cwd: workingDir }); // Check for common warning patterns let formattedOutput = `Analysis of ${path.basename(filePath)} completed.\n\n`; @@ -747,17 +672,14 @@ async function buildProject(server: XcodeServer, configuration: string, scheme: try { // Different command for workspace vs project vs SPM if (server.activeProject.isSPMProject) { - // For SPM projects, we use swift build - const buildConfig = configuration.toLowerCase() === 'release' ? '--configuration release' : ''; try { // Use the active directory from ProjectDirectoryState for the working directory const workingDir = server.directoryState.getActiveDirectory(); - // Add jobs parameter if specified - const jobsArg = options.jobs ? `--jobs ${options.jobs}` : ''; - - const cmd = `cd "${workingDir}" && swift build ${buildConfig} ${jobsArg}`; - const { stdout, stderr } = await execAsync(cmd); + const swiftArgs = ["build"]; + if (configuration.toLowerCase() === "release") swiftArgs.push("--configuration", "release"); + if (options.jobs) swiftArgs.push("--jobs", String(options.jobs)); + const { stdout, stderr } = await runExecFile("swift", swiftArgs, { cwd: workingDir }); return { content: [{ type: "text", @@ -792,46 +714,24 @@ async function buildProject(server: XcodeServer, configuration: string, scheme: throw new XcodeServerError(`Invalid scheme "${scheme}". Available schemes: ${projectInfo.schemes.join(", ")}`); } - // Use -workspace for workspace projects, -project for regular projects - // Check if the file exists and has the correct extension - let projectFlag; - - // Check if the path ends with .xcworkspace (workspace) or .xcodeproj (project) - if (projectPath.endsWith('.xcworkspace')) { - projectFlag = `-workspace "${projectPath}"`; - } else if (projectPath.endsWith('.xcodeproj')) { - projectFlag = `-project "${projectPath}"`; - } else { - // Fall back to the server's detection mechanism - projectFlag = server.activeProject.isWorkspace ? `-workspace "${projectPath}"` : `-project "${projectPath}"`; - } - - // Handle destination or SDK specification - let destinationOrSdkFlag = ''; - if (options.destination) { - destinationOrSdkFlag = `-destination "${options.destination}"`; - } else if (options.sdk) { - destinationOrSdkFlag = `-sdk ${options.sdk}`; - } else { - // Try to find a suitable simulator if no destination or SDK is specified + const workingDir = server.directoryState.getActiveDirectory(); + const args = [ + ...xcodeBuildArgs(projectPath, server.activeProject.isWorkspace ?? false), + "-scheme", scheme, + "-configuration", configuration, + ]; + if (options.destination) args.push("-destination", options.destination); + else if (options.sdk) args.push("-sdk", options.sdk); + else { const simulator = await findBestAvailableSimulator(); - if (simulator) { - destinationOrSdkFlag = `-destination "platform=iOS Simulator,id=${simulator.udid}"`; - } + if (simulator) args.push("-destination", `platform=iOS Simulator,id=${simulator.udid}`); } - - // Handle additional options - const jobsFlag = options.jobs ? `-jobs ${options.jobs}` : ''; - const derivedDataFlag = sanitizedDerivedDataPath ? `-derivedDataPath "${sanitizedDerivedDataPath}"` : ''; - - // Use the active directory from ProjectDirectoryState for the working directory - const workingDir = server.directoryState.getActiveDirectory(); - - // More advanced build command with enhanced options - const cmd = `cd "${workingDir}" && xcodebuild ${projectFlag} -scheme "${scheme}" -configuration "${configuration}" ${destinationOrSdkFlag} ${jobsFlag} ${derivedDataFlag} build -quiet`; + if (options.jobs) args.push("-jobs", String(options.jobs)); + if (sanitizedDerivedDataPath) args.push("-derivedDataPath", sanitizedDerivedDataPath); + args.push("build", "-quiet"); try { - const { stdout, stderr } = await execAsync(cmd); + const { stdout, stderr } = await runExecFile("xcodebuild", args, { cwd: workingDir }); // Check for known error patterns if (stderr && ( @@ -909,81 +809,43 @@ async function runTests(server: XcodeServer, options: { sanitizedResultBundlePath = resolvedPath; } - // Build the command with all the provided options - let projectFlag; const projectPath = server.activeProject.path; - - // Check if the path ends with .xcworkspace (workspace) or .xcodeproj (project) - if (projectPath.endsWith('.xcworkspace')) { - projectFlag = `-workspace "${projectPath}"`; - } else if (projectPath.endsWith('.xcodeproj')) { - projectFlag = `-project "${projectPath}"`; - } else { - // Fall back to the server's detection mechanism - projectFlag = server.activeProject.isWorkspace ? `-workspace "${projectPath}"` : `-project "${projectPath}"`; - } - - // If scheme is provided, use it, otherwise we need to figure out a scheme - let schemeFlag = ''; + let schemeToUse: string; if (options.scheme) { - schemeFlag = `-scheme "${options.scheme}"`; + schemeToUse = options.scheme; } else { - // Try to get schemes from the project info - try { - const info = server.activeProject.isWorkspace ? - await getWorkspaceInfo(server.activeProject.path) : - await getProjectInfo(server.activeProject.path); - - if (info.schemes && info.schemes.length > 0) { - schemeFlag = `-scheme "${info.schemes[0]}"`; - } else { - throw new XcodeServerError("No schemes found in the project. Please specify a scheme for testing."); - } - } catch (error) { - throw new XcodeServerError("Failed to determine scheme for testing. Please specify a scheme."); + const info = server.activeProject.isWorkspace + ? await getWorkspaceInfo(server.activeProject.path) + : await getProjectInfo(server.activeProject.path); + if (info.schemes && info.schemes.length > 0) { + schemeToUse = info.schemes[0]; + } else { + throw new XcodeServerError("No schemes found in the project. Please specify a scheme for testing."); } } - // Handle testPlan option - const testPlanFlag = options.testPlan ? `-testPlan "${options.testPlan}"` : ""; - - // Handle destination option or find a suitable simulator - let destinationFlag = ''; - if (options.destination) { - destinationFlag = `-destination "${options.destination}"`; - } else { - // Try to find a suitable simulator + const args = [ + ...xcodeBuildArgs(projectPath, server.activeProject.isWorkspace ?? false), + "-scheme", schemeToUse, + ]; + if (options.destination) args.push("-destination", options.destination); + else { const simulator = await findBestAvailableSimulator(); - if (simulator) { - destinationFlag = `-destination "platform=iOS Simulator,id=${simulator.udid}"`; - } + if (simulator) args.push("-destination", `platform=iOS Simulator,id=${simulator.udid}`); } - - // Handle only-testing flags - const onlyTestingFlags = options.onlyTesting && options.onlyTesting.length > 0 - ? options.onlyTesting.map(test => `-only-testing:${test}`).join(' ') - : ''; - - // Handle skip-testing flags - const skipTestingFlags = options.skipTesting && options.skipTesting.length > 0 - ? options.skipTesting.map(test => `-skip-testing:${test}`).join(' ') - : ''; - - // Handle result bundle path - const resultBundleFlag = sanitizedResultBundlePath - ? `-resultBundlePath "${sanitizedResultBundlePath}"` - : ''; - - // Handle code coverage - const codeCoverageFlag = options.enableCodeCoverage === true - ? '-enableCodeCoverage YES' - : ''; - - // Build the full command - const cmd = `cd "${workingDir}" && xcodebuild ${projectFlag} ${schemeFlag} ${destinationFlag} ${testPlanFlag} ${onlyTestingFlags} ${skipTestingFlags} ${resultBundleFlag} ${codeCoverageFlag} test`; + if (options.testPlan) args.push("-testPlan", options.testPlan); + if (options.onlyTesting?.length) { + for (const t of options.onlyTesting) args.push("-only-testing:" + t); + } + if (options.skipTesting?.length) { + for (const t of options.skipTesting) args.push("-skip-testing:" + t); + } + if (sanitizedResultBundlePath) args.push("-resultBundlePath", sanitizedResultBundlePath); + if (options.enableCodeCoverage === true) args.push("-enableCodeCoverage", "YES"); + args.push("test"); try { - const { stdout, stderr } = await execAsync(cmd); + const { stdout, stderr } = await runExecFile("xcodebuild", args, { cwd: workingDir }); // Check for test failures const hasFailures = stdout.includes("** TEST FAILED **") || stderr.includes("** TEST FAILED **");
src/tools/cocoapods/index.ts+30 −97 modified@@ -1,12 +1,9 @@ import { z } from "zod"; -import { promisify } from "util"; -import { exec } from "child_process"; import * as fs from "fs/promises"; import * as path from "path"; import { XcodeServer } from "../../server.js"; import { ProjectNotFoundError, XcodeServerError, CommandExecutionError, PathAccessError } from "../../utils/errors.js"; - -const execAsync = promisify(exec); +import { runExecFile } from "../../utils/execFile.js"; interface GemIssue { type: string; @@ -51,11 +48,8 @@ async function getPodfileLockVersion(podfileLockPath: string): Promise<string | */ async function checkCocoaPodsInstallation(): Promise<CocoaPodsCheck> { try { - // Check if pod command exists - await execAsync('which pod'); - - // Check CocoaPods version - const { stdout } = await execAsync('pod --version'); + await runExecFile("which", ["pod"]); + const { stdout } = await runExecFile("pod", ["--version"]); return { installed: true, version: stdout.trim() }; } catch (error) { return { installed: false, error }; @@ -111,8 +105,8 @@ async function checkVersionCompatibility(projectRoot: string): Promise<void> { */ async function checkRubyEnvironment(): Promise<RubyCheck> { try { - const { stdout: rubyVersion } = await execAsync('ruby --version'); - const { stdout: gemList } = await execAsync('gem list'); + const { stdout: rubyVersion } = await runExecFile("ruby", ["--version"]); + const { stdout: gemList } = await runExecFile("gem", ["list"]); const issues: GemIssue[] = []; @@ -223,7 +217,7 @@ async function checkOutdatedPods(projectDir: string): Promise<PodInfo[]> { const installedPods = await extractInstalledPods(podfileLockPath); // Run pod outdated to get the list of outdated pods - const { stdout } = await execAsync('pod outdated --no-repo-update', { cwd: projectDir }); + const { stdout } = await runExecFile("pod", ["outdated", "--no-repo-update"], { cwd: projectDir }); // Parse the output to identify outdated pods and their latest versions const outdatedPods = new Map<string, string>(); @@ -351,26 +345,12 @@ export function registerCocoaPodsTools(server: XcodeServer) { LC_ALL: 'en_US.UTF-8' }; - // Build the command with appropriate flags - let cmd = 'pod install'; - if (repoUpdate) { - cmd += ' --repo-update'; - } else { - cmd += ' --no-repo-update'; - } - - if (cleanInstall) { - cmd += ' --clean-install'; - } - - if (verbose) { - cmd += ' --verbose'; - } - - const { stdout, stderr } = await execAsync(cmd, { - cwd: activeDirectory, - env - }); + const installArgs = ["install"]; + if (repoUpdate) installArgs.push("--repo-update"); + else installArgs.push("--no-repo-update"); + if (cleanInstall) installArgs.push("--clean-install"); + if (verbose) installArgs.push("--verbose"); + const { stdout, stderr } = await runExecFile("pod", installArgs, { cwd: activeDirectory, env }); return { content: [{ @@ -520,38 +500,13 @@ export function registerCocoaPodsTools(server: XcodeServer) { LC_ALL: 'en_US.UTF-8' }; - // Build the command - let cmd = 'pod update'; - - // Add specific pods to update - if (pods && pods.length > 0) { - cmd += ' ' + pods.map(pod => pod.trim()).join(' '); - } - - // Add repo update flag - if (!repoUpdate) { - cmd += ' --no-repo-update'; - } - - // Add excluded pods - if (excludePods && excludePods.length > 0) { - cmd += ` --exclude-pods=${excludePods.join(',')}`; - } - - // Add clean install flag - if (cleanInstall) { - cmd += ' --clean-install'; - } - - // Add sources - if (sources && sources.length > 0) { - cmd += ` --sources=${sources.join(',')}`; - } - - const { stdout, stderr } = await execAsync(cmd, { - cwd: activeDirectory, - env - }); + const updateArgs = ["update"]; + if (pods?.length) updateArgs.push(...pods.map((p) => p.trim())); + if (!repoUpdate) updateArgs.push("--no-repo-update"); + if (excludePods?.length) updateArgs.push(`--exclude-pods=${excludePods.join(",")}`); + if (cleanInstall) updateArgs.push("--clean-install"); + if (sources?.length) updateArgs.push(`--sources=${sources.join(",")}`); + const { stdout, stderr } = await runExecFile("pod", updateArgs, { cwd: activeDirectory, env }); return { content: [{ @@ -648,20 +603,10 @@ export function registerCocoaPodsTools(server: XcodeServer) { } try { - // Build command with options - let cmd = 'pod outdated'; - - if (ignorePrerelease) { - cmd += ' --ignore-prerelease'; - } - - if (!repoUpdate) { - cmd += ' --no-repo-update'; - } - - const { stdout, stderr } = await execAsync(cmd, { - cwd: activeDirectory - }); + const outdatedArgs = ["outdated"]; + if (ignorePrerelease) outdatedArgs.push("--ignore-prerelease"); + if (!repoUpdate) outdatedArgs.push("--no-repo-update"); + const { stdout, stderr } = await runExecFile("pod", outdatedArgs, { cwd: activeDirectory }); // Parse the outdated pods information const outdatedPods: PodInfo[] = []; @@ -727,18 +672,10 @@ export function registerCocoaPodsTools(server: XcodeServer) { }, async ({ verbose = false, silent = false }) => { try { - // Build command with options - let cmd = 'pod repo update'; - - if (verbose) { - cmd += ' --verbose'; - } - - if (silent) { - cmd += ' --silent'; - } - - const { stdout, stderr } = await execAsync(cmd); + const repoArgs = ["repo", "update"]; + if (verbose) repoArgs.push("--verbose"); + if (silent) repoArgs.push("--silent"); + const { stdout, stderr } = await runExecFile("pod", repoArgs); return { content: [{ @@ -785,9 +722,7 @@ export function registerCocoaPodsTools(server: XcodeServer) { } try { - const { stdout, stderr } = await execAsync('pod deintegrate', { - cwd: activeDirectory - }); + const { stdout, stderr } = await runExecFile("pod", ["deintegrate"], { cwd: activeDirectory }); return { content: [{ @@ -889,7 +824,7 @@ export function registerCocoaPodsTools(server: XcodeServer) { let repoInfo = null; if (cocoaPodsCheck.installed) { try { - const { stdout } = await execAsync('pod repo list'); + const { stdout } = await runExecFile("pod", ["repo", "list"]); repoInfo = stdout.trim(); } catch { // Ignore repo listing errors @@ -953,9 +888,7 @@ export function registerCocoaPodsTools(server: XcodeServer) { } try { - const { stdout, stderr } = await execAsync('pod init', { - cwd: activeDirectory - }); + const { stdout, stderr } = await runExecFile("pod", ["init"], { cwd: activeDirectory }); // Read the generated Podfile let podfileContent = "";
src/tools/file/index.ts+35 −64 modified@@ -5,10 +5,7 @@ import * as path from "path"; import { XcodeServer } from "../../server.js"; import { ProjectNotFoundError, PathAccessError, FileOperationError, CommandExecutionError } from "../../utils/errors.js"; import { getMimeTypeForExtension, listDirectory, expandPath } from "../../utils/file.js"; -import { promisify } from "util"; -import { exec } from "child_process"; - -const execAsync = promisify(exec); +import { runExecFile } from "../../utils/execFile.js"; /** * Helper function to format file size in human-readable format @@ -65,22 +62,15 @@ async function getFileInfo(filePath: string): Promise<FileInfo> { else if (stats.isDirectory()) type = 'directory'; else if (stats.isSymbolicLink()) type = 'symlink'; - // Try to get owner info (this might fail in some environments) - let owner = undefined; - try { - const { stdout } = await execAsync(`ls -l "${filePath}" | awk '{print $3}'`); - owner = stdout.trim(); - } catch { - // Ignore errors, owner will remain undefined - } - - // Get permissions - let permissions = undefined; + let owner: string | undefined; + let permissions: string | undefined; try { - const { stdout } = await execAsync(`ls -la "${filePath}" | awk '{print $1}'`); - permissions = stdout.trim(); + const { stdout } = await runExecFile("ls", ["-l", filePath]); + const parts = stdout.trim().split(/\s+/); + if (parts.length >= 3) permissions = parts[0]; + if (parts.length >= 4) owner = parts[2]; } catch { - // Ignore errors, permissions will remain undefined + // Ignore errors } return { @@ -329,10 +319,9 @@ export function registerFileTools(server: XcodeServer) { } if (isDirectory) { - // Use cp with recursive flag for directories - const { stdout, stderr } = await execAsync(`cp -R "${resolvedSource}" "${targetPath}"`); + const { stderr } = await runExecFile("cp", ["-R", resolvedSource, targetPath]); if (stderr) { - throw new FileOperationError('copy directory', resolvedSource, new Error(stderr)); + throw new FileOperationError("copy directory", resolvedSource, new Error(stderr)); } } else { // Copy file @@ -736,13 +725,13 @@ export function registerFileTools(server: XcodeServer) { } // For text files, try to show encoding and line count - if (mimeType && mimeType.startsWith('text/') && info.size && info.size < 10 * 1024 * 1024) { + if (mimeType && mimeType.startsWith("text/") && info.size && info.size < 10 * 1024 * 1024) { try { - const { stdout: wc } = await execAsync(`wc -l "${resolvedPath}" | awk '{print $1}'`); - additionalInfo += `Line Count: ${wc.trim()}\n`; - - const { stdout: file } = await execAsync(`file -b "${resolvedPath}"`); - additionalInfo += `File Type: ${file.trim()}\n`; + const { stdout: wc } = await runExecFile("wc", ["-l", resolvedPath]); + const lineCount = wc.trim().split(/\s+/)[0] ?? ""; + additionalInfo += `Line Count: ${lineCount}\n`; + const { stdout: fileOut } = await runExecFile("file", ["-b", resolvedPath]); + additionalInfo += `File Type: ${fileOut.trim()}\n`; } catch { // Ignore errors for these extra info commands } @@ -811,38 +800,13 @@ export function registerFileTools(server: XcodeServer) { throw new FileOperationError('find', resolvedPath, new Error('Path is not a directory')); } - // Use find command with appropriate options - let findCmd = `find "${resolvedPath}" -type f`; - - // Add maxDepth if specified - if (maxDepth !== undefined) { - findCmd += ` -maxdepth ${maxDepth}`; - } - - // Exclude hidden files/dirs if not showing hidden - if (!showHidden) { - findCmd += ` -not -path "*/\\.*"`; - } - - // Add pattern matching - if (pattern) { - // Convert glob pattern to find-compatible pattern - if (pattern.includes('*') || pattern.includes('?')) { - // For simple glob patterns, use -name - if (!pattern.includes('/**/')) { - findCmd += ` -name "${pattern}"`; - } else { - // For more complex patterns with ** (any depth), we need to post-process - // Remove the ** handling and filter results after - } - } else { - // For exact matches, use -name - findCmd += ` -name "${pattern}"`; - } - } + const findArgs = [resolvedPath, "-type", "f"]; + if (maxDepth !== undefined) findArgs.push("-maxdepth", String(maxDepth)); + if (!showHidden) findArgs.push("-not", "-path", "*/\\.*"); + if (pattern && !pattern.includes("/**/")) findArgs.push("-name", pattern); try { - const { stdout, stderr } = await execAsync(findCmd); + const { stdout, stderr } = await runExecFile("find", findArgs); if (stderr) { console.error(`Warning from find command: ${stderr}`); @@ -1050,13 +1014,15 @@ export function registerFileTools(server: XcodeServer) { searchRegex = new RegExp(escapedText, caseSensitive ? 'g' : 'gi'); } - // Find matching files - const findCmd = `find "${resolvedPath}" -type f ${includeHidden ? '' : '-not -path "*/\\.*"'} -name "${pattern}"${maxResults ? ` | head -n ${maxResults}` : ''}`; + const findArgs = [resolvedPath, "-type", "f"]; + if (!includeHidden) findArgs.push("-not", "-path", "*/\\.*"); + findArgs.push("-name", pattern); let files: string[]; try { - const { stdout } = await execAsync(findCmd); - files = stdout.trim().split('\n').filter(Boolean); + const { stdout } = await runExecFile("find", findArgs); + files = stdout.trim().split("\n").filter(Boolean); + if (maxResults) files = files.slice(0, maxResults); } catch (error) { throw new CommandExecutionError( 'find', @@ -1429,9 +1395,14 @@ async function updateProjectReferences(projectRoot: string, filePath: string) { // This is a more advanced approach that requires Xcode to be running try { // Check if Xcode is running - const { stdout: isRunning } = await execAsync('pgrep -x Xcode || echo "not running"'); - - if (isRunning.trim() === 'not running') { + let isRunning = false; + try { + await runExecFile("pgrep", ["-x", "Xcode"]); + isRunning = true; + } catch { + // pgrep exits 1 when no process found + } + if (!isRunning) { console.error('Xcode is not running. Cannot automatically add file to project.'); return; }
src/tools/project/index.ts+26 −46 modified@@ -2,13 +2,12 @@ import { z } from "zod"; import * as fs from "fs/promises"; import * as fsSync from "fs"; import * as path from "path"; +import { execFile } from "child_process"; import { promisify } from "util"; -import { exec, execFile } from "child_process"; import { XcodeServer } from "../../server.js"; import { getProjectInfo } from "../../utils/project.js"; import { ProjectNotFoundError, PathAccessError, FileOperationError, CommandExecutionError } from "../../utils/errors.js"; - -const execAsync = promisify(exec); +import { runExecFile } from "../../utils/execFile.js"; /** * Interface for workspace document @@ -181,10 +180,12 @@ async function getProjectConfiguration(projectPath: string): Promise<ProjectConf // Get project schemes try { // Determine if this is a workspace or regular project - const isWorkspace = projectPath.endsWith('.xcworkspace'); - const flag = isWorkspace ? '-workspace' : '-project'; - - const { stdout: schemesOutput } = await execAsync(`xcodebuild ${flag} "${projectPath}" -list`); + const isWorkspace = projectPath.endsWith(".xcworkspace"); + const { stdout: schemesOutput } = await runExecFile("xcodebuild", [ + isWorkspace ? "-workspace" : "-project", + projectPath, + "-list" + ]); // Parse schemes const schemesMatch = schemesOutput.match(/Schemes:\s+((?:.+\s*)+)/); @@ -493,8 +494,12 @@ export function registerProjectTools(server: XcodeServer) { if (includeSPM) { try { // Find directories containing Package.swift - const findCmd = `find "${searchDir}" -name "Package.swift" -type f -not -path "*/\\.*/"`; - const { stdout: spmOutput } = await execAsync(findCmd); + const { stdout: spmOutput } = await runExecFile("find", [ + searchDir, + "-name", "Package.swift", + "-type", "f", + "-not", "-path", "*/\\.*" + ]); if (spmOutput.trim()) { spmProjects = spmOutput.trim().split('\n') @@ -958,8 +963,7 @@ export function registerProjectTools(server: XcodeServer) { await fs.writeFile(tempScriptPath, scriptContent, "utf-8"); try { - // Execute the AppleScript - const { stdout, stderr } = await execAsync(`osascript "${tempScriptPath}"`); + const { stdout, stderr } = await runExecFile("osascript", [tempScriptPath]); // Clean up the temporary script file await fs.unlink(tempScriptPath).catch(() => {}); @@ -1287,24 +1291,13 @@ export function registerProjectTools(server: XcodeServer) { throw new Error(`Unsupported template: ${template}`); } - // Build the command to create the project - let cmd = `cd "${resolvedOutputDir}" && xcrun swift package init`; - // For Swift Package Manager templates, use swift package init if (template === "cross-platform-framework" || template === "cross-platform-library") { const isLibrary = template === "cross-platform-library"; - cmd = `cd "${resolvedOutputDir}" && xcrun swift package init --${isLibrary ? 'type library' : 'type framework'}`; - - if (!includeTests) { - cmd += " --no-tests"; - } - - // Execute the command - const { stdout, stderr } = await execAsync(cmd); - - // Generate Xcode project from the Swift package - const generateCmd = `cd "${resolvedOutputDir}" && xcrun swift package generate-xcodeproj`; - const { stdout: genStdout, stderr: genStderr } = await execAsync(generateCmd); + const initArgs = ["swift", "package", "init", "--type", isLibrary ? "library" : "framework"]; + if (!includeTests) initArgs.push("--no-tests"); + const { stdout, stderr } = await runExecFile("xcrun", initArgs, { cwd: resolvedOutputDir }); + const { stdout: genStdout, stderr: genStderr } = await runExecFile("xcrun", ["swift", "package", "generate-xcodeproj"], { cwd: resolvedOutputDir }); // Get the path to the generated .xcodeproj const projectPath = path.join(resolvedOutputDir, `${name}.xcodeproj`); @@ -1334,29 +1327,16 @@ export function registerProjectTools(server: XcodeServer) { }; } - // For Xcode templates, use Xcode's template system - // Create a temporary directory for the project const projectDir = path.join(resolvedOutputDir, name); await fs.mkdir(projectDir, { recursive: true }); - // Build the Xcode project creation command - cmd = `cd "${resolvedOutputDir}" && xcrun swift package generate-xcodeproj --output "${name}.xcodeproj"`; - - // Add organization name if provided - const orgNameArg = organizationName ? `--organization "${organizationName}"` : ""; - - // Add organization identifier if provided - const orgIdArg = organizationIdentifier ? `--identifier "${organizationIdentifier}"` : ""; - - // Add language - const langArg = `--language ${language === "swift" ? "Swift" : "Objective-C"}`; - - // Add test options - const testArgs = includeTests ? "" : "--no-tests"; - const uiTestArgs = includeUITests ? "--include-ui-tests" : ""; - - // Execute the command - const { stdout, stderr } = await execAsync(cmd); + const genArgs = ["swift", "package", "generate-xcodeproj", "--output", `${name}.xcodeproj`]; + if (organizationName) genArgs.push("--organization", organizationName); + if (organizationIdentifier) genArgs.push("--identifier", organizationIdentifier); + genArgs.push("--language", language === "swift" ? "Swift" : "Objective-C"); + if (!includeTests) genArgs.push("--no-tests"); + if (includeUITests) genArgs.push("--include-ui-tests"); + const { stdout, stderr } = await runExecFile("xcrun", genArgs, { cwd: resolvedOutputDir }); // Get the path to the generated .xcodeproj const projectPath = path.join(resolvedOutputDir, `${name}.xcodeproj`);
src/tools/simulator/index.ts+34 −40 modified@@ -1,12 +1,17 @@ import { z } from "zod"; -import { promisify } from "util"; -import { exec } from "child_process"; import * as fs from "fs/promises"; import * as path from "path"; import { XcodeServer } from "../../server.js"; import { CommandExecutionError, PathAccessError } from "../../utils/errors.js"; +import { runExecFile } from "../../utils/execFile.js"; -const execAsync = promisify(exec); +const UDID_REGEX = /^[0-9A-Fa-f-]{36}$/; + +function validateUdid(udid: string): void { + if (!UDID_REGEX.test(udid)) { + throw new Error(`Invalid simulator UDID format: "${udid}". Use list_simulators to get a valid UDID.`); + } +} interface SimulatorInfo { name: string; @@ -81,7 +86,7 @@ function parseSimulatorDevices(simctlOutput: string): SimulatorInfo[] { */ async function getBootedSimulators(): Promise<SimulatorInfo[]> { try { - const { stdout } = await execAsync("xcrun simctl list --json"); + const { stdout } = await runExecFile("xcrun", ["simctl", "list", "--json"]); const allSimulators = parseSimulatorDevices(stdout); return allSimulators.filter(sim => sim.state === 'Booted'); } catch (error) { @@ -162,7 +167,7 @@ export function registerSimulatorTools(server: XcodeServer) { }, async ({ format = "parsed", filterRuntime, filterState, filterName }) => { try { - const { stdout, stderr } = await execAsync("xcrun simctl list --json"); + const { stdout, stderr } = await runExecFile("xcrun", ["simctl", "list", "--json"]); if (format === "json") { return { @@ -258,7 +263,7 @@ export function registerSimulatorTools(server: XcodeServer) { // If name is provided, we need to find the matching simulator if (name && !udid) { - const { stdout } = await execAsync("xcrun simctl list --json"); + const { stdout } = await runExecFile("xcrun", ["simctl", "list", "--json"]); const simulators = parseSimulatorDevices(stdout); // Filter by name (case insensitive) @@ -304,8 +309,9 @@ export function registerSimulatorTools(server: XcodeServer) { udid = matches[0].udid; } - // Boot the simulator - const { stdout, stderr } = await execAsync(`xcrun simctl boot "${udid}"`); + if (!udid) throw new Error("Simulator UDID could not be determined"); + validateUdid(udid); + const { stdout, stderr } = await runExecFile("xcrun", ["simctl", "boot", udid]); return { content: [{ @@ -376,16 +382,9 @@ export function registerSimulatorTools(server: XcodeServer) { throw new Error("Either udid parameter or all=true must be provided"); } - let command; - if (all) { - // Shutdown all simulators - command = "xcrun simctl shutdown all"; - } else { - // Shutdown specific simulator - command = `xcrun simctl shutdown "${udid}"`; - } - - const { stdout, stderr } = await execAsync(command); + if (!all) validateUdid(udid!); + const args = all ? ["simctl", "shutdown", "all"] : ["simctl", "shutdown", udid!]; + const { stdout, stderr } = await runExecFile("xcrun", args); return { content: [{ @@ -449,8 +448,8 @@ export function registerSimulatorTools(server: XcodeServer) { throw new Error(`The app bundle doesn't exist: ${appPath}`); } - // Install the app - const { stdout, stderr } = await execAsync(`xcrun simctl install "${udid}" "${resolvedAppPath}"`); + validateUdid(udid); + const { stdout, stderr } = await runExecFile("xcrun", ["simctl", "install", udid, resolvedAppPath]); return { content: [{ @@ -488,18 +487,11 @@ export function registerSimulatorTools(server: XcodeServer) { }, async ({ udid, bundleId, waitForDebugger = false, args = [] }) => { try { - // Build the command - let command = `xcrun simctl launch ${waitForDebugger ? '-w' : ''}`; - - // Add the UDID and bundle ID - command += ` "${udid}" "${bundleId}"`; - - // Add any arguments - if (args.length > 0) { - command += ` ${args.map(arg => `"${arg}"`).join(' ')}`; - } - - const { stdout, stderr } = await execAsync(command); + validateUdid(udid); + const launchArgs = ["simctl", "launch"]; + if (waitForDebugger) launchArgs.push("-w"); + launchArgs.push(udid, bundleId, ...args); + const { stdout, stderr } = await runExecFile("xcrun", launchArgs); return { content: [{ @@ -552,7 +544,8 @@ export function registerSimulatorTools(server: XcodeServer) { }, async ({ udid, bundleId }) => { try { - const { stdout, stderr } = await execAsync(`xcrun simctl terminate "${udid}" "${bundleId}"`); + validateUdid(udid); + const { stdout, stderr } = await runExecFile("xcrun", ["simctl", "terminate", udid, bundleId]); return { content: [{ @@ -605,7 +598,8 @@ export function registerSimulatorTools(server: XcodeServer) { throw new Error(`Invalid URL format: ${url}`); } - const { stdout, stderr } = await execAsync(`xcrun simctl openurl "${udid}" "${url}"`); + validateUdid(udid); + const { stdout, stderr } = await runExecFile("xcrun", ["simctl", "openurl", udid, url]); return { content: [{ @@ -645,8 +639,8 @@ export function registerSimulatorTools(server: XcodeServer) { const outputDir = path.dirname(resolvedOutputPath); await fs.mkdir(outputDir, { recursive: true }); - // Take the screenshot - const { stdout, stderr } = await execAsync(`xcrun simctl io "${udid}" screenshot "${resolvedOutputPath}"`); + validateUdid(udid); + const { stdout, stderr } = await runExecFile("xcrun", ["simctl", "io", udid, "screenshot", resolvedOutputPath]); return { content: [{ @@ -687,8 +681,8 @@ export function registerSimulatorTools(server: XcodeServer) { throw new Error("You must set confirm=true to reset a simulator. This will erase all content and settings."); } - // Reset the simulator - const { stdout, stderr } = await execAsync(`xcrun simctl erase "${udid}"`); + validateUdid(udid); + const { stdout, stderr } = await runExecFile("xcrun", ["simctl", "erase", udid]); return { content: [{ @@ -728,8 +722,8 @@ export function registerSimulatorTools(server: XcodeServer) { }, async ({ udid }) => { try { - // Get the list of installed apps - const { stdout, stderr } = await execAsync(`xcrun simctl listapps "${udid}"`); + validateUdid(udid); + const { stdout, stderr } = await runExecFile("xcrun", ["simctl", "listapps", udid]); // Parse the output to extract app information // The output format is a plist-like structure
src/tools/spm/index.ts+65 −185 modified@@ -1,12 +1,9 @@ import { z } from "zod"; -import { promisify } from "util"; -import { exec } from "child_process"; import * as fs from "fs/promises"; import * as path from "path"; import { XcodeServer } from "../../server.js"; import { ProjectNotFoundError, XcodeServerError, CommandExecutionError, PathAccessError, FileOperationError } from "../../utils/errors.js"; - -const execAsync = promisify(exec); +import { runExecFile } from "../../utils/execFile.js"; /** * Helper function to escape special characters in a string for use in a regular expression @@ -223,10 +220,8 @@ export function registerSPMTools(server: XcodeServer) { testingArgs = '--enable-xctest --disable-swift-testing'; } - // Use absolute paths and proper directory - const cmd = `cd "${packageDirectory}" && swift package init ${typeArg} ${nameArg} ${testingArgs}`.trim(); - - const { stdout, stderr } = await execAsync(cmd); + const initArgs = ["package", "init", typeArg, nameArg, ...testingArgs.trim().split(/\s+/).filter(Boolean)]; + const { stdout, stderr } = await runExecFile("swift", initArgs, { cwd: packageDirectory }); // If we have an active project, update its info to reflect it's now an SPM project if (server.activeProject) { @@ -301,34 +296,28 @@ export function registerSPMTools(server: XcodeServer) { } try { - let dependencyArg = `"${url}"`; - - // Handle version requirements + const addArgs = ["package", "add-dependency", url]; if (version) { - if (version.startsWith('exact:')) { - dependencyArg += ` --exact ${version.split(':')[1].trim()}`; - } else if (version.startsWith('from:')) { - dependencyArg += ` --from ${version.split(':')[1].trim()}`; - } else if (version.startsWith('branch:')) { - dependencyArg += ` --branch ${version.split(':')[1].trim()}`; - } else if (version.startsWith('revision:')) { - dependencyArg += ` --revision ${version.split(':')[1].trim()}`; + if (version.startsWith("exact:")) { + addArgs.push("--exact", version.split(":")[1].trim()); + } else if (version.startsWith("from:")) { + addArgs.push("--from", version.split(":")[1].trim()); + } else if (version.startsWith("branch:")) { + addArgs.push("--branch", version.split(":")[1].trim()); + } else if (version.startsWith("revision:")) { + addArgs.push("--revision", version.split(":")[1].trim()); } else { - // Assume it's a "from:" version if not specified - dependencyArg += ` --from ${version}`; + addArgs.push("--from", version); } } + if (productName) addArgs.push("--product", productName); - const productArg = productName ? ` --product ${productName}` : ''; - const cmd = `cd "${activeDirectory}" && swift package add-dependency ${dependencyArg}${productArg}`; - - const { stdout, stderr } = await execAsync(cmd); + const { stdout, stderr } = await runExecFile("swift", addArgs, { cwd: activeDirectory }); - // After adding dependency, run package update unless skipped - let updateOutput = ''; + let updateOutput = ""; if (!skipUpdate) { try { - const { stdout: updateStdout, stderr: updateStderr } = await execAsync('swift package update', { cwd: activeDirectory }); + const { stdout: updateStdout, stderr: updateStderr } = await runExecFile("swift", ["package", "update"], { cwd: activeDirectory }); updateOutput = `\n\nDependencies updated:\n${updateStdout}${updateStderr ? '\nUpdate errors:\n' + updateStderr : ''}`; } catch (updateError) { updateOutput = `\n\nFailed to update dependencies: ${updateError instanceof Error ? updateError.message : String(updateError)}`; @@ -455,7 +444,7 @@ export function registerSPMTools(server: XcodeServer) { // Run package update to clean up let updateOutput = ''; try { - const { stdout: updateStdout, stderr: updateStderr } = await execAsync('swift package update', { cwd: activeDirectory }); + const { stdout: updateStdout, stderr: updateStderr } = await runExecFile("swift", ["package", "update"], { cwd: activeDirectory }); updateOutput = `\n\nDependencies updated:\n${updateStdout}${updateStderr ? '\nUpdate errors:\n' + updateStderr : ''}`; } catch (updateError) { updateOutput = `\n\nFailed to update dependencies after removal: ${updateError instanceof Error ? updateError.message : String(updateError)}`; @@ -544,7 +533,7 @@ export function registerSPMTools(server: XcodeServer) { let updateOutput = ''; try { - const { stdout, stderr } = await execAsync('swift package update', { cwd: packageDir }); + const { stdout, stderr } = await runExecFile("swift", ["package", "update"], { cwd: packageDir }); updateOutput = `\n\nDependencies updated:\n${stdout}${stderr ? '\nUpdate errors:\n' + stderr : ''}`; } catch (error) { let stderr = ''; @@ -608,21 +597,10 @@ export function registerSPMTools(server: XcodeServer) { } } - // Build the command - let cmd = `cd "${packageDir}" && swift build --configuration ${configuration}`; - - // Add target if specified - if (target) { - cmd += ` --target ${target}`; - } - - // Add verbose flag if requested - if (verbose) { - cmd += ` --verbose`; - } - - // Execute the command - const { stdout, stderr } = await execAsync(cmd); + const buildArgs = ["build", "--configuration", configuration]; + if (target) buildArgs.push("--target", target); + if (verbose) buildArgs.push("--verbose"); + const { stdout, stderr } = await runExecFile("swift", buildArgs, { cwd: packageDir }); return { content: [{ @@ -683,26 +661,11 @@ export function registerSPMTools(server: XcodeServer) { } } - // Build the command - let cmd = `cd "${packageDir}" && swift test`; - - // Add filter if specified - if (filter) { - cmd += ` --filter "${filter}"`; - } - - // Add parallel flag if requested - if (parallel) { - cmd += ` --parallel`; - } - - // Add verbose flag if requested - if (verbose) { - cmd += ` --verbose`; - } - - // Execute the command - const { stdout, stderr } = await execAsync(cmd); + const testArgs = ["test"]; + if (filter) testArgs.push("--filter", filter); + if (parallel) testArgs.push("--parallel"); + if (verbose) testArgs.push("--verbose"); + const { stdout, stderr } = await runExecFile("swift", testArgs, { cwd: packageDir }); return { content: [{ @@ -790,7 +753,7 @@ export function registerSPMTools(server: XcodeServer) { // Get package targets using swift package dump-package let packageDump: any = {}; try { - const { stdout } = await execAsync(`cd "${packageDir}" && swift package dump-package`); + const { stdout } = await runExecFile("swift", ["package", "dump-package"], { cwd: packageDir }); packageDump = JSON.parse(stdout); } catch (error) { console.error("Error dumping package:", error); @@ -889,31 +852,10 @@ export function registerSPMTools(server: XcodeServer) { } try { - let cmd: string; - - // If updating a specific package, use resolve command - if (specificPackage) { - cmd = `cd "${activeDirectory}" && swift package resolve`; - - if (version) { - cmd += ` --version "${version}"`; - } - - if (branch) { - cmd += ` --branch "${branch}"`; - } - - if (revision) { - cmd += ` --revision "${revision}"`; - } - - cmd += ` "${specificPackage}"`; - } else { - // Otherwise, use regular update command - cmd = `cd "${activeDirectory}" && swift package update`; - } - - const { stdout, stderr } = await execAsync(cmd); + const pkgArgs = specificPackage + ? ["package", "resolve", ...(version ? ["--version", version] : []), ...(branch ? ["--branch", branch] : []), ...(revision ? ["--revision", revision] : []), specificPackage] + : ["package", "update"]; + const { stdout, stderr } = await runExecFile("swift", pkgArgs, { cwd: activeDirectory }); // Try to get information about current dependencies let dependencyInfo = ''; @@ -999,12 +941,11 @@ export function registerSPMTools(server: XcodeServer) { throw new XcodeServerError("No Package.swift found in the active directory. This project doesn't use Swift Package Manager."); } - const configArg = configuration ? `--configuration ${configuration}` : ''; - const extraArgsStr = extraArgs || ''; - + const pkgArgs = ["package", command]; + if (configuration) pkgArgs.push("--configuration", configuration); + if (extraArgs?.trim()) pkgArgs.push(extraArgs.trim()); try { - const cmd = `cd "${activeDirectory}" && swift package ${command} ${configArg} ${extraArgsStr}`.trim(); - const { stdout, stderr } = await execAsync(cmd); + const { stdout, stderr } = await runExecFile("swift", pkgArgs, { cwd: activeDirectory }); return { content: [{ type: "text", @@ -1062,35 +1003,16 @@ export function registerSPMTools(server: XcodeServer) { throw new XcodeServerError("No Package.swift found in the active directory. This project doesn't use Swift Package Manager."); } - // Build the command with all options - let args = `--configuration ${configuration}`; - - if (target) { - args += ` --target "${target}"`; - } - - if (product) { - args += ` --product "${product}"`; - } - - if (showBinPath) { - args += ` --show-bin-path`; - } - - if (buildTests) { - args += ` --build-tests`; - } - - if (jobs) { - args += ` --jobs ${jobs}`; - } - - if (verbose) { - args += ` --verbose`; - } + const buildArgs = ["build", "--configuration", configuration]; + if (target) buildArgs.push("--target", target); + if (product) buildArgs.push("--product", product); + if (showBinPath) buildArgs.push("--show-bin-path"); + if (buildTests) buildArgs.push("--build-tests"); + if (jobs) buildArgs.push("--jobs", String(jobs)); + if (verbose) buildArgs.push("--verbose"); try { - const { stdout, stderr } = await execAsync(`swift build ${args}`, { cwd: activeDirectory }); + const { stdout, stderr } = await runExecFile("swift", buildArgs, { cwd: activeDirectory }); // If binary path was requested, highlight it in the output let formattedOutput = stdout; @@ -1156,42 +1078,22 @@ export function registerSPMTools(server: XcodeServer) { throw new XcodeServerError("No Package.swift found in the active directory. This project doesn't use Swift Package Manager."); } - // Build the command with all options - let args = `--configuration ${configuration}`; - - // If list tests is requested, we use a different subcommand - if (listTests) { - args = `list ${args}`; - } - - if (filter) { - args += ` --filter "${filter}"`; - } - - if (skip) { - args += ` --skip "${skip}"`; - } - + const testArgs = listTests ? ["test", "list", "--configuration", configuration] : ["test", "--configuration", configuration]; + if (filter) testArgs.push("--filter", filter); + if (skip) testArgs.push("--skip", skip); if (parallel) { - args += ` --parallel`; - - if (numWorkers) { - args += ` --num-workers ${numWorkers}`; - } - } - - if (codeCoverage) { - args += ` --enable-code-coverage`; + testArgs.push("--parallel"); + if (numWorkers) testArgs.push("--num-workers", String(numWorkers)); } - + if (codeCoverage) testArgs.push("--enable-code-coverage"); if (outputPath) { const resolvedOutputPath = server.pathManager.normalizePath(outputPath); server.pathManager.validatePathForWriting(resolvedOutputPath); - args += ` --xunit-output "${resolvedOutputPath}"`; + testArgs.push("--xunit-output", resolvedOutputPath); } try { - const { stdout, stderr } = await execAsync(`swift test ${args}`, { cwd: activeDirectory }); + const { stdout, stderr } = await runExecFile("swift", testArgs, { cwd: activeDirectory }); return { content: [{ @@ -1294,24 +1196,17 @@ export function registerSPMTools(server: XcodeServer) { throw new XcodeServerError("No Package.swift found in the active directory. This project doesn't use Swift Package Manager."); } - // Build the command - let args = `show-dependencies --format ${format}`; - - if (verbose) { - args += ` --verbose`; - } - + const showArgs = ["package", "show-dependencies", "--format", format]; + if (verbose) showArgs.push("--verbose"); if (outputPath) { const resolvedOutputPath = server.pathManager.normalizePath(outputPath); server.pathManager.validatePathForWriting(resolvedOutputPath); - args += ` --output-path "${resolvedOutputPath}"`; - - // Ensure the output directory exists + showArgs.push("--output-path", resolvedOutputPath); await fs.mkdir(path.dirname(resolvedOutputPath), { recursive: true }); } try { - const { stdout, stderr } = await execAsync(`swift package ${args}`, { cwd: activeDirectory }); + const { stdout, stderr } = await runExecFile("swift", showArgs, { cwd: activeDirectory }); // Try to get information about the Package.swift dependencies directly let packageDepsInfo = ''; @@ -1390,7 +1285,7 @@ export function registerSPMTools(server: XcodeServer) { } try { - const { stdout, stderr } = await execAsync(`swift package ${command}`, { cwd: activeDirectory }); + const { stdout, stderr } = await runExecFile("swift", ["package", command], { cwd: activeDirectory }); return { content: [{ @@ -1442,7 +1337,7 @@ export function registerSPMTools(server: XcodeServer) { } try { - const { stdout, stderr } = await execAsync(`swift package dump-package`, { cwd: activeDirectory }); + const { stdout, stderr } = await runExecFile("swift", ["package", "dump-package"], { cwd: activeDirectory }); // Try to parse and pretty print the JSON let formattedOutput = stdout; @@ -1517,31 +1412,18 @@ export function registerSPMTools(server: XcodeServer) { try { // First, check if swift-docc is available try { - await execAsync('which swift-docc'); + await runExecFile("which", ["swift-docc"]); } catch { throw new Error( "swift-docc command not found. DocC is available in Swift 5.5 and later. " + "Make sure you have the latest version of Swift installed." ); } - // Build the command with all options - let cmd = `cd "${activeDirectory}" && swift package --disable-sandbox generate-documentation`; - - // Add output path - cmd += ` --output-path "${resolvedOutputPath}"`; - - // Add hosting base path if provided - if (hostingBasePath) { - cmd += ` --hosting-base-path "${hostingBasePath}"`; - } - - // Add transform for static hosting if requested - if (transformForStaticHosting) { - cmd += ` --transform-for-static-hosting`; - } - - const { stdout, stderr } = await execAsync(cmd); + const docArgs = ["package", "--disable-sandbox", "generate-documentation", "--output-path", resolvedOutputPath]; + if (hostingBasePath) docArgs.push("--hosting-base-path", hostingBasePath); + if (transformForStaticHosting) docArgs.push("--transform-for-static-hosting"); + const { stdout, stderr } = await runExecFile("swift", docArgs, { cwd: activeDirectory }); // Determine the index.html path for opening in browser const indexPath = path.join(resolvedOutputPath, 'index.html'); @@ -1553,9 +1435,7 @@ export function registerSPMTools(server: XcodeServer) { // Check if the index.html file exists await fs.access(indexPath); - // Open in browser - const { exec } = await import('child_process'); - exec(`open "${indexPath}"`); + await runExecFile("open", [indexPath]); browserMessage = `\n\nDocumentation opened in your default browser.`; } catch {
src/tools/xcode/index.ts+78 −105 modified@@ -1,34 +1,36 @@ import { z } from "zod"; -import { promisify } from "util"; -import { exec } from "child_process"; import * as path from "path"; import * as fs from "fs/promises"; import { XcodeServer } from "../../server.js"; import { CommandExecutionError, PathAccessError, FileOperationError } from "../../utils/errors.js"; - -const execAsync = promisify(exec); +import { runExecFile } from "../../utils/execFile.js"; /** * Get available Xcode versions on the system */ async function getXcodeVersions(): Promise<{path: string, version: string, build: string, isDefault: boolean}[]> { try { - const { stdout } = await execAsync('mdfind "kMDItemCFBundleIdentifier == com.apple.dt.Xcode"'); - const paths = stdout.trim().split('\n').filter(Boolean); + const { stdout } = await runExecFile("mdfind", ["kMDItemCFBundleIdentifier == com.apple.dt.Xcode"]); + const paths = stdout.trim().split("\n").filter(Boolean); const versions = []; const defaultPath = await getDefaultXcodePath(); - for (const path of paths) { + for (const p of paths) { try { - const { stdout: versionOutput } = await execAsync(`/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "${path}/Contents/Info.plist"`); - const { stdout: buildOutput } = await execAsync(`/usr/libexec/PlistBuddy -c "Print :DTXcode" "${path}/Contents/Info.plist"`); + const plistPath = path.join(p, "Contents", "Info.plist"); + const { stdout: versionOutput } = await runExecFile("/usr/libexec/PlistBuddy", [ + "-c", "Print :CFBundleShortVersionString", plistPath + ]); + const { stdout: buildOutput } = await runExecFile("/usr/libexec/PlistBuddy", [ + "-c", "Print :DTXcode", plistPath + ]); versions.push({ - path, + path: p, version: versionOutput.trim(), build: buildOutput.trim(), - isDefault: path === defaultPath + isDefault: p === defaultPath }); } catch { // Skip if we can't get version info @@ -47,7 +49,7 @@ async function getXcodeVersions(): Promise<{path: string, version: string, build */ async function getDefaultXcodePath(): Promise<string> { try { - const { stdout } = await execAsync('xcode-select -p'); + const { stdout } = await runExecFile("xcode-select", ["-p"]); // xcode-select returns the Developer directory, we need to go up two levels return path.dirname(path.dirname(stdout.trim())); } catch (error) { @@ -71,36 +73,30 @@ export function registerXcodeTools(server: XcodeServer) { }, async ({ tool, args = "", workingDir }) => { try { - let options = {}; - + const options: { cwd?: string } = {}; if (workingDir) { - // Validate and resolve the working directory path const resolvedWorkingDir = server.pathManager.normalizePath(workingDir); server.pathManager.validatePathForReading(resolvedWorkingDir); - options = { cwd: resolvedWorkingDir }; + options.cwd = resolvedWorkingDir; } - const { stdout, stderr } = await execAsync(`xcrun ${tool} ${args}`, options); + const xcrunArgs = args ? [tool, args] : [tool]; + const { stdout, stderr } = await runExecFile("xcrun", xcrunArgs, options); - return { - content: [{ + return { + content: [{ type: "text", - text: `${tool} output:\n${stdout}\n${stderr ? 'Error output:\n' + stderr : ''}` + text: `${tool} output:\n${stdout}\n${stderr ? "Error output:\n" + stderr : ""}` }] }; } catch (error) { - let stderr = ''; - if (error instanceof Error && 'stderr' in error) { - stderr = (error as any).stderr; - } - if (error instanceof PathAccessError) { throw new Error(`Access denied: ${error.message}`); } - + if (error instanceof CommandExecutionError) throw error; throw new CommandExecutionError( `xcrun ${tool}`, - stderr || (error instanceof Error ? error.message : String(error)) + error instanceof Error ? error.message : String(error) ); } } @@ -149,30 +145,26 @@ export function registerXcodeTools(server: XcodeServer) { throw new FileOperationError(`Failed to create output directory: ${outputDir}`, String(error)); } - // Build the command with validated paths - let cmd = `xcrun actool "${resolvedCatalogPath}" --output-format human-readable-text --notices --warnings`; - - // Add platform - cmd += ` --platform ${platform}`; - - // Add deployment target - cmd += ` --minimum-deployment-target ${minDeploymentTarget}`; - - // Add target devices + const plistPath = path.join(resolvedOutputDir, "assetcatalog_generated_info.plist"); + const actoolArgs = [ + resolvedCatalogPath, + "--output-format", "human-readable-text", + "--notices", "--warnings", + "--platform", platform, + "--minimum-deployment-target", minDeploymentTarget, + ]; for (const device of targetDevices) { - cmd += ` --target-device ${device}`; + actoolArgs.push("--target-device", device); } - - // Add app icon if specified if (appIcon) { - cmd += ` --app-icon ${appIcon}`; + actoolArgs.push("--app-icon", appIcon); } + actoolArgs.push( + "--output-partial-info-plist", plistPath, + "--compress-pngs", "--compile", resolvedOutputDir + ); - // Add output paths - const plistPath = path.join(resolvedOutputDir, 'assetcatalog_generated_info.plist'); - cmd += ` --output-partial-info-plist "${plistPath}" --compress-pngs --compile "${resolvedOutputDir}"`; - - const { stdout, stderr } = await execAsync(cmd); + const { stdout, stderr } = await runExecFile("xcrun", ["actool", ...actoolArgs]); return { content: [{ @@ -209,29 +201,24 @@ export function registerXcodeTools(server: XcodeServer) { }, async ({ args = "", command }) => { try { - let cmd = `xcrun lldb ${args}`; - - // If a single command is provided, execute it and exit + const lldbArgs: string[] = ["lldb"]; + if (args) lldbArgs.push(args); if (command) { - cmd += ` -o "${command}" -b`; // -b for batch mode (exit after running commands) + lldbArgs.push("-o", command, "-b"); } + const { stdout, stderr } = await runExecFile("xcrun", lldbArgs); - const { stdout, stderr } = await execAsync(cmd); - - return { - content: [{ + return { + content: [{ type: "text", - text: `LLDB output:\n${stdout}\n${stderr ? 'Error output:\n' + stderr : ''}` + text: `LLDB output:\n${stdout}\n${stderr ? "Error output:\n" + stderr : ""}` }] }; } catch (error) { - let stderr = ''; - if (error instanceof Error && 'stderr' in error) { - stderr = (error as any).stderr; - } + if (error instanceof CommandExecutionError) throw error; throw new CommandExecutionError( - 'xcrun lldb', - stderr || (error instanceof Error ? error.message : String(error)) + "xcrun lldb", + error instanceof Error ? error.message : String(error) ); } } @@ -267,16 +254,16 @@ export function registerXcodeTools(server: XcodeServer) { const outputDir = path.dirname(resolvedOutputPath); await fs.mkdir(outputDir, { recursive: true }); - // Build the command - let cmd = `xcrun xctrace record --template "${template}" --time-limit ${duration}s --output "${resolvedOutputPath}"`; + const xctraceArgs = [ + "xctrace", "record", + "--template", template, + "--time-limit", `${duration}s`, + "--output", resolvedOutputPath, + ]; + if (startSuspended) xctraceArgs.push("--launch-suspended"); + xctraceArgs.push("--launch", "--", resolvedAppPath); - if (startSuspended) { - cmd += ' --launch-suspended'; - } - - cmd += ` --launch -- "${resolvedAppPath}"`; - - const { stdout, stderr } = await execAsync(cmd); + const { stdout, stderr } = await runExecFile("xcrun", xctraceArgs); return { content: [{ @@ -327,41 +314,33 @@ export function registerXcodeTools(server: XcodeServer) { // Add SDK information try { - // Get iOS SDK information - const { stdout: iOSSDKOutput } = await execAsync('xcrun --show-sdk-path --sdk iphoneos'); + const { stdout: iOSSDKOutput } = await runExecFile("xcrun", ["--show-sdk-path", "--sdk", "iphoneos"]); formattedOutput += `\niOS SDK Path: ${iOSSDKOutput.trim()}\n`; - - const { stdout: iOSSDKVersionOutput } = await execAsync('xcrun --show-sdk-version --sdk iphoneos'); + const { stdout: iOSSDKVersionOutput } = await runExecFile("xcrun", ["--show-sdk-version", "--sdk", "iphoneos"]); formattedOutput += `iOS SDK Version: ${iOSSDKVersionOutput.trim()}\n`; - // Get macOS SDK information try { - const { stdout: macOSSDKOutput } = await execAsync('xcrun --show-sdk-path --sdk macosx'); + const { stdout: macOSSDKOutput } = await runExecFile("xcrun", ["--show-sdk-path", "--sdk", "macosx"]); formattedOutput += `\nmacOS SDK Path: ${macOSSDKOutput.trim()}\n`; - - const { stdout: macOSSDKVersionOutput } = await execAsync('xcrun --show-sdk-version --sdk macosx'); + const { stdout: macOSSDKVersionOutput } = await runExecFile("xcrun", ["--show-sdk-version", "--sdk", "macosx"]); formattedOutput += `macOS SDK Version: ${macOSSDKVersionOutput.trim()}\n`; } catch { // Ignore if macOS SDK info can't be retrieved } - // Get watchOS SDK information try { - const { stdout: watchOSSDKOutput } = await execAsync('xcrun --show-sdk-path --sdk watchos'); + const { stdout: watchOSSDKOutput } = await runExecFile("xcrun", ["--show-sdk-path", "--sdk", "watchos"]); formattedOutput += `\nwatchOS SDK Path: ${watchOSSDKOutput.trim()}\n`; - - const { stdout: watchOSSDKVersionOutput } = await execAsync('xcrun --show-sdk-version --sdk watchos'); + const { stdout: watchOSSDKVersionOutput } = await runExecFile("xcrun", ["--show-sdk-version", "--sdk", "watchos"]); formattedOutput += `watchOS SDK Version: ${watchOSSDKVersionOutput.trim()}\n`; } catch { // Ignore if watchOS SDK info can't be retrieved } - // Get tvOS SDK information try { - const { stdout: tvOSSDKOutput } = await execAsync('xcrun --show-sdk-path --sdk appletvos'); + const { stdout: tvOSSDKOutput } = await runExecFile("xcrun", ["--show-sdk-path", "--sdk", "appletvos"]); formattedOutput += `\ntvOS SDK Path: ${tvOSSDKOutput.trim()}\n`; - - const { stdout: tvOSSDKVersionOutput } = await execAsync('xcrun --show-sdk-version --sdk appletvos'); + const { stdout: tvOSSDKVersionOutput } = await runExecFile("xcrun", ["--show-sdk-version", "--sdk", "appletvos"]); formattedOutput += `tvOS SDK Version: ${tvOSSDKVersionOutput.trim()}\n`; } catch { // Ignore if tvOS SDK info can't be retrieved @@ -460,10 +439,8 @@ export function registerXcodeTools(server: XcodeServer) { throw new Error('No target Xcode version found'); } - // Use the path module - // (already imported at the top of the file) - const developerDir = path.join(targetXcode.path, 'Contents', 'Developer'); - const { stdout, stderr } = await execAsync(`sudo xcode-select --switch "${developerDir}"`); + const developerDir = path.join(targetXcode.path, "Contents", "Developer"); + const { stdout, stderr } = await runExecFile("xcode-select", ["--switch", developerDir]); return { content: [{ @@ -595,9 +572,12 @@ export function registerXcodeTools(server: XcodeServer) { // Write the export options plist await fs.writeFile(exportOptionsPath, exportOptionsContent, 'utf-8'); - // Run the export command - const cmd = `xcrun xcodebuild -exportArchive -archivePath "${resolvedArchivePath}" -exportOptionsPlist "${exportOptionsPath}" -exportPath "${resolvedExportPath}"`; - const { stdout, stderr } = await execAsync(cmd); + const { stdout, stderr } = await runExecFile("xcrun", [ + "xcodebuild", "-exportArchive", + "-archivePath", resolvedArchivePath, + "-exportOptionsPlist", exportOptionsPath, + "-exportPath", resolvedExportPath + ]); // Clean up the temporary directory try { @@ -705,19 +685,14 @@ export function registerXcodeTools(server: XcodeServer) { } } - // Build the validation command - let cmd; - + const altoolArgs = ["altool", "--validate-app", "-f", resolvedIpaPath, "--type", "ios"]; if (apiKey && apiIssuer && apiKeyPath) { - // Use API Key authentication const resolvedApiKeyPath = server.pathManager.normalizePath(apiKeyPath); - cmd = `xcrun altool --validate-app -f "${resolvedIpaPath}" --apiKey "${apiKey}" --apiIssuer "${apiIssuer}" --api-key-path "${resolvedApiKeyPath}" --type ios`; + altoolArgs.push("--apiKey", apiKey, "--apiIssuer", apiIssuer, "--api-key-path", resolvedApiKeyPath); } else { - // Use username/password authentication - cmd = `xcrun altool --validate-app -f "${resolvedIpaPath}" -u "${username}" -p "${password}" --type ios`; + altoolArgs.push("-u", username, "-p", password); } - - const { stdout, stderr } = await execAsync(cmd); + const { stdout, stderr } = await runExecFile("xcrun", altoolArgs); // Check for validation success if (stdout.includes("No errors validating")) { @@ -844,9 +819,7 @@ export function registerXcodeTools(server: XcodeServer) { const filename = `icon_${size}x${size}@${scale}x.png`; const outputFilePath = path.join(iconsetPath, filename); - // Generate the resized image - const resizeCmd = `sips -Z ${pixelSize} "${resolvedSourceImage}" --out "${outputFilePath}"`; - await execAsync(resizeCmd); + await runExecFile("sips", ["-Z", String(pixelSize), resolvedSourceImage, "--out", outputFilePath]); // Add to Contents.json (contentsJson.images as any[]).push({
src/utils/execFile.ts+45 −0 added@@ -0,0 +1,45 @@ +import { execFile } from "child_process"; +import { promisify } from "util"; +import { CommandExecutionError } from "./errors.js"; + +const execFileAsync = promisify(execFile); + +export interface RunExecFileOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + timeout?: number; + maxBuffer?: number; +} + +/** + * Run a binary with argument array. No shell is invoked; arguments are passed + * literally to the process. Use this instead of exec() to prevent CWE-78 + * command injection when any argument is or could be user-controlled. + */ +export async function runExecFile( + file: string, + args: string[], + options?: RunExecFileOptions +): Promise<{ stdout: string; stderr: string }> { + try { + const result = await execFileAsync(file, args, { + encoding: "utf8", + ...options, + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; + } catch (error) { + const stderr = + error && typeof error === "object" && "stderr" in error + ? String((error as { stderr: string }).stderr) + : ""; + const message = + error instanceof Error ? error.message : String(error); + throw new CommandExecutionError( + [file, ...args].join(" "), + stderr || message + ); + } +}
src/utils/project.ts+33 −53 modified@@ -1,22 +1,17 @@ -import * as path from 'path'; -import * as fs from 'fs/promises'; -import { promisify } from 'util'; -import { exec, execFile } from 'child_process'; -import { isInXcodeProject } from './file.js'; -import { XcodeProject, ProjectInfo } from '../types/index.js'; - -const execAsync = promisify(exec); -const execFileAsync = promisify(execFile); +import * as path from "path"; +import * as fs from "fs/promises"; +import { isInXcodeProject } from "./file.js"; +import { XcodeProject, ProjectInfo } from "../types/index.js"; +import { runExecFile } from "./execFile.js"; /** * Find all Xcode projects in the given search path */ export async function findXcodeProjects(searchPath = "."): Promise<XcodeProject[]> { try { - // Find .xcodeproj, .xcworkspace, and Package.swift files - const { stdout: projStdout } = await execFileAsync('find', [searchPath, '-name', '*.xcodeproj']); - const { stdout: workspaceStdout } = await execFileAsync('find', [searchPath, '-name', '*.xcworkspace']); - const { stdout: spmStdout } = await execFileAsync('find', [searchPath, '-name', 'Package.swift']); + const { stdout: projStdout } = await runExecFile("find", [searchPath, "-name", "*.xcodeproj"]); + const { stdout: workspaceStdout } = await runExecFile("find", [searchPath, "-name", "*.xcworkspace"]); + const { stdout: spmStdout } = await runExecFile("find", [searchPath, "-name", "Package.swift"]); const projects: XcodeProject[] = []; @@ -88,8 +83,8 @@ export async function findXcodeProjects(searchPath = "."): Promise<XcodeProject[ */ export async function isProjectInWorkspace(projectPath: string): Promise<boolean> { const projectDir = path.dirname(projectPath); - const workspaceCheck = await execFileAsync('find', [projectDir, '-maxdepth', '2', '-name', '*.xcworkspace']); - return workspaceCheck.stdout.trim().length > 0; + const { stdout } = await runExecFile("find", [projectDir, "-maxdepth", "2", "-name", "*.xcworkspace"]); + return stdout.trim().length > 0; } /** @@ -146,39 +141,31 @@ export async function findMainProjectInWorkspace(workspacePath: string): Promise */ export async function getProjectInfo(projectPath: string): Promise<ProjectInfo> { try { - // Determine the right command based on the project path type - let cmd: string; - - if (projectPath.endsWith('.xcworkspace')) { - // For workspaces, use -workspace flag - cmd = `xcodebuild -list -workspace "${projectPath}"`; - } else if (projectPath.endsWith('/project.xcworkspace')) { - // Handle the case where we incorrectly get a project.xcworkspace inside an .xcodeproj - // Strip off the /project.xcworkspace and use the .xcodeproj with -project flag - const xcodeProjectPath = projectPath.replace('/project.xcworkspace', ''); - cmd = `xcodebuild -list -project "${xcodeProjectPath}"`; - } else if (projectPath.endsWith('.xcodeproj')) { - // Standard project - cmd = `xcodebuild -list -project "${projectPath}"`; + let args: string[]; + + if (projectPath.endsWith(".xcworkspace")) { + args = ["-list", "-workspace", projectPath]; + } else if (projectPath.endsWith("/project.xcworkspace")) { + const xcodeProjectPath = projectPath.replace("/project.xcworkspace", ""); + args = ["-list", "-project", xcodeProjectPath]; + } else if (projectPath.endsWith(".xcodeproj")) { + args = ["-list", "-project", projectPath]; } else { - // Check if it's an SPM project - const packageSwiftPath = path.join(projectPath, 'Package.swift'); + const packageSwiftPath = path.join(projectPath, "Package.swift"); try { await fs.access(packageSwiftPath); - // For SPM projects, return basic info return { path: projectPath, - targets: ['all'], - configurations: ['debug', 'release'], - schemes: ['all'] + targets: ["all"], + configurations: ["debug", "release"], + schemes: ["all"] }; } catch { - // Not an SPM project, try as a standard project - cmd = `xcodebuild -list -project "${projectPath}"`; + args = ["-list", "-project", projectPath]; } } - const { stdout } = await execAsync(cmd); + const { stdout } = await runExecFile("xcodebuild", args); const info: ProjectInfo = { path: projectPath, targets: [], @@ -211,23 +198,16 @@ export async function getProjectInfo(projectPath: string): Promise<ProjectInfo> */ export async function getWorkspaceInfo(workspacePath: string): Promise<ProjectInfo> { try { - // Handle different path formats - let cmd: string; - - if (workspacePath.endsWith('.xcworkspace')) { - // Standard workspace - cmd = `xcodebuild -workspace "${workspacePath}" -list`; - } else if (workspacePath.endsWith('/project.xcworkspace')) { - // Handle case where we get project.xcworkspace inside an .xcodeproj - const xcodeProjectPath = workspacePath.replace('/project.xcworkspace', ''); - // In this case, use project instead of workspace - cmd = `xcodebuild -project "${xcodeProjectPath}" -list`; + let args: string[]; + if (workspacePath.endsWith(".xcworkspace")) { + args = ["-workspace", workspacePath, "-list"]; + } else if (workspacePath.endsWith("/project.xcworkspace")) { + const xcodeProjectPath = workspacePath.replace("/project.xcworkspace", ""); + args = ["-project", xcodeProjectPath, "-list"]; } else { - // Default to treating it as a workspace - cmd = `xcodebuild -workspace "${workspacePath}" -list`; + args = ["-workspace", workspacePath, "-list"]; } - - const { stdout } = await execAsync(cmd); + const { stdout } = await runExecFile("xcodebuild", args); const info: ProjectInfo = { path: workspacePath, targets: [],
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/r-huijts/xcode-mcp-server/commit/11f8d6bacadd153beee649f92a78a9dad761f56fnvdPatchWEB
- github.com/r-huijts/xcode-mcp-server/issues/13nvdExploitWEB
- github.com/r-huijts/xcode-mcp-server/issues/13nvdExploitWEB
- github.com/advisories/GHSA-84fx-pwf3-7777ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-2178ghsaADVISORY
- vuldb.comnvdThird Party AdvisoryVDB EntryWEB
- vuldb.comnvdThird Party AdvisoryVDB EntryWEB
- vuldb.comnvdPermissions RequiredVDB EntryWEB
News mentions
0No linked articles in our index yet.