7e79fe89ca
- Implemented WebsiteService to create and manage website projects from templates. - Added functionality to start and stop a local HTTP server for project previews. - Defined multiple built-in website templates (blank, landing page, portfolio, blog, documentation, dashboard) with corresponding HTML, CSS, and JS generation methods. - Introduced WebsiteProject and WebsiteTemplate models to encapsulate project data and template details. - Included error handling for project creation and template management.
306 lines
11 KiB
Swift
306 lines
11 KiB
Swift
// 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
|
|
}
|
|
}
|