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.
This commit is contained in:
cx-git-agent
2026-04-21 19:10:12 -05:00
parent b9bbc5034d
commit 7e79fe89ca
10 changed files with 2107 additions and 0 deletions
+413
View File
@@ -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)"]]
}
}