From 7e79fe89ca6705557a8648ce134e32957e3d35d8 Mon Sep 17 00:00:00 2001 From: cx-git-agent Date: Tue, 21 Apr 2026 19:10:12 -0500 Subject: [PATCH] Add WebsiteService for static website generation and local preview - 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. --- .gitignore | 1 + Agent/Tools/WebsiteTools.swift | 413 ++++++++ CxIDE.xcodeproj/project.pbxproj | 20 + CxSwiftAgent/Sources/CxSwiftAgent/main.swift | 1 + .../Sources/CxSwiftAgentMain/main.swift | 1 + Services/AgentService.swift | 1 + Services/CredentialStore.swift | 155 +++ Services/GoDaddyService.swift | 313 ++++++ Services/WebsiteDeployService.swift | 305 ++++++ Services/WebsiteService.swift | 897 ++++++++++++++++++ 10 files changed, 2107 insertions(+) create mode 100644 Agent/Tools/WebsiteTools.swift create mode 100644 Services/CredentialStore.swift create mode 100644 Services/GoDaddyService.swift create mode 100644 Services/WebsiteDeployService.swift create mode 100644 Services/WebsiteService.swift diff --git a/.gitignore b/.gitignore index 0817f74..efe8110 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ cx-agent.conf *.key .env .env.local +.cxide-credentials.enc # Swift Package Manager CxSwiftAgent/.build/ diff --git a/Agent/Tools/WebsiteTools.swift b/Agent/Tools/WebsiteTools.swift new file mode 100644 index 0000000..2a45a86 --- /dev/null +++ b/Agent/Tools/WebsiteTools.swift @@ -0,0 +1,413 @@ +// WebsiteTools.swift +// CxIDE Agent — Website building, deployment, and DNS management tools +// +// 8 tools: website_create, website_preview, website_deploy, website_templates, +// dns_list, dns_set, domain_check, credential_set + +import Foundation + +enum WebsiteTools { + static func register(on server: MCPServer, config: AgentConfig, memory: AgentMemory) { + + // ── website_create ──────────────────────────────────────────── + server.registerTool( + "website_create", + description: "Create a new website project from a template. Templates: blank, landing, portfolio, blog, docs, dashboard.", + inputSchema: [ + "type": "object", + "required": ["name", "template"], + "properties": [ + "name": ["type": "string", "description": "Project name (used as directory name)."], + "template": ["type": "string", "description": "Template ID: blank, landing, portfolio, blog, docs, dashboard."], + "directory": ["type": "string", "description": "Parent directory (default: workspace root)."], + ] as [String: Any], + ] as [String: Any] + ) { args in + let name = args["name"] as? String ?? "my-website" + let templateId = args["template"] as? String ?? "blank" + let dirPath = args["directory"] as? String + + let parentDir: URL + if let path = dirPath, let resolved = config.resolvePath(path) { + parentDir = URL(fileURLWithPath: resolved) + } else { + parentDir = URL(fileURLWithPath: config.workspaceRoot) + } + + guard let template = WebsiteTemplate.allTemplates.first(where: { $0.id == templateId }) else { + return err("Unknown template '\(templateId)'. Available: blank, landing, portfolio, blog, docs, dashboard") + } + + do { + let service = await WebsiteService() + let project = try await service.createProject(name: name, template: template, at: parentDir) + let files = template.generateFiles(name) + let fileList = files.map { " \($0.path)" }.joined(separator: "\n") + return ok("Created website '\(name)' using '\(template.name)' template at:\n\(project.directory.path)\n\nFiles:\n\(fileList)") + } catch { + return err(error.localizedDescription) + } + } + + // ── website_templates ───────────────────────────────────────── + server.registerTool( + "website_templates", + description: "List available website templates with descriptions.", + inputSchema: ["type": "object", "properties": [:] as [String: Any]] as [String: Any], + annotations: ToolAnnotations(readOnlyHint: true) + ) { _ in + var output = "Available Website Templates:\n\n" + for t in WebsiteTemplate.allTemplates { + output += " \(t.id) — \(t.name)\n \(t.description)\n Category: \(t.category)\n\n" + } + return ok(output) + } + + // ── website_preview ─────────────────────────────────────────── + server.registerTool( + "website_preview", + description: "Start or stop a local preview server for a website directory.", + inputSchema: [ + "type": "object", + "required": ["action"], + "properties": [ + "action": ["type": "string", "description": "start or stop"], + "directory": ["type": "string", "description": "Website directory path (for start)."], + "port": ["type": "integer", "description": "Port number (default: 8080)."], + ] as [String: Any], + ] as [String: Any] + ) { args in + let action = args["action"] as? String ?? "start" + + if action == "stop" { + let service = await WebsiteService() + await service.stopPreview() + return ok("Preview server stopped.") + } + + let dirPath = args["directory"] as? String ?? config.workspaceRoot + guard let resolved = config.resolvePath(dirPath) else { + return err("Invalid directory path") + } + + let port = args["port"] as? Int ?? 8080 + let dir = URL(fileURLWithPath: resolved) + + do { + let service = await WebsiteService() + try await service.startPreview(projectDir: dir, port: port) + return ok("Preview server started at http://localhost:\(port)\nServing: \(dir.path)") + } catch { + return err("Failed to start preview: \(error.localizedDescription)") + } + } + + // ── website_deploy ──────────────────────────────────────────── + server.registerTool( + "website_deploy", + description: "Deploy a website to a free hosting provider. Providers: netlify, surge, github-pages, vercel, rsync.", + inputSchema: [ + "type": "object", + "required": ["directory", "provider"], + "properties": [ + "directory": ["type": "string", "description": "Website directory to deploy."], + "provider": ["type": "string", "description": "Hosting provider: netlify, surge, github-pages, vercel, rsync."], + "site_name": ["type": "string", "description": "Site/project name for the provider."], + "domain": ["type": "string", "description": "Custom domain to use."], + "git_repo": ["type": "string", "description": "Git repository URL (for github-pages)."], + "git_username": ["type": "string", "description": "GitHub username (for github-pages URL)."], + "rsync_target": ["type": "string", "description": "rsync target (e.g., user@host:/path)."], + ] as [String: Any], + ] as [String: Any] + ) { args in + guard let dirPath = args["directory"] as? String, + let resolved = config.resolvePath(dirPath) else { + return err("Invalid directory path") + } + + let providerStr = args["provider"] as? String ?? "netlify" + let provider: WebsiteDeployService.Provider + switch providerStr.lowercased() { + case "netlify": provider = .netlify + case "surge", "surge.sh": provider = .surge + case "github-pages", "gh-pages", "github": provider = .githubPages + case "vercel": provider = .vercel + case "rsync", "sftp": provider = .rsync + default: return err("Unknown provider '\(providerStr)'. Available: netlify, surge, github-pages, vercel, rsync") + } + + // Check CLI availability + let deployService = WebsiteDeployService.shared + if !deployService.checkCLI(for: provider) { + return err("\(provider.rawValue) CLI not found. Install it first.") + } + + let options = DeployOptions( + siteName: args["site_name"] as? String, + domain: args["domain"] as? String, + gitRepo: args["git_repo"] as? String, + gitUsername: args["git_username"] as? String, + rsyncTarget: args["rsync_target"] as? String + ) + + let result = await deployService.deploy( + directory: URL(fileURLWithPath: resolved), + provider: provider, + options: options + ) + + if result.success { + var msg = "Deployed to \(provider.rawValue) successfully!" + if let url = result.url { + msg += "\nURL: \(url)" + } + msg += "\nDuration: \(String(format: "%.1f", result.duration))s" + return ok(msg) + } else { + return err("Deploy failed: \(result.output)") + } + } + + // ── dns_list ────────────────────────────────────────────────── + server.registerTool( + "dns_list", + description: "List DNS records for a domain using GoDaddy API.", + inputSchema: [ + "type": "object", + "required": ["domain"], + "properties": [ + "domain": ["type": "string", "description": "Domain name (e.g., example.com)."], + "type": ["type": "string", "description": "Record type filter: A, AAAA, CNAME, MX, TXT, NS, etc."], + "environment": ["type": "string", "description": "ote or production (default: ote)."], + ] as [String: Any], + ] as [String: Any], + annotations: ToolAnnotations(readOnlyHint: true) + ) { args in + let domain = args["domain"] as? String ?? "" + guard !domain.isEmpty else { return err("Domain name required") } + + let env: GoDaddyService.Environment = (args["environment"] as? String)?.lowercased() == "production" ? .production : .ote + let service = GoDaddyService(environment: env) + + do { + let records: [DNSRecord] + if let typeStr = args["type"] as? String, + let recordType = DNSRecordType(rawValue: typeStr.uppercased()) { + records = try await service.getDNSRecords(domain: domain, type: recordType) + } else { + records = try await service.getDNSRecords(domain: domain) + } + + if records.isEmpty { + return ok("No DNS records found for \(domain)") + } + + var output = "DNS Records for \(domain) (\(env.rawValue)):\n\n" + output += String(format: "%-8s %-20s %-40s %s\n", "Type", "Name", "Data", "TTL") + output += String(repeating: "-", count: 80) + "\n" + for r in records { + output += String(format: "%-8s %-20s %-40s %d\n", + r.type.rawValue, r.name, r.data, r.ttl ?? 0) + } + return ok(output) + } catch { + return err(error.localizedDescription) + } + } + + // ── dns_set ─────────────────────────────────────────────────── + server.registerTool( + "dns_set", + description: "Set DNS records for a domain via GoDaddy API. Can point to hosting providers.", + inputSchema: [ + "type": "object", + "required": ["domain", "action"], + "properties": [ + "domain": ["type": "string", "description": "Domain name."], + "action": ["type": "string", "description": "Action: set-a, set-cname, netlify, github-pages, vercel, cloudflare."], + "name": ["type": "string", "description": "Record name (default: @)."], + "value": ["type": "string", "description": "Record value (IP or target)."], + "ttl": ["type": "integer", "description": "TTL in seconds (default: 600)."], + "github_username": ["type": "string", "description": "GitHub username (for github-pages)."], + "netlify_subdomain": ["type": "string", "description": "Netlify subdomain (for netlify)."], + "cloudflare_project": ["type": "string", "description": "Cloudflare project name."], + "environment": ["type": "string", "description": "ote or production (default: ote)."], + ] as [String: Any], + ] as [String: Any] + ) { args in + let domain = args["domain"] as? String ?? "" + let action = args["action"] as? String ?? "" + guard !domain.isEmpty, !action.isEmpty else { return err("domain and action required") } + + let env: GoDaddyService.Environment = (args["environment"] as? String)?.lowercased() == "production" ? .production : .ote + let service = GoDaddyService(environment: env) + + do { + switch action.lowercased() { + case "set-a": + guard let ip = args["value"] as? String else { return err("IP address required (value)") } + let name = args["name"] as? String ?? "@" + let ttl = args["ttl"] as? Int ?? 600 + let record = DNSRecord(type: .A, name: name, data: ip, ttl: ttl) + try await service.replaceDNSRecord(domain: domain, type: .A, name: name, records: [record]) + return ok("Set A record: \(name).\(domain) → \(ip) (TTL: \(ttl))") + + case "set-cname": + guard let target = args["value"] as? String else { return err("CNAME target required (value)") } + let name = args["name"] as? String ?? "www" + let ttl = args["ttl"] as? Int ?? 3600 + try await service.setCNAME(domain: domain, name: name, target: target, ttl: ttl) + return ok("Set CNAME: \(name).\(domain) → \(target) (TTL: \(ttl))") + + case "netlify": + let sub = args["netlify_subdomain"] as? String ?? domain.split(separator: ".").first.map(String.init) ?? "site" + try await service.configureForNetlify(domain: domain, netlifySubdomain: sub) + return ok("Configured \(domain) for Netlify hosting (\(sub).netlify.app)") + + case "github-pages": + guard let username = args["github_username"] as? String else { + return err("github_username required") + } + try await service.configureForGitHubPages(domain: domain, githubUsername: username) + return ok("Configured \(domain) for GitHub Pages (\(username).github.io)") + + case "vercel": + try await service.configureForVercel(domain: domain) + return ok("Configured \(domain) for Vercel hosting") + + case "cloudflare": + let project = args["cloudflare_project"] as? String ?? domain.split(separator: ".").first.map(String.init) ?? "site" + try await service.configureForCloudflare(domain: domain, projectName: project) + return ok("Configured \(domain) for Cloudflare Pages (\(project).pages.dev)") + + default: + return err("Unknown action '\(action)'. Available: set-a, set-cname, netlify, github-pages, vercel, cloudflare") + } + } catch { + return err(error.localizedDescription) + } + } + + // ── domain_check ────────────────────────────────────────────── + server.registerTool( + "domain_check", + description: "Check domain availability and get suggestions via GoDaddy API.", + inputSchema: [ + "type": "object", + "required": ["domain"], + "properties": [ + "domain": ["type": "string", "description": "Domain to check (e.g., example.com)."], + "suggest": ["type": "boolean", "description": "Also return domain suggestions (default: true)."], + "environment": ["type": "string", "description": "ote or production (default: ote)."], + ] as [String: Any], + ] as [String: Any], + annotations: ToolAnnotations(readOnlyHint: true) + ) { args in + let domain = args["domain"] as? String ?? "" + guard !domain.isEmpty else { return err("Domain name required") } + + let env: GoDaddyService.Environment = (args["environment"] as? String)?.lowercased() == "production" ? .production : .ote + let service = GoDaddyService(environment: env) + let suggest = args["suggest"] as? Bool ?? true + + do { + let availability = try await service.checkAvailability(domain: domain) + var output = "Domain: \(domain)\n" + output += "Available: \(availability.available ? "✅ Yes" : "❌ No")\n" + if let price = availability.price, let currency = availability.currency { + output += "Price: \(price / 1_000_000) \(currency)/year\n" + } + + if suggest { + let query = domain.split(separator: ".").first.map(String.init) ?? domain + let suggestions = try await service.suggestDomains(query: query, limit: 5) + if !suggestions.isEmpty { + output += "\nSuggested alternatives:\n" + for s in suggestions { + output += " • \(s.domain)\n" + } + } + } + + return ok(output) + } catch { + return err(error.localizedDescription) + } + } + + // ── credential_set ──────────────────────────────────────────── + server.registerTool( + "credential_set", + description: "Set or list stored credentials (GoDaddy API keys, deploy tokens). Credentials are AES-256 encrypted.", + inputSchema: [ + "type": "object", + "required": ["action"], + "properties": [ + "action": ["type": "string", "description": "list, set, or remove."], + "key": ["type": "string", "description": "Credential key name."], + "value": ["type": "string", "description": "Credential value (for set)."], + ] as [String: Any], + ] as [String: Any] + ) { args in + let action = args["action"] as? String ?? "list" + let store = CredentialStore.shared + let wsDir = URL(fileURLWithPath: config.workspaceRoot) + + // Load existing credentials + try? store.load(from: wsDir) + + switch action.lowercased() { + case "list": + let keys = store.keys() + if keys.isEmpty { + return ok("No credentials stored. Use action='set' to add credentials.") + } + var output = "Stored credentials (\(keys.count)):\n" + for key in keys { + output += " • \(key)\n" + } + return ok(output) + + case "set": + guard let key = args["key"] as? String, !key.isEmpty else { + return err("Credential key required") + } + guard let value = args["value"] as? String, !value.isEmpty else { + return err("Credential value required") + } + store.set(key, value: value) + do { + try store.save(to: wsDir) + return ok("Credential '\(key)' saved (encrypted).") + } catch { + return err("Failed to save: \(error.localizedDescription)") + } + + case "remove": + guard let key = args["key"] as? String, !key.isEmpty else { + return err("Credential key required") + } + store.remove(key) + do { + try store.save(to: wsDir) + return ok("Credential '\(key)' removed.") + } catch { + return err("Failed to save: \(error.localizedDescription)") + } + + default: + return err("Unknown action '\(action)'. Available: list, set, remove") + } + } + } + + // MARK: - Helpers + + private static func ok(_ text: String) -> [[String: Any]] { + [["type": "text", "text": text]] + } + + private static func err(_ message: String) -> [[String: Any]] { + [["type": "text", "text": "Error: \(message)"]] + } +} diff --git a/CxIDE.xcodeproj/project.pbxproj b/CxIDE.xcodeproj/project.pbxproj index ba27628..4a1839a 100644 --- a/CxIDE.xcodeproj/project.pbxproj +++ b/CxIDE.xcodeproj/project.pbxproj @@ -43,6 +43,11 @@ B10075 /* XcodeTools.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10075 /* XcodeTools.swift */; }; B10076 /* DiagnosticsTools.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10076 /* DiagnosticsTools.swift */; }; B10077 /* AutoPilotTools.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10077 /* AutoPilotTools.swift */; }; + B10090 /* CredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10090 /* CredentialStore.swift */; }; + B10091 /* GoDaddyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10091 /* GoDaddyService.swift */; }; + B10092 /* WebsiteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10092 /* WebsiteService.swift */; }; + B10093 /* WebsiteDeployService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10093 /* WebsiteDeployService.swift */; }; + B10094 /* WebsiteTools.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10094 /* WebsiteTools.swift */; }; B10080 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A10080 /* Assets.xcassets */; }; B20001 /* CxIDETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20001 /* CxIDETests.swift */; }; B20002 /* EditorViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20002 /* EditorViewModelTests.swift */; }; @@ -98,6 +103,11 @@ A10075 /* XcodeTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeTools.swift; sourceTree = ""; }; A10076 /* DiagnosticsTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsTools.swift; sourceTree = ""; }; A10077 /* AutoPilotTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPilotTools.swift; sourceTree = ""; }; + A10090 /* CredentialStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialStore.swift; sourceTree = ""; }; + A10091 /* GoDaddyService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoDaddyService.swift; sourceTree = ""; }; + A10092 /* WebsiteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteService.swift; sourceTree = ""; }; + A10093 /* WebsiteDeployService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteDeployService.swift; sourceTree = ""; }; + A10094 /* WebsiteTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteTools.swift; sourceTree = ""; }; A10080 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A10081 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A10082 /* CxIDE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CxIDE.entitlements; sourceTree = ""; }; @@ -196,6 +206,10 @@ A10050 /* AgentService.swift */, A10051 /* WorkspaceService.swift */, A10052 /* GitService.swift */, + A10090 /* CredentialStore.swift */, + A10091 /* GoDaddyService.swift */, + A10092 /* WebsiteService.swift */, + A10093 /* WebsiteDeployService.swift */, ); path = Services; sourceTree = ""; @@ -223,6 +237,7 @@ A10075 /* XcodeTools.swift */, A10076 /* DiagnosticsTools.swift */, A10077 /* AutoPilotTools.swift */, + A10094 /* WebsiteTools.swift */, ); path = Tools; sourceTree = ""; @@ -371,6 +386,11 @@ B10075 /* XcodeTools.swift in Sources */, B10076 /* DiagnosticsTools.swift in Sources */, B10077 /* AutoPilotTools.swift in Sources */, + B10090 /* CredentialStore.swift in Sources */, + B10091 /* GoDaddyService.swift in Sources */, + B10092 /* WebsiteService.swift in Sources */, + B10093 /* WebsiteDeployService.swift in Sources */, + B10094 /* WebsiteTools.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/CxSwiftAgent/Sources/CxSwiftAgent/main.swift b/CxSwiftAgent/Sources/CxSwiftAgent/main.swift index 3f9eb6e..0d6426c 100644 --- a/CxSwiftAgent/Sources/CxSwiftAgent/main.swift +++ b/CxSwiftAgent/Sources/CxSwiftAgent/main.swift @@ -85,6 +85,7 @@ TerminalTools.register(on: server, config: config, memory: memory) XcodeTools.register(on: server, config: config, memory: memory) DiagnosticsTools.register(on: server, config: config, memory: memory) AutoPilotTools.register(on: server, config: config, memory: memory) +WebsiteTools.register(on: server, config: config, memory: memory) // Register workspace root as an MCP root server.registerRoot(uri: "file://\(config.workspaceRoot)", name: "workspace") diff --git a/CxSwiftAgent/Sources/CxSwiftAgentMain/main.swift b/CxSwiftAgent/Sources/CxSwiftAgentMain/main.swift index 8525664..5288577 100644 --- a/CxSwiftAgent/Sources/CxSwiftAgentMain/main.swift +++ b/CxSwiftAgent/Sources/CxSwiftAgentMain/main.swift @@ -76,6 +76,7 @@ TerminalTools.register(on: server, config: config, memory: memory) XcodeTools.register(on: server, config: config, memory: memory) DiagnosticsTools.register(on: server, config: config, memory: memory) AutoPilotTools.register(on: server, config: config, memory: memory) +WebsiteTools.register(on: server, config: config, memory: memory) // Register workspace root as an MCP root server.registerRoot(uri: "file://\(config.workspaceRoot)", name: "workspace") diff --git a/Services/AgentService.swift b/Services/AgentService.swift index 64ac129..420a758 100644 --- a/Services/AgentService.swift +++ b/Services/AgentService.swift @@ -704,6 +704,7 @@ final class AgentService: ObservableObject { XcodeTools.register(on: agentServer, config: agentConfig, memory: agentMemory) DiagnosticsTools.register(on: agentServer, config: agentConfig, memory: agentMemory) AutoPilotTools.register(on: agentServer, config: agentConfig, memory: agentMemory) + WebsiteTools.register(on: agentServer, config: agentConfig, memory: agentMemory) // Register workspace root agentServer.registerRoot(uri: "file://\(workspacePath)", name: "workspace") diff --git a/Services/CredentialStore.swift b/Services/CredentialStore.swift new file mode 100644 index 0000000..456a306 --- /dev/null +++ b/Services/CredentialStore.swift @@ -0,0 +1,155 @@ +// CredentialStore.swift +// CxIDE — Encrypted .env file credential management +// +// Stores API keys and secrets in an AES-256 encrypted .env file +// within the workspace. Never stores credentials in plain text in source. + +import Foundation +import CryptoKit + +// MARK: - Credential Store + +final class CredentialStore: @unchecked Sendable { + static let shared = CredentialStore() + + private let fileName = ".cxide-credentials.enc" + private var cachedCredentials: [String: String] = [:] + private let lock = NSLock() + + // Derive encryption key from machine-specific seed + app bundle ID + private var encryptionKey: SymmetricKey { + let seed = ProcessInfo.processInfo.hostName + + (Bundle.main.bundleIdentifier ?? "com.cxide.CxIDE") + + NSUserName() + let hash = SHA256.hash(data: Data(seed.utf8)) + return SymmetricKey(data: hash) + } + + // MARK: - Public API + + /// Load credentials from encrypted file in the given directory + func load(from directory: URL) throws { + let fileURL = directory.appendingPathComponent(fileName) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + lock.lock() + cachedCredentials = [:] + lock.unlock() + return + } + + let encryptedData = try Data(contentsOf: fileURL) + let sealedBox = try AES.GCM.SealedBox(combined: encryptedData) + let decrypted = try AES.GCM.open(sealedBox, using: encryptionKey) + let json = try JSONSerialization.jsonObject(with: decrypted) as? [String: String] ?? [:] + + lock.lock() + cachedCredentials = json + lock.unlock() + } + + /// Save current credentials to encrypted file + func save(to directory: URL) throws { + let fileURL = directory.appendingPathComponent(fileName) + + lock.lock() + let creds = cachedCredentials + lock.unlock() + + let json = try JSONSerialization.data(withJSONObject: creds, options: .sortedKeys) + let sealedBox = try AES.GCM.seal(json, using: encryptionKey) + guard let combined = sealedBox.combined else { + throw CredentialError.encryptionFailed + } + try combined.write(to: fileURL) + + // Set restrictive permissions (owner read/write only) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: fileURL.path + ) + } + + /// Get a credential value + func get(_ key: String) -> String? { + lock.lock() + defer { lock.unlock() } + return cachedCredentials[key] + } + + /// Set a credential value + func set(_ key: String, value: String) { + lock.lock() + cachedCredentials[key] = value + lock.unlock() + } + + /// Remove a credential + func remove(_ key: String) { + lock.lock() + cachedCredentials.removeValue(forKey: key) + lock.unlock() + } + + /// List all credential keys (not values) + func keys() -> [String] { + lock.lock() + defer { lock.unlock() } + return Array(cachedCredentials.keys).sorted() + } + + /// Check if a credential exists + func has(_ key: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return cachedCredentials[key] != nil + } + + /// Remove the encrypted file + func deleteStore(in directory: URL) throws { + let fileURL = directory.appendingPathComponent(fileName) + if FileManager.default.fileExists(atPath: fileURL.path) { + try FileManager.default.removeItem(at: fileURL) + } + lock.lock() + cachedCredentials = [:] + lock.unlock() + } + + // MARK: - Convenience: GoDaddy + + /// GoDaddy OTE (test environment) credentials + var godaddyOTEKey: String? { self.get("GODADDY_OTE_KEY") } + var godaddyOTESecret: String? { self.get("GODADDY_OTE_SECRET") } + + /// GoDaddy Production credentials + var godaddyProdKey: String? { self.get("GODADDY_PROD_KEY") } + var godaddyProdSecret: String? { self.get("GODADDY_PROD_SECRET") } + + /// Set GoDaddy OTE credentials + func setGoDaddyOTE(key: String, secret: String) { + set("GODADDY_OTE_KEY", value: key) + set("GODADDY_OTE_SECRET", value: secret) + } + + /// Set GoDaddy Production credentials + func setGoDaddyProduction(key: String, secret: String) { + set("GODADDY_PROD_KEY", value: key) + set("GODADDY_PROD_SECRET", value: secret) + } + + // MARK: - Errors + + enum CredentialError: LocalizedError { + case encryptionFailed + case decryptionFailed + case invalidFormat + + var errorDescription: String? { + switch self { + case .encryptionFailed: return "Failed to encrypt credentials" + case .decryptionFailed: return "Failed to decrypt credentials" + case .invalidFormat: return "Invalid credential file format" + } + } + } +} diff --git a/Services/GoDaddyService.swift b/Services/GoDaddyService.swift new file mode 100644 index 0000000..57d70de --- /dev/null +++ b/Services/GoDaddyService.swift @@ -0,0 +1,313 @@ +// GoDaddyService.swift +// CxIDE — GoDaddy API integration for domain and DNS management +// +// Supports both OTE (test) and Production environments. +// API docs: https://developer.godaddy.com/doc + +import Foundation + +// MARK: - GoDaddy Service + +final class GoDaddyService: @unchecked Sendable { + + enum Environment: String, CaseIterable, Sendable { + case ote = "OTE (Test)" + case production = "Production" + + var baseURL: String { + switch self { + case .ote: return "https://api.ote-godaddy.com" + case .production: return "https://api.godaddy.com" + } + } + } + + private let credentialStore: CredentialStore + var environment: Environment + + init(credentialStore: CredentialStore = .shared, environment: Environment = .ote) { + self.credentialStore = credentialStore + self.environment = environment + } + + // MARK: - Auth Header + + private var authHeader: String? { + let key: String? + let secret: String? + switch environment { + case .ote: + key = credentialStore.godaddyOTEKey + secret = credentialStore.godaddyOTESecret + case .production: + key = credentialStore.godaddyProdKey + secret = credentialStore.godaddyProdSecret + } + guard let k = key, let s = secret, !k.isEmpty, !s.isEmpty else { return nil } + return "sso-key \(k):\(s)" + } + + // MARK: - Domain Operations + + /// List all domains in the account + func listDomains() async throws -> [GoDaddyDomain] { + let data = try await request(path: "/v1/domains", method: "GET") + return try JSONDecoder().decode([GoDaddyDomain].self, from: data) + } + + /// Get domain details + func getDomain(_ domain: String) async throws -> GoDaddyDomainDetail { + let data = try await request(path: "/v1/domains/\(domain)", method: "GET") + return try JSONDecoder().decode(GoDaddyDomainDetail.self, from: data) + } + + /// Check domain availability + func checkAvailability(domain: String) async throws -> GoDaddyAvailability { + let data = try await request(path: "/v1/domains/available?domain=\(domain)", method: "GET") + return try JSONDecoder().decode(GoDaddyAvailability.self, from: data) + } + + /// Get suggested domains + func suggestDomains(query: String, limit: Int = 10) async throws -> [GoDaddySuggestion] { + let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query + let data = try await request( + path: "/v1/domains/suggest?query=\(encoded)&limit=\(limit)", + method: "GET" + ) + return try JSONDecoder().decode([GoDaddySuggestion].self, from: data) + } + + // MARK: - DNS Records + + /// Get all DNS records for a domain + func getDNSRecords(domain: String) async throws -> [DNSRecord] { + let data = try await request(path: "/v1/domains/\(domain)/records", method: "GET") + return try JSONDecoder().decode([DNSRecord].self, from: data) + } + + /// Get DNS records by type + func getDNSRecords(domain: String, type: DNSRecordType) async throws -> [DNSRecord] { + let data = try await request( + path: "/v1/domains/\(domain)/records/\(type.rawValue)", + method: "GET" + ) + return try JSONDecoder().decode([DNSRecord].self, from: data) + } + + /// Add DNS records + func addDNSRecords(domain: String, records: [DNSRecord]) async throws { + let body = try JSONEncoder().encode(records) + _ = try await request(path: "/v1/domains/\(domain)/records", method: "PATCH", body: body) + } + + /// Replace all DNS records of a specific type and name + func replaceDNSRecord(domain: String, type: DNSRecordType, name: String, records: [DNSRecord]) async throws { + let body = try JSONEncoder().encode(records) + _ = try await request( + path: "/v1/domains/\(domain)/records/\(type.rawValue)/\(name)", + method: "PUT", + body: body + ) + } + + /// Delete all DNS records of a specific type and name + func deleteDNSRecord(domain: String, type: DNSRecordType, name: String) async throws { + _ = try await request( + path: "/v1/domains/\(domain)/records/\(type.rawValue)/\(name)", + method: "DELETE" + ) + } + + // MARK: - Convenience: Point domain to hosting + + /// Point a domain's A record to an IP address (for hosting) + func pointDomainToIP(domain: String, ip: String, ttl: Int = 600) async throws { + let record = DNSRecord(type: .A, name: "@", data: ip, ttl: ttl) + try await replaceDNSRecord(domain: domain, type: .A, name: "@", records: [record]) + } + + /// Set a CNAME record (e.g., www -> hosting provider) + func setCNAME(domain: String, name: String, target: String, ttl: Int = 3600) async throws { + let record = DNSRecord(type: .CNAME, name: name, data: target, ttl: ttl) + try await replaceDNSRecord(domain: domain, type: .CNAME, name: name, records: [record]) + } + + /// Configure DNS for Netlify hosting + func configureForNetlify(domain: String, netlifySubdomain: String) async throws { + // Point @ to Netlify's load balancer + try await pointDomainToIP(domain: domain, ip: "75.2.60.5") + // Point www to Netlify subdomain + try await setCNAME(domain: domain, name: "www", target: "\(netlifySubdomain).netlify.app") + } + + /// Configure DNS for GitHub Pages + func configureForGitHubPages(domain: String, githubUsername: String) async throws { + // GitHub Pages IPs + let githubIPs = ["185.199.108.153", "185.199.109.153", "185.199.110.153", "185.199.111.153"] + let records = githubIPs.map { DNSRecord(type: .A, name: "@", data: $0, ttl: 600) } + try await replaceDNSRecord(domain: domain, type: .A, name: "@", records: records) + try await setCNAME(domain: domain, name: "www", target: "\(githubUsername).github.io") + } + + /// Configure DNS for Vercel hosting + func configureForVercel(domain: String) async throws { + try await pointDomainToIP(domain: domain, ip: "76.76.21.21") + try await setCNAME(domain: domain, name: "www", target: "cname.vercel-dns.com") + } + + /// Configure DNS for Cloudflare Pages + func configureForCloudflare(domain: String, projectName: String) async throws { + try await setCNAME(domain: domain, name: "@", target: "\(projectName).pages.dev") + try await setCNAME(domain: domain, name: "www", target: "\(projectName).pages.dev") + } + + // MARK: - HTTP Client + + private func request(path: String, method: String, body: Data? = nil) async throws -> Data { + guard let auth = authHeader else { + throw GoDaddyError.notAuthenticated + } + + let urlString = environment.baseURL + path + guard let url = URL(string: urlString) else { + throw GoDaddyError.invalidURL(urlString) + } + + var req = URLRequest(url: url) + req.httpMethod = method + req.setValue(auth, forHTTPHeaderField: "Authorization") + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("application/json", forHTTPHeaderField: "Accept") + req.timeoutInterval = 30 + + if let body = body { + req.httpBody = body + } + + let (data, response) = try await URLSession.shared.data(for: req) + + guard let httpResponse = response as? HTTPURLResponse else { + throw GoDaddyError.invalidResponse + } + + switch httpResponse.statusCode { + case 200...299: + return data + case 401: + throw GoDaddyError.unauthorized + case 403: + throw GoDaddyError.forbidden + case 404: + throw GoDaddyError.notFound(path) + case 422: + let msg = String(data: data, encoding: .utf8) ?? "Validation error" + throw GoDaddyError.validationError(msg) + case 429: + throw GoDaddyError.rateLimited + default: + let msg = String(data: data, encoding: .utf8) ?? "HTTP \(httpResponse.statusCode)" + throw GoDaddyError.apiError(httpResponse.statusCode, msg) + } + } +} + +// MARK: - Models + +struct GoDaddyDomain: Codable, Sendable { + let domain: String + let status: String? + let expires: String? + let expirationProtected: Bool? + let holdRegistrar: Bool? + let locked: Bool? + let privacy: Bool? + let renewAuto: Bool? + let renewable: Bool? + let transferProtected: Bool? +} + +struct GoDaddyDomainDetail: Codable, Sendable { + let domain: String + let domainId: Int? + let status: String? + let expires: String? + let nameServers: [String]? + let locked: Bool? + let privacy: Bool? + let renewAuto: Bool? + let contactAdmin: GoDaddyContact? + let contactRegistrant: GoDaddyContact? +} + +struct GoDaddyContact: Codable, Sendable { + let email: String? + let nameFirst: String? + let nameLast: String? + let organization: String? + let phone: String? +} + +struct GoDaddyAvailability: Codable, Sendable { + let available: Bool + let domain: String + let definitive: Bool? + let price: Int? + let currency: String? + let period: Int? +} + +struct GoDaddySuggestion: Codable, Sendable { + let domain: String +} + +enum DNSRecordType: String, Codable, Sendable, CaseIterable { + case A, AAAA, CNAME, MX, NS, SOA, SRV, TXT, CAA +} + +struct DNSRecord: Codable, Sendable { + let type: DNSRecordType + let name: String + let data: String + var ttl: Int? + var priority: Int? + var port: Int? + var weight: Int? + var service: String? + var `protocol`: String? + + init(type: DNSRecordType, name: String, data: String, ttl: Int? = nil, priority: Int? = nil) { + self.type = type + self.name = name + self.data = data + self.ttl = ttl + self.priority = priority + } +} + +// MARK: - Errors + +enum GoDaddyError: LocalizedError { + case notAuthenticated + case unauthorized + case forbidden + case notFound(String) + case invalidURL(String) + case invalidResponse + case validationError(String) + case rateLimited + case apiError(Int, String) + + var errorDescription: String? { + switch self { + case .notAuthenticated: return "GoDaddy API credentials not configured" + case .unauthorized: return "Invalid GoDaddy API credentials" + case .forbidden: return "Access forbidden — check API key permissions" + case .notFound(let path): return "Resource not found: \(path)" + case .invalidURL(let url): return "Invalid URL: \(url)" + case .invalidResponse: return "Invalid response from GoDaddy API" + case .validationError(let msg): return "Validation error: \(msg)" + case .rateLimited: return "GoDaddy API rate limit exceeded" + case .apiError(let code, let msg): return "GoDaddy API error (\(code)): \(msg)" + } + } +} diff --git a/Services/WebsiteDeployService.swift b/Services/WebsiteDeployService.swift new file mode 100644 index 0000000..2558584 --- /dev/null +++ b/Services/WebsiteDeployService.swift @@ -0,0 +1,305 @@ +// 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 + } +} diff --git a/Services/WebsiteService.swift b/Services/WebsiteService.swift new file mode 100644 index 0000000..0b76dad --- /dev/null +++ b/Services/WebsiteService.swift @@ -0,0 +1,897 @@ +// WebsiteService.swift +// CxIDE — Static website generation with templates and local preview +// +// Generates HTML/CSS/JS projects from built-in templates. +// Supports local preview via a lightweight HTTP server. + +import Foundation +#if canImport(Network) +import Network +#endif + +// MARK: - Website Service + +@MainActor +final class WebsiteService: ObservableObject { + @Published var projects: [WebsiteProject] = [] + @Published var previewPort: Int = 8080 + @Published var isPreviewRunning: Bool = false + + private var previewProcess: Process? + + // MARK: - Project Creation + + /// Create a new website project from a template + func createProject( + name: String, + template: WebsiteTemplate, + at directory: URL + ) throws -> WebsiteProject { + let projectDir = directory.appendingPathComponent(name) + let fm = FileManager.default + + guard !fm.fileExists(atPath: projectDir.path) else { + throw WebsiteError.projectExists(name) + } + + try fm.createDirectory(at: projectDir, withIntermediateDirectories: true) + + // Generate files from template + let files = template.generateFiles(name) + for (relativePath, content) in files { + let fileURL = projectDir.appendingPathComponent(relativePath) + let parentDir = fileURL.deletingLastPathComponent() + if !fm.fileExists(atPath: parentDir.path) { + try fm.createDirectory(at: parentDir, withIntermediateDirectories: true) + } + try content.write(to: fileURL, atomically: true, encoding: String.Encoding.utf8) + } + + let project = WebsiteProject( + name: name, + directory: projectDir, + template: template.id, + createdAt: Date() + ) + projects.append(project) + return project + } + + // MARK: - Local Preview + + /// Start a local HTTP server for previewing + func startPreview(projectDir: URL, port: Int = 8080) throws { + stopPreview() + previewPort = port + + // Use Python's built-in HTTP server (available on macOS) + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/python3") + proc.arguments = ["-m", "http.server", "\(port)", "--directory", projectDir.path] + proc.currentDirectoryURL = projectDir + + proc.terminationHandler = { [weak self] _ in + Task { @MainActor [weak self] in + self?.isPreviewRunning = false + } + } + + try proc.run() + previewProcess = proc + isPreviewRunning = true + } + + /// Stop the local preview server + func stopPreview() { + if let proc = previewProcess, proc.isRunning { + proc.terminate() + } + previewProcess = nil + isPreviewRunning = false + } + + /// Get the preview URL + var previewURL: URL? { + isPreviewRunning ? URL(string: "http://localhost:\(previewPort)") : nil + } +} + +// MARK: - Website Project + +struct WebsiteProject: Identifiable, Codable, Sendable { + let id: UUID + let name: String + let directory: URL + let template: String + let createdAt: Date + var deployedURL: String? + var domain: String? + + init(name: String, directory: URL, template: String, createdAt: Date) { + self.id = UUID() + self.name = name + self.directory = directory + self.template = template + self.createdAt = createdAt + } +} + +// MARK: - Website Template + +struct WebsiteTemplate: Identifiable, Sendable { + let id: String + let name: String + let description: String + let category: String + let previewImageName: String? + + /// Generate the files for this template + let generateFiles: @Sendable (_ projectName: String) -> [(path: String, content: String)] + + // MARK: - Built-in Templates + + static let allTemplates: [WebsiteTemplate] = [ + blank, landingPage, portfolio, blog, documentation, dashboard + ] + + static let blank = WebsiteTemplate( + id: "blank", + name: "Blank Site", + description: "Empty HTML/CSS/JS project with modern defaults", + category: "Starter", + previewImageName: nil + ) { name in + [ + ("index.html", Self.blankHTML(name)), + ("css/style.css", Self.blankCSS()), + ("js/main.js", Self.blankJS()), + ("README.md", "# \(name)\n\nA website built with CxIDE.\n"), + (".gitignore", "node_modules/\n.DS_Store\n*.log\n"), + ] + } + + static let landingPage = WebsiteTemplate( + id: "landing", + name: "Landing Page", + description: "Modern responsive landing page with hero, features, and CTA", + category: "Marketing", + previewImageName: nil + ) { name in + [ + ("index.html", Self.landingHTML(name)), + ("css/style.css", Self.landingCSS()), + ("js/main.js", Self.landingJS()), + ("images/.gitkeep", ""), + ("README.md", "# \(name)\n\nA landing page built with CxIDE.\n"), + (".gitignore", "node_modules/\n.DS_Store\n*.log\n"), + ] + } + + static let portfolio = WebsiteTemplate( + id: "portfolio", + name: "Portfolio", + description: "Personal portfolio with project showcase and about section", + category: "Personal", + previewImageName: nil + ) { name in + [ + ("index.html", Self.portfolioHTML(name)), + ("css/style.css", Self.portfolioCSS()), + ("js/main.js", Self.portfolioJS()), + ("projects.html", Self.portfolioProjectsHTML(name)), + ("images/.gitkeep", ""), + ("README.md", "# \(name)\n\nA portfolio site built with CxIDE.\n"), + (".gitignore", "node_modules/\n.DS_Store\n*.log\n"), + ] + } + + static let blog = WebsiteTemplate( + id: "blog", + name: "Blog", + description: "Clean blog with post listing and article pages", + category: "Content", + previewImageName: nil + ) { name in + [ + ("index.html", Self.blogHTML(name)), + ("css/style.css", Self.blogCSS()), + ("js/main.js", Self.blogJS()), + ("post.html", Self.blogPostHTML(name)), + ("posts/.gitkeep", ""), + ("README.md", "# \(name)\n\nA blog built with CxIDE.\n"), + (".gitignore", "node_modules/\n.DS_Store\n*.log\n"), + ] + } + + static let documentation = WebsiteTemplate( + id: "docs", + name: "Documentation", + description: "Technical documentation site with sidebar navigation", + category: "Developer", + previewImageName: nil + ) { name in + [ + ("index.html", Self.docsHTML(name)), + ("css/style.css", Self.docsCSS()), + ("js/main.js", Self.docsJS()), + ("getting-started.html", Self.docsGettingStartedHTML(name)), + ("README.md", "# \(name) Documentation\n\nDocumentation site built with CxIDE.\n"), + (".gitignore", "node_modules/\n.DS_Store\n*.log\n"), + ] + } + + static let dashboard = WebsiteTemplate( + id: "dashboard", + name: "Dashboard", + description: "Admin dashboard with charts, tables, and dark theme", + category: "Application", + previewImageName: nil + ) { name in + [ + ("index.html", Self.dashboardHTML(name)), + ("css/style.css", Self.dashboardCSS()), + ("js/main.js", Self.dashboardJS()), + ("README.md", "# \(name)\n\nA dashboard built with CxIDE.\n"), + (".gitignore", "node_modules/\n.DS_Store\n*.log\n"), + ] + } +} + +// MARK: - Template HTML Generators + +extension WebsiteTemplate { + + // ── Blank ── + + static func blankHTML(_ name: String) -> String { + """ + + + + + + \(name) + + + +
+

