import { promises as fs } from "fs";
import { dirname, basename, join, relative } from "path";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
/**
* Enhanced Xcode Project Manager with better pbxproj manipulation
*/
export class XcodeProjectManager {
constructor(projectPath) {
this.projectPath = projectPath;
this.projectDir = dirname(projectPath);
this.projectName = basename(projectPath, ".xcodeproj");
this.pbxprojPath = join(projectPath, "project.pbxproj");
this.projectData = null;
}
/**
* Read and parse the pbxproj file
*/
async readProject() {
try {
const content = await fs.readFile(this.pbxprojPath, "utf-8");
this.projectData = this.parsePbxProj(content);
return this.projectData;
} catch (error) {
throw new Error(`Failed to read project: ${error.message}`);
}
}
/**
* Parse pbxproj file structure
* This is a simplified parser - for production, consider using a library like 'xcode'
*/
parsePbxProj(content) {
const targets = [];
const fileReferences = [];
const groups = [];
const buildPhases = [];
// Extract targets
const targetMatch = content.match(
/\/\* Begin PBXNativeTarget section \*\/\s*((?:\/\*[^*]+\*\/|.*?)*?)\/\* End PBXNativeTarget section \*\//s
);
if (targetMatch) {
const targetSection = targetMatch[1];
// Extract target definitions
const targetDefRegex =
/([A-F0-9]{24}) \/\* Begin PBXNativeTarget section \*\/\s*((?:\/\*[^*]+\*\/|.*?)*?)\/\* End PBXNativeTarget section \*\//gs;
const targetRegex = /([A-F0-9]{24}) \/\* ([^*]+) \*\//g;
let match;
while ((match = targetRegex.exec(targetSection)) !== null) {
// Find target definition
const targetDefMatch = content.match(
new RegExp(
`${match[1]} = \\{[^}]*name = ${match[2].trim()}[^}]*\\}`,
"s"
)
);
targets.push({
id: match[1],
name: match[2].trim(),
});
}
}
// Extract file system synchronized groups (newer Xcode projects)
const fsGroupMatch = content.match(
/\/\* Begin PBXFileSystemSynchronizedRootGroup section \*\/\s*((?:\/\*[^*]+\*\/|.*?)*?)\/\* End PBXFileSystemSynchronizedRootGroup section \*\//s
);
if (fsGroupMatch) {
const fsGroupRegex = /([A-F0-9]{24}) \/\* ([^*]+) \*\//g;
let match;
while ((match = fsGroupRegex.exec(fsGroupMatch[1])) !== null) {
groups.push({
id: match[1],
name: match[2].trim(),
type: "filesystem",
});
}
}
return {
targets,
fileReferences,
groups,
raw: content,
hasFileSystemGroups: groups.length > 0,
};
}
/**
* List all build schemes using xcodebuild
*/
async listSchemes() {
try {
const { stdout } = await execAsync(
`xcodebuild -project "${this.projectPath}" -list`
);
return {
success: true,
schemes: this.parseSchemes(stdout),
raw: stdout,
};
} catch (error) {
return {
success: false,
error: error.message,
};
}
}
parseSchemes(output) {
const schemes = [];
const lines = output.split("\n");
let inSchemes = false;
for (const line of lines) {
if (line.trim().startsWith("Schemes:")) {
inSchemes = true;
continue;
}
if (inSchemes && line.trim() === "") {
break;
}
if (inSchemes && line.trim()) {
schemes.push(line.trim());
}
}
return schemes;
}
/**
* Generate a new UUID for Xcode project objects
*/
generateUUID() {
const chars = "0123456789ABCDEF";
let uuid = "";
for (let i = 0; i < 24; i++) {
uuid += chars[Math.floor(Math.random() * 16)];
}
return uuid;
}
/**
* Add a test target to the project
* This creates the necessary pbxproj entries
*/
async addTestTarget(targetName = null) {
const testTargetName = targetName || `${this.projectName}Tests`;
// Check if target already exists
const project = await this.readProject();
const existingTarget = project.targets.find(
(t) => t.name === testTargetName
);
if (existingTarget) {
return {
success: false,
message: `Target '${testTargetName}' already exists`,
targetName: testTargetName,
};
}
// For file system synchronized projects, we need to create the directory structure
// and then modify the pbxproj to add the test target
const testDir = join(this.projectDir, testTargetName);
try {
// Check if test directory exists
await fs.access(testDir);
// Directory exists, good
} catch {
// Directory doesn't exist, we should note this
return {
success: false,
message: `Test directory '${testDir}' does not exist. Please create it first or use Xcode to add the test target.`,
targetName: testTargetName,
suggestion:
"Use Xcode: File > New > Target > Unit Testing Bundle, or create the directory structure manually.",
};
}
// Note: Actually modifying pbxproj requires careful parsing and insertion
// This is complex and error-prone. For now, we provide guidance.
return {
success: false,
message:
"Automatic test target creation requires manual pbxproj editing which is complex.",
targetName: testTargetName,
recommendation:
"Use Xcode GUI: File > New > Target > Unit Testing Bundle, or use a library like 'xcode' npm package for programmatic manipulation.",
};
}
/**
* Configure a test target in the project by modifying the pbxproj file
* This method uses Python with pbxproj library to properly add the test target
*/
async configureTestTarget(targetName = null) {
const testTargetName = targetName || `${this.projectName}Tests`;
const testDir = join(this.projectDir, testTargetName);
try {
// Check if test directory exists
let testDirExists = false;
try {
await fs.access(testDir);
testDirExists = true;
} catch {
// Directory doesn't exist
}
if (!testDirExists) {
return {
success: false,
message: `Test directory '${testDir}' does not exist. Please create it first.`,
targetName: testTargetName,
suggestion: `Create the directory: mkdir -p "${testDir}"`,
};
}
// Check if target already exists by reading project
const project = await this.readProject();
const existingTarget = project.targets.find(
(t) => t.name === testTargetName
);
if (existingTarget) {
return {
success: true,
message: `Test target '${testTargetName}' already exists in the project.`,
targetName: testTargetName,
};
}
// Try using Python with pbxproj library to add the test target
// This is the most reliable way to modify Xcode projects
const pythonScript = `
import json
import sys
import os
try:
from pbxproj import XcodeProject
from pbxproj.pbxextensions.ProjectFiles import FileOptions
project_path = sys.argv[1] # Full path to .xcodeproj directory
test_target_name = sys.argv[2]
project_dir = os.path.dirname(project_path) # Parent of .xcodeproj
pbxproj_path = os.path.join(project_path, "project.pbxproj")
# Open the project
project = XcodeProject.load(pbxproj_path)
# Check if target already exists
targets = project.objects.get_targets()
for target in targets:
if target.name == test_target_name:
result = {"success": True, "exists": True, "target": test_target_name, "message": "Test target already exists"}
print(json.dumps(result))
sys.exit(0)
# Check if test directory exists
test_dir = os.path.join(project_dir, test_target_name)
if not os.path.exists(test_dir):
print(json.dumps({"error": f"Test directory does not exist: {test_dir}"}))
sys.exit(1)
# Get or create group for test files
test_group = project.get_or_create_group(test_target_name, path=test_target_name)
# Note: pbxproj library doesn't have a simple method to create a full test target
# Adding a test target requires creating multiple objects (PBXNativeTarget, build phases, etc.)
# This is complex. The library is better for modifying existing targets.
# Save the project (with the new group if it didn't exist)
project.save()
# Return status
result = {
"success": False,
"message": "Test group added to project, but full test target requires manual configuration in Xcode",
"targetName": test_target_name,
"details": "pbxproj library can add groups and files, but creating a complete test target (with all build phases, configurations, etc.) requires Xcode GUI",
"recommendation": "Use Xcode: File > New > Target > Unit Testing Bundle, then the test files will be automatically included",
"testDirectory": test_dir,
"testGroupAdded": True
}
print(json.dumps(result))
except ImportError as e:
print(json.dumps({
"error": "pbxproj Python library not installed",
"details": str(e),
"install": "pip install pbxproj"
}))
except Exception as e:
import traceback
print(json.dumps({
"error": str(e),
"traceback": traceback.format_exc()
}))
`;
try {
// Try running Python script
const { stdout, stderr } = await execAsync(
`python3 -c ${JSON.stringify(pythonScript)} "${this.pbxprojPath}" "${testTargetName}" 2>&1`
);
// Try to parse JSON output
try {
const result = JSON.parse(stdout.trim().split('\n').pop());
return {
...result,
pythonOutput: stdout,
};
} catch {
// If not JSON, return raw output
return {
success: false,
message: "Python script executed but returned unexpected output",
pythonOutput: stdout,
pythonError: stderr,
targetName: testTargetName,
};
}
} catch (pythonError) {
// Python not available or script failed
// Return guidance
return {
success: false,
message: "Unable to automatically configure test target. Python with pbxproj library is recommended for automatic configuration.",
targetName: testTargetName,
recommendation: "Option 1: Install pbxproj and Python: pip install pbxproj\nOption 2: Use Xcode GUI: File > New > Target > Unit Testing Bundle\nOption 3: Manually edit pbxproj (not recommended)",
testDirectoryExists: testDirExists,
testDirectoryPath: testDir,
pythonError: pythonError.message,
};
}
} catch (error) {
return {
success: false,
error: error.message,
targetName: testTargetName,
};
}
}
/**
* Check if a file is in a file system synchronized group
*/
async isFileInProject(filePath) {
const project = await this.readProject();
// For file system synchronized groups, files are automatically included
if (project.hasFileSystemGroups) {
const relativePath = relative(this.projectDir, filePath);
try {
await fs.access(join(this.projectDir, relativePath));
return true;
} catch {
return false;
}
}
// For traditional projects, we'd need to check file references
return false;
}
/**
* Add a file to a target (simplified - works with file system synchronized groups)
*/
async addFileToTarget(filePath, targetName) {
const fullPath = join(this.projectDir, filePath);
const fileExists = await fs
.access(fullPath)
.then(() => true)
.catch(() => false);
if (!fileExists) {
throw new Error(`File does not exist: ${fullPath}`);
}
const project = await this.readProject();
// Check if target exists
const target = project.targets.find((t) => t.name === targetName);
if (!target) {
throw new Error(`Target '${targetName}' not found in project`);
}
// For file system synchronized groups, files are automatically included if in the right directory
if (project.hasFileSystemGroups) {
return {
success: true,
message: `File ${filePath} is in a file system synchronized group and should be automatically included. Ensure it's in the correct target directory.`,
filePath: fullPath,
targetName,
};
}
// For traditional projects, we'd need to modify pbxproj
return {
success: false,
message:
"Adding files to traditional (non-file-system-synchronized) projects requires pbxproj manipulation. Use Xcode GUI or a proper pbxproj parser library.",
filePath: fullPath,
targetName,
};
}
/**
* Build the project
*/
async build(scheme = null, configuration = "Debug") {
const schemeToUse = scheme || this.projectName;
try {
const { stdout, stderr } = await execAsync(
`xcodebuild -project "${this.projectPath}" -scheme "${schemeToUse}" -configuration ${configuration} build 2>&1`
);
return {
success: true,
stdout,
stderr,
};
} catch (error) {
return {
success: false,
error: error.message,
stdout: error.stdout || "",
stderr: error.stderr || "",
};
}
}
/**
* Run tests
*/
async test(scheme = null, destination = null) {
const schemeToUse = scheme || `${this.projectName}Tests`;
const destinationStr =
destination ||
"platform=iOS Simulator,name=iPhone 15,OS=latest";
try {
const { stdout, stderr } = await execAsync(
`xcodebuild test -project "${this.projectPath}" -scheme "${schemeToUse}" -destination '${destinationStr}' 2>&1`
);
return {
success: true,
stdout,
stderr,
};
} catch (error) {
return {
success: false,
error: error.message,
stdout: error.stdout || "",
stderr: error.stderr || "",
};
}
}
/**
* Get build settings
*/
async getBuildSettings(scheme = null, configuration = "Debug") {
const schemeToUse = scheme || this.projectName;
try {
const { stdout } = await execAsync(
`xcodebuild -project "${this.projectPath}" -scheme "${schemeToUse}" -configuration ${configuration} -showBuildSettings 2>&1`
);
return {
success: true,
settings: this.parseBuildSettings(stdout),
raw: stdout,
};
} catch (error) {
return {
success: false,
error: error.message,
stdout: error.stdout || "",
};
}
}
parseBuildSettings(output) {
const settings = {};
const lines = output.split("\n");
for (const line of lines) {
const match = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.+)$/);
if (match) {
settings[match[1]] = match[2].trim();
}
}
return settings;
}
/**
* Clean build artifacts
*/
async clean(scheme = null) {
const schemeToUse = scheme || this.projectName;
try {
const { stdout, stderr } = await execAsync(
`xcodebuild clean -project "${this.projectPath}" -scheme "${schemeToUse}" 2>&1`
);
return {
success: true,
stdout,
stderr,
};
} catch (error) {
return {
success: false,
error: error.message,
stdout: error.stdout || "",
stderr: error.stderr || "",
};
}
}
/**
* Archive the project (for distribution)
*/
async archive(
scheme = null,
archivePath,
configuration = "Release"
) {
const schemeToUse = scheme || this.projectName;
try {
const { stdout, stderr } = await execAsync(
`xcodebuild archive -project "${this.projectPath}" -scheme "${schemeToUse}" -configuration ${configuration} -archivePath "${archivePath}" 2>&1`
);
return {
success: true,
stdout,
stderr,
archivePath,
};
} catch (error) {
return {
success: false,
error: error.message,
stdout: error.stdout || "",
stderr: error.stderr || "",
};
}
}
/**
* Remove a file from a target
*/
async removeFileFromTarget(filePath, targetName) {
const fullPath = join(this.projectDir, filePath);
try {
const project = await this.readProject();
// Check if target exists
const target = project.targets.find((t) => t.name === targetName);
if (!target) {
return {
success: false,
error: `Target '${targetName}' not found in project`,
};
}
// For file system synchronized groups, removing the file from disk is usually enough
// But we should note that the file reference might still exist in pbxproj
const fileExists = await fs
.access(fullPath)
.then(() => true)
.catch(() => false);
if (!fileExists) {
return {
success: true,
message: `File ${filePath} does not exist on disk. If it's still referenced in the project, you may need to remove it manually in Xcode.`,
filePath: fullPath,
targetName,
};
}
return {
success: true,
message: `For file system synchronized projects, deleting the file from disk should remove it from the project. For traditional projects, use Xcode GUI to remove the file reference.`,
filePath: fullPath,
targetName,
recommendation: "Delete the file from disk, then verify in Xcode that it's removed from the project.",
};
} catch (error) {
return {
success: false,
error: error.message,
filePath: fullPath,
targetName,
};
}
}
/**
* Get build errors from a build attempt
*/
async getBuildErrors(scheme = null) {
const schemeToUse = scheme || this.projectName;
try {
// Try to build and capture errors
const { stdout, stderr } = await execAsync(
`xcodebuild -project "${this.projectPath}" -scheme "${schemeToUse}" build 2>&1 || true`
);
const output = stdout + stderr;
const errors = this.parseBuildErrors(output);
return {
success: true,
errors,
hasErrors: errors.length > 0,
rawOutput: output,
};
} catch (error) {
// Even if build fails, we want to parse the errors
const output = (error.stdout || "") + (error.stderr || "");
const errors = this.parseBuildErrors(output);
return {
success: false,
errors,
hasErrors: errors.length > 0,
rawOutput: output,
error: error.message,
};
}
}
/**
* Parse build errors from xcodebuild output
*/
parseBuildErrors(output) {
const errors = [];
const lines = output.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Match error patterns
// Format: path/to/file.swift:line:column: error: message
const errorMatch = line.match(/^(.+):(\d+):(\d+):\s*(error|warning):\s*(.+)$/);
if (errorMatch) {
errors.push({
file: errorMatch[1],
line: parseInt(errorMatch[2]),
column: parseInt(errorMatch[3]),
type: errorMatch[4],
message: errorMatch[5],
});
}
// Also catch "error:" lines
if (line.includes("error:") && !errorMatch) {
errors.push({
type: "error",
message: line.trim(),
});
}
}
return errors;
}
/**
* List files in a target
*/
async listFilesInTarget(targetName) {
try {
const project = await this.readProject();
// Check if target exists
const target = project.targets.find((t) => t.name === targetName);
if (!target) {
return {
success: false,
error: `Target '${targetName}' not found in project`,
availableTargets: project.targets.map((t) => t.name),
};
}
// For file system synchronized projects, list files in the project directory
if (project.hasFileSystemGroups) {
const files = [];
const targetDir = join(this.projectDir, targetName);
// Recursive function to find Swift files
const findSwiftFiles = async (dir) => {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isFile() && entry.name.endsWith(".swift")) {
files.push(relative(this.projectDir, fullPath));
} else if (entry.isDirectory()) {
await findSwiftFiles(fullPath);
}
}
} catch {
// Skip directories we can't read
}
};
try {
await findSwiftFiles(targetDir);
} catch {
// Target directory might not exist
}
return {
success: true,
targetName,
files,
message: "Files listed from file system synchronized groups. For complete file list including resources, check Xcode project.",
};
}
return {
success: true,
targetName,
message: "For traditional projects, file listing requires parsing pbxproj file references. Use Xcode to view complete file list.",
targetId: target.id,
};
} catch (error) {
return {
success: false,
error: error.message,
targetName,
};
}
}
/**
* Validate project structure
*/
async validateProject() {
const issues = [];
const warnings = [];
try {
// Check if project file exists
try {
await fs.access(this.pbxprojPath);
} catch {
issues.push({
type: "error",
message: `Project file not found: ${this.pbxprojPath}`,
});
return {
success: false,
issues,
warnings,
};
}
// Read and parse project
const project = await this.readProject();
// Check for targets
if (project.targets.length === 0) {
issues.push({
type: "error",
message: "No targets found in project",
});
}
// Check for schemes
const schemesResult = await this.listSchemes();
if (!schemesResult.success || schemesResult.schemes.length === 0) {
warnings.push({
type: "warning",
message: "No build schemes found. Project may not build correctly.",
});
}
// Check if project uses file system synchronized groups
if (project.hasFileSystemGroups) {
warnings.push({
type: "info",
message: "Project uses file system synchronized groups. Files are automatically included based on directory structure.",
});
}
return {
success: issues.length === 0,
issues,
warnings,
projectInfo: {
targets: project.targets.map((t) => t.name),
schemes: schemesResult.schemes || [],
hasFileSystemGroups: project.hasFileSystemGroups,
},
};
} catch (error) {
return {
success: false,
issues: [
{
type: "error",
message: `Failed to validate project: ${error.message}`,
},
],
warnings,
};
}
}
}