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