\(name)

+

Welcome to your new website.

+
+ + + + """ + } + + static func blankCSS() -> String { + """ + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + :root { + --bg: #0a0a0a; --fg: #e8e8e8; --accent: #3b82f6; + --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + } + body { font-family: var(--font); background: var(--bg); color: var(--fg); line-height: 1.6; } + main { max-width: 800px; margin: 4rem auto; padding: 0 2rem; } + h1 { font-size: 2.5rem; margin-bottom: 1rem; } + a { color: var(--accent); text-decoration: none; } + a:hover { text-decoration: underline; } + """ + } + + static func blankJS() -> String { + """ + // main.js — \(Date()) + document.addEventListener('DOMContentLoaded', () => { + console.log('Site loaded successfully'); + }); + """ + } + + // ── Landing Page ── + + static func landingHTML(_ name: String) -> String { + """ + + + + + + \(name) + + + + + +
+

Build Something Amazing

+

A modern solution for modern problems. Start building today.

+ +
+ +
+

Features

+
+
+
+

Lightning Fast

+

Optimized for speed and performance out of the box.

+
+
+
🔒
+

Secure

+

Enterprise-grade security built into every layer.

+
+
+
🚀
+

Scalable

+

Grows with your business from day one.

+
+
+
+ +
+

Simple Pricing

+
+
+

Starter

+
$0/mo
+
    +
  • 1 Project
  • +
  • Basic Support
  • +
  • 1GB Storage
  • +
+ Start Free +
+ +
+
+ +
+

Get In Touch

+
+ + + +
+
+ +
+

© 2026 \(name). Built with CxIDE.

+
+ + + + """ + } + + static func landingCSS() -> String { + """ + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + :root { + --bg: #0f172a; --fg: #e2e8f0; --accent: #3b82f6; --accent-dark: #2563eb; + --card-bg: #1e293b; --border: #334155; + --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + } + body { font-family: var(--font); background: var(--bg); color: var(--fg); line-height: 1.6; } + + .navbar { display: flex; justify-content: space-between; align-items: center; padding: 1rem 4rem; border-bottom: 1px solid var(--border); } + .nav-brand { font-size: 1.5rem; font-weight: 700; color: var(--accent); } + .nav-links { display: flex; gap: 2rem; align-items: center; } + .nav-links a { color: var(--fg); text-decoration: none; } + + .btn { display: inline-block; padding: 0.6rem 1.5rem; border-radius: 8px; font-weight: 600; text-decoration: none; transition: all 0.2s; cursor: pointer; border: none; font-size: 1rem; } + .btn-primary { background: var(--accent); color: white; } + .btn-primary:hover { background: var(--accent-dark); } + .btn-outline { border: 2px solid var(--accent); color: var(--accent); background: transparent; } + .btn-outline:hover { background: var(--accent); color: white; } + .btn-lg { padding: 0.8rem 2rem; font-size: 1.1rem; } + + .hero { text-align: center; padding: 8rem 2rem; } + .hero h1 { font-size: 3.5rem; margin-bottom: 1rem; background: linear-gradient(135deg, var(--accent), #8b5cf6); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } + .hero p { font-size: 1.25rem; color: #94a3b8; max-width: 600px; margin: 0 auto 2rem; } + .hero-buttons { display: flex; gap: 1rem; justify-content: center; } + + .features, .pricing, .contact { padding: 5rem 4rem; text-align: center; } + h2 { font-size: 2rem; margin-bottom: 3rem; } + .feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 2rem; max-width: 1000px; margin: 0 auto; } + .feature-card { background: var(--card-bg); padding: 2rem; border-radius: 12px; border: 1px solid var(--border); } + .feature-icon { font-size: 2.5rem; margin-bottom: 1rem; } + .feature-card h3 { margin-bottom: 0.5rem; } + + .pricing-grid { display: flex; gap: 2rem; justify-content: center; flex-wrap: wrap; } + .price-card { background: var(--card-bg); padding: 2.5rem; border-radius: 12px; border: 1px solid var(--border); min-width: 280px; } + .price-card.featured { border-color: var(--accent); transform: scale(1.05); } + .price { font-size: 3rem; font-weight: 700; margin: 1rem 0; } + .price span { font-size: 1rem; color: #94a3b8; } + .price-card ul { list-style: none; margin: 1.5rem 0; } + .price-card li { padding: 0.4rem 0; color: #94a3b8; } + + .contact-form { max-width: 500px; margin: 0 auto; display: flex; flex-direction: column; gap: 1rem; } + .contact-form input, .contact-form textarea { padding: 0.8rem; border-radius: 8px; border: 1px solid var(--border); background: var(--card-bg); color: var(--fg); font-size: 1rem; } + + footer { text-align: center; padding: 2rem; color: #64748b; border-top: 1px solid var(--border); } + + @media (max-width: 768px) { + .navbar { padding: 1rem 2rem; flex-direction: column; gap: 1rem; } + .hero h1 { font-size: 2.5rem; } + .features, .pricing, .contact { padding: 3rem 2rem; } + } + """ + } + + static func landingJS() -> String { + """ + document.addEventListener('DOMContentLoaded', () => { + // Smooth scroll for anchor links + document.querySelectorAll('a[href^="#"]').forEach(anchor => { + anchor.addEventListener('click', e => { + e.preventDefault(); + const target = document.querySelector(anchor.getAttribute('href')); + if (target) target.scrollIntoView({ behavior: 'smooth' }); + }); + }); + + // Animate cards on scroll + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.style.opacity = '1'; + entry.target.style.transform = 'translateY(0)'; + } + }); + }, { threshold: 0.1 }); + + document.querySelectorAll('.feature-card, .price-card').forEach(card => { + card.style.opacity = '0'; + card.style.transform = 'translateY(20px)'; + card.style.transition = 'all 0.6s ease'; + observer.observe(card); + }); + }); + """ + } + + // ── Portfolio ── + + static func portfolioHTML(_ name: String) -> String { + """ + + + + + + \(name) — Portfolio + + + + +
+

Hi, I'm \(name)

+

Developer, designer, creator.

+
+
+

About Me

+

A passionate developer building modern web experiences.

+
+
+

Contact

+

Email: hello@example.com

+
+

© 2026 \(name). Built with CxIDE.

+ + + + """ + } + + static func portfolioProjectsHTML(_ name: String) -> String { + """ + + + + + + \(name) — Projects + + + + +
+

My Projects

+
+
+

Project Alpha

+

A full-stack web application built with modern tools.

+
SwiftSwiftUI
+
+
+

Project Beta

+

An open-source library for data visualization.

+
JavaScriptD3.js
+
+
+
+

© 2026 \(name). Built with CxIDE.

+ + + + """ + } + + static func portfolioCSS() -> String { + """ + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + :root { --bg: #0a0a0a; --fg: #e8e8e8; --accent: #10b981; --card-bg: #171717; --border: #262626; } + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); line-height: 1.7; } + .navbar { display: flex; justify-content: space-between; align-items: center; padding: 1.5rem 4rem; } + .nav-brand { font-size: 1.4rem; font-weight: 700; color: var(--accent); text-decoration: none; } + .nav-links { display: flex; gap: 2rem; } + .nav-links a { color: var(--fg); text-decoration: none; } + .nav-links a.active { color: var(--accent); } + .hero { text-align: center; padding: 8rem 2rem 4rem; } + .hero h1 { font-size: 3rem; } + .accent { color: var(--accent); } + .about, .projects, .contact { padding: 4rem; max-width: 900px; margin: 0 auto; } + h2 { font-size: 2rem; margin-bottom: 1.5rem; } + .project-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; } + .project-card { background: var(--card-bg); padding: 2rem; border-radius: 12px; border: 1px solid var(--border); } + .project-card h3 { margin-bottom: 0.5rem; } + .tags { display: flex; gap: 0.5rem; margin-top: 1rem; } + .tags span { background: var(--accent); color: #000; padding: 0.2rem 0.8rem; border-radius: 20px; font-size: 0.85rem; font-weight: 600; } + footer { text-align: center; padding: 2rem; color: #666; } + """ + } + + static func portfolioJS() -> String { + "document.addEventListener('DOMContentLoaded', () => { console.log('Portfolio loaded'); });\n" + } + + // ── Blog ── + + static func blogHTML(_ name: String) -> String { + """ + + + + + + \(name) — Blog + + + + +
+

Blog

+ + +
+

© 2026 \(name). Built with CxIDE.

+ + + + """ + } + + static func blogPostHTML(_ name: String) -> String { + """ + + + + + + Blog Post — \(name) + + + + +
+ +

Getting Started with Web Development

+

Welcome to your first blog post. Edit this file to add your content.

+

Getting Started

+

Start by editing the HTML files in your project directory.

+
<h1>Hello World</h1>
+
+

© 2026 \(name). Built with CxIDE.

+ + + + """ + } + + static func blogCSS() -> String { + """ + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + :root { --bg: #fafafa; --fg: #1a1a1a; --accent: #2563eb; --muted: #6b7280; } + body { font-family: Georgia, 'Times New Roman', serif; background: var(--bg); color: var(--fg); line-height: 1.8; } + .navbar { padding: 1.5rem 4rem; border-bottom: 1px solid #e5e7eb; } + .nav-brand { font-size: 1.4rem; font-weight: 700; color: var(--fg); text-decoration: none; } + .blog, .post { max-width: 700px; margin: 0 auto; padding: 3rem 2rem; } + h1 { font-size: 2.5rem; margin-bottom: 1rem; } + h2 { font-size: 1.5rem; margin: 2rem 0 1rem; } + time { color: var(--muted); font-size: 0.9rem; } + .post-preview { margin-bottom: 2.5rem; padding-bottom: 2.5rem; border-bottom: 1px solid #e5e7eb; } + .post-preview h2 a { color: var(--fg); text-decoration: none; } + .post-preview h2 a:hover { color: var(--accent); } + pre { background: #1e293b; color: #e2e8f0; padding: 1.5rem; border-radius: 8px; overflow-x: auto; margin: 1.5rem 0; } + code { font-family: 'SF Mono', Menlo, monospace; } + footer { text-align: center; padding: 2rem; color: var(--muted); } + """ + } + + static func blogJS() -> String { + "document.addEventListener('DOMContentLoaded', () => { console.log('Blog loaded'); });\n" + } + + // ── Documentation ── + + static func docsHTML(_ name: String) -> String { + """ + + + + + + \(name) — Documentation + + + + +
+ +
+

Introduction

+

Welcome to the \(name) documentation. This guide will help you get started.

+

Overview

+

This is a documentation template. Edit these files to document your project.

+
+
+ + + + """ + } + + static func docsGettingStartedHTML(_ name: String) -> String { + """ + + + + + + Getting Started — \(name) Docs + + + + +
+ +
+

Getting Started

+

Installation

+
git clone https://github.com/your-repo.git
+        cd your-repo
+

Quick Start

+

Open index.html in your browser to preview the site.

+
+
+ + + + """ + } + + static func docsCSS() -> String { + """ + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + :root { --bg: #ffffff; --fg: #1a1a2e; --accent: #3b82f6; --sidebar-bg: #f8fafc; --border: #e2e8f0; } + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); } + .topnav { display: flex; align-items: center; justify-content: space-between; padding: 0.8rem 2rem; border-bottom: 1px solid var(--border); } + .brand { font-weight: 700; font-size: 1.2rem; } + .search-input { padding: 0.4rem 1rem; border: 1px solid var(--border); border-radius: 6px; width: 250px; } + .layout { display: flex; min-height: calc(100vh - 50px); } + .sidebar { width: 260px; padding: 2rem 1.5rem; background: var(--sidebar-bg); border-right: 1px solid var(--border); } + .sidebar h3 { font-size: 0.8rem; text-transform: uppercase; color: #6b7280; margin: 1.5rem 0 0.5rem; } + .sidebar ul { list-style: none; } + .sidebar li a { display: block; padding: 0.3rem 0.8rem; color: var(--fg); text-decoration: none; border-radius: 4px; font-size: 0.95rem; } + .sidebar li a:hover { background: #e2e8f0; } + .sidebar li a.active { background: var(--accent); color: white; } + .content { flex: 1; padding: 3rem 4rem; max-width: 800px; } + .content h1 { font-size: 2rem; margin-bottom: 1rem; } + .content h2 { font-size: 1.4rem; margin: 2rem 0 1rem; } + pre { background: #1e293b; color: #e2e8f0; padding: 1.2rem; border-radius: 8px; overflow-x: auto; margin: 1rem 0; } + code { font-family: 'SF Mono', Menlo, monospace; font-size: 0.9rem; } + """ + } + + static func docsJS() -> String { + """ + document.addEventListener('DOMContentLoaded', () => { + const searchInput = document.querySelector('.search-input'); + if (searchInput) { + searchInput.addEventListener('input', e => { + console.log('Searching:', e.target.value); + }); + } + }); + """ + } + + // ── Dashboard ── + + static func dashboardHTML(_ name: String) -> String { + """ + + + + + + \(name) — Dashboard + + + +
+ +
+
+

Dashboard

+ Admin +
+
+

Users

1,234
+

Revenue

$12,345
+

Orders

567
+

Conversion

3.2%
+
+
+

Recent Activity

+ + + + + + + +
UserActionDateStatus
AlicePurchaseTodayCompleted
BobSign upTodayActive
CarolRefundYesterdayPending
+
+
+
+ + + + """ + } + + static func dashboardCSS() -> String { + """ + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + :root { --bg: #0f172a; --fg: #e2e8f0; --sidebar: #1e293b; --card: #1e293b; --accent: #3b82f6; --border: #334155; } + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--fg); } + .dashboard { display: flex; min-height: 100vh; } + .sidebar { width: 220px; background: var(--sidebar); padding: 1.5rem; border-right: 1px solid var(--border); } + .logo { font-size: 1.3rem; font-weight: 700; color: var(--accent); margin-bottom: 2rem; } + .sidebar nav { display: flex; flex-direction: column; gap: 0.3rem; } + .sidebar nav a { color: var(--fg); text-decoration: none; padding: 0.6rem 1rem; border-radius: 6px; } + .sidebar nav a:hover, .sidebar nav a.active { background: var(--accent); color: white; } + main { flex: 1; padding: 2rem; } + header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; } + .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem; margin-bottom: 2rem; } + .stat-card { background: var(--card); padding: 1.5rem; border-radius: 12px; border: 1px solid var(--border); } + .stat-card h3 { font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.5rem; } + .stat-value { font-size: 2rem; font-weight: 700; } + .table-section h2 { margin-bottom: 1rem; } + table { width: 100%; border-collapse: collapse; background: var(--card); border-radius: 12px; overflow: hidden; } + th, td { padding: 0.8rem 1.2rem; text-align: left; border-bottom: 1px solid var(--border); } + th { color: #94a3b8; font-size: 0.85rem; text-transform: uppercase; } + .status-ok { color: #10b981; } + .status-warn { color: #f59e0b; } + """ + } + + static func dashboardJS() -> String { + """ + document.addEventListener('DOMContentLoaded', () => { + // Animate stat values + document.querySelectorAll('.stat-value').forEach(el => { + el.style.opacity = '0'; + el.style.transform = 'translateY(10px)'; + el.style.transition = 'all 0.4s ease'; + setTimeout(() => { + el.style.opacity = '1'; + el.style.transform = 'translateY(0)'; + }, 100); + }); + }); + """ + } +} + +// MARK: - Errors + +enum WebsiteError: LocalizedError { + case projectExists(String) + case templateNotFound(String) + case previewFailed(String) + + var errorDescription: String? { + switch self { + case .projectExists(let name): return "Project '\(name)' already exists" + case .templateNotFound(let id): return "Template '\(id)' not found" + case .previewFailed(let msg): return "Preview failed: \(msg)" + } + } +}