// WebsiteDeployService.swift // CxIDE — Deploy websites to free hosting providers // // Supports: Netlify (drop), Surge.sh, GitHub Pages, Vercel, // and generic SFTP/rsync deployment. import Foundation // MARK: - Website Deploy Service final class WebsiteDeployService: @unchecked Sendable { static let shared = WebsiteDeployService() enum Provider: String, CaseIterable, Sendable { case netlify = "Netlify" case surge = "Surge.sh" case githubPages = "GitHub Pages" case vercel = "Vercel" case rsync = "rsync/SFTP" var description: String { switch self { case .netlify: return "Free hosting with custom domains, HTTPS, and CI/CD" case .surge: return "Free static hosting with custom domains" case .githubPages: return "Free hosting from a GitHub repository" case .vercel: return "Free serverless hosting with edge network" case .rsync: return "Deploy via rsync or SFTP to any server" } } var requiresCLI: String? { switch self { case .netlify: return "netlify" case .surge: return "surge" case .vercel: return "vercel" case .githubPages: return "git" case .rsync: return "rsync" } } } struct DeployResult: Sendable { let success: Bool let url: String? let output: String let provider: Provider let duration: TimeInterval } // MARK: - Deploy /// Deploy a website directory to a hosting provider func deploy( directory: URL, provider: Provider, options: DeployOptions = DeployOptions() ) async -> DeployResult { let start = Date() // Verify the directory exists and has an index.html let fm = FileManager.default guard fm.fileExists(atPath: directory.path) else { return DeployResult(success: false, url: nil, output: "Directory not found: \(directory.path)", provider: provider, duration: 0) } let hasIndex = fm.fileExists(atPath: directory.appendingPathComponent("index.html").path) if !hasIndex { return DeployResult(success: false, url: nil, output: "No index.html found in \(directory.path)", provider: provider, duration: 0) } let result: DeployResult switch provider { case .netlify: result = await deployToNetlify(directory: directory, options: options, start: start) case .surge: result = await deployToSurge(directory: directory, options: options, start: start) case .githubPages: result = await deployToGitHubPages(directory: directory, options: options, start: start) case .vercel: result = await deployToVercel(directory: directory, options: options, start: start) case .rsync: result = await deployViaRsync(directory: directory, options: options, start: start) } return result } // MARK: - Check CLI availability func checkCLI(for provider: Provider) -> Bool { guard let cli = provider.requiresCLI else { return true } let result = shell("which \(cli)") return result.exitCode == 0 } /// Install a CLI tool via npm (for netlify, surge, vercel) func installCLI(for provider: Provider) async -> (success: Bool, output: String) { switch provider { case .netlify: let r = shell("npm install -g netlify-cli"); return (r.exitCode == 0, r.output) case .surge: let r = shell("npm install -g surge"); return (r.exitCode == 0, r.output) case .vercel: let r = shell("npm install -g vercel"); return (r.exitCode == 0, r.output) default: return (true, "No installation needed for \(provider.rawValue)") } } // MARK: - Provider Implementations private func deployToNetlify(directory: URL, options: DeployOptions, start: Date) async -> DeployResult { // Check if netlify CLI is available guard checkCLI(for: .netlify) else { return DeployResult(success: false, url: nil, output: "Netlify CLI not found. Install with: npm install -g netlify-cli", provider: .netlify, duration: Date().timeIntervalSince(start)) } // Deploy with netlify deploy var cmd = "cd \(shellEscape(directory.path)) && netlify deploy --dir=. --prod" if let siteName = options.siteName { cmd += " --site=\(siteName)" } let result = shell(cmd) let duration = Date().timeIntervalSince(start) // Extract URL from output let url = extractURL(from: result.output, pattern: "https://.*\\.netlify\\.app") return DeployResult( success: result.exitCode == 0, url: url, output: result.output, provider: .netlify, duration: duration ) } private func deployToSurge(directory: URL, options: DeployOptions, start: Date) async -> DeployResult { guard checkCLI(for: .surge) else { return DeployResult(success: false, url: nil, output: "Surge CLI not found. Install with: npm install -g surge", provider: .surge, duration: Date().timeIntervalSince(start)) } let domain = options.domain ?? "\(options.siteName ?? "cxide-site").surge.sh" let cmd = "cd \(shellEscape(directory.path)) && surge . \(domain)" let result = shell(cmd) let duration = Date().timeIntervalSince(start) return DeployResult( success: result.exitCode == 0, url: result.exitCode == 0 ? "https://\(domain)" : nil, output: result.output, provider: .surge, duration: duration ) } private func deployToGitHubPages(directory: URL, options: DeployOptions, start: Date) async -> DeployResult { guard checkCLI(for: .githubPages) else { return DeployResult(success: false, url: nil, output: "Git not found.", provider: .githubPages, duration: Date().timeIntervalSince(start)) } guard let repo = options.gitRepo else { return DeployResult(success: false, url: nil, output: "GitHub repository URL required (options.gitRepo)", provider: .githubPages, duration: Date().timeIntervalSince(start)) } let cmds = [ "cd \(shellEscape(directory.path))", "git init", "git checkout -b gh-pages", "git add -A", "git commit -m 'Deploy to GitHub Pages'", "git remote add origin \(repo)", "git push -f origin gh-pages", ].joined(separator: " && ") let result = shell(cmds) let duration = Date().timeIntervalSince(start) // Extract username/repo from URL let url: String? if let username = options.gitUsername { let repoName = URL(string: repo)?.lastPathComponent.replacingOccurrences(of: ".git", with: "") ?? "" url = "https://\(username).github.io/\(repoName)" } else { url = nil } return DeployResult( success: result.exitCode == 0, url: url, output: result.output, provider: .githubPages, duration: duration ) } private func deployToVercel(directory: URL, options: DeployOptions, start: Date) async -> DeployResult { guard checkCLI(for: .vercel) else { return DeployResult(success: false, url: nil, output: "Vercel CLI not found. Install with: npm install -g vercel", provider: .vercel, duration: Date().timeIntervalSince(start)) } let cmd = "cd \(shellEscape(directory.path)) && vercel --prod --yes" let result = shell(cmd) let duration = Date().timeIntervalSince(start) let url = extractURL(from: result.output, pattern: "https://.*\\.vercel\\.app") return DeployResult( success: result.exitCode == 0, url: url, output: result.output, provider: .vercel, duration: duration ) } private func deployViaRsync(directory: URL, options: DeployOptions, start: Date) async -> DeployResult { guard let target = options.rsyncTarget else { return DeployResult(success: false, url: nil, output: "rsync target required (e.g., user@host:/var/www/html/)", provider: .rsync, duration: Date().timeIntervalSince(start)) } let cmd = "rsync -avz --delete \(shellEscape(directory.path))/ \(target)" let result = shell(cmd) let duration = Date().timeIntervalSince(start) return DeployResult( success: result.exitCode == 0, url: options.domain.map { "https://\($0)" }, output: result.output, provider: .rsync, duration: duration ) } // MARK: - Helpers private func shell(_ command: String) -> (output: String, exitCode: Int32) { let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") proc.arguments = ["-c", command] let outPipe = Pipe() let errPipe = Pipe() proc.standardOutput = outPipe proc.standardError = errPipe do { try proc.run() proc.waitUntilExit() let outData = outPipe.fileHandleForReading.readDataToEndOfFile() let errData = errPipe.fileHandleForReading.readDataToEndOfFile() let output = (String(data: outData, encoding: .utf8) ?? "") + (String(data: errData, encoding: .utf8) ?? "") return (output.trimmingCharacters(in: .whitespacesAndNewlines), proc.terminationStatus) } catch { return ("Error: \(error.localizedDescription)", -1) } } private func shellEscape(_ path: String) -> String { "'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'" } private func extractURL(from text: String, pattern: String) -> String? { guard let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), let range = Range(match.range, in: text) else { return nil } return String(text[range]) } } // MARK: - Deploy Options struct DeployOptions: Sendable { var siteName: String? var domain: String? var gitRepo: String? var gitUsername: String? var rsyncTarget: String? var netlifyToken: String? init(siteName: String? = nil, domain: String? = nil, gitRepo: String? = nil, gitUsername: String? = nil, rsyncTarget: String? = nil, netlifyToken: String? = nil) { self.siteName = siteName self.domain = domain self.gitRepo = gitRepo self.gitUsername = gitUsername self.rsyncTarget = rsyncTarget self.netlifyToken = netlifyToken } }