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:
@@ -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)"]]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user