feat(cli): add deploy command for Synor Hosting
Add `synor deploy` command group for deploying web applications: - `synor deploy push` - upload project to Synor Hosting - `synor deploy init` - create synor.json configuration - `synor deploy list` - list all deployments - `synor deploy delete` - remove a deployment Features: - synor.json configuration parsing with build/routes/headers - Framework auto-detection (Next.js, Vite, Astro, Angular, SvelteKit) - Build command execution with install support - Multipart file upload to storage gateway - Deployment name validation with reserved word protection - Content type detection for 20+ MIME types Also adds Phase 9 milestone documentation and marks Synor Hosting as 100% complete in the roadmap.
This commit is contained in:
parent
d13597b67e
commit
c829362729
8 changed files with 1101 additions and 5 deletions
|
|
@ -56,7 +56,7 @@ aes-gcm = "0.10"
|
|||
argon2 = "0.5"
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
reqwest = { version = "0.11", features = ["json", "multipart"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
751
apps/cli/src/commands/deploy.rs
Normal file
751
apps/cli/src/commands/deploy.rs
Normal file
|
|
@ -0,0 +1,751 @@
|
|||
//! Deploy commands for Synor Hosting.
|
||||
//!
|
||||
//! Handles deploying web applications to Synor's decentralized hosting.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
/// Deploy configuration from synor.json
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SynorJson {
|
||||
/// Name of the deployment
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// Build configuration
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub build: Option<BuildConfig>,
|
||||
|
||||
/// Routes configuration
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub routes: Option<RoutesConfig>,
|
||||
|
||||
/// Headers configuration
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<Vec<HeaderRule>>,
|
||||
|
||||
/// Redirects configuration
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub redirects: Option<Vec<RedirectRule>>,
|
||||
|
||||
/// Custom error pages
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_pages: Option<HashMap<u16, String>>,
|
||||
}
|
||||
|
||||
/// Build configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BuildConfig {
|
||||
/// Build command
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub command: Option<String>,
|
||||
|
||||
/// Output directory (default: "dist" or "build")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<String>,
|
||||
|
||||
/// Install command (default: "npm install" or "pnpm install")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub install: Option<String>,
|
||||
}
|
||||
|
||||
/// Routes configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoutesConfig {
|
||||
/// Enable SPA mode (fallback to index.html)
|
||||
#[serde(default)]
|
||||
pub spa: bool,
|
||||
|
||||
/// Custom cleanUrls (remove .html extensions)
|
||||
#[serde(default)]
|
||||
pub clean_urls: bool,
|
||||
}
|
||||
|
||||
/// Header rule
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HeaderRule {
|
||||
pub source: String,
|
||||
pub headers: Vec<HeaderKeyValue>,
|
||||
}
|
||||
|
||||
/// Single header key-value pair
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HeaderKeyValue {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Redirect rule
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RedirectRule {
|
||||
pub source: String,
|
||||
pub destination: String,
|
||||
#[serde(default = "default_redirect_status")]
|
||||
pub status: u16,
|
||||
}
|
||||
|
||||
fn default_redirect_status() -> u16 {
|
||||
308
|
||||
}
|
||||
|
||||
/// File to be uploaded
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DeployFile {
|
||||
/// Relative path from output directory
|
||||
pub path: String,
|
||||
/// File size in bytes
|
||||
pub size: u64,
|
||||
/// Content type
|
||||
pub content_type: String,
|
||||
}
|
||||
|
||||
/// Deployment result
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DeployResult {
|
||||
/// Deployment name
|
||||
pub name: String,
|
||||
/// Content ID (CID) of the deployment
|
||||
pub cid: String,
|
||||
/// URL to access the deployment
|
||||
pub url: String,
|
||||
/// Number of files deployed
|
||||
pub file_count: usize,
|
||||
/// Total size in bytes
|
||||
pub total_size: u64,
|
||||
}
|
||||
|
||||
impl SynorJson {
|
||||
/// Load from file path
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
serde_json::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse {}", path.display()))
|
||||
}
|
||||
|
||||
/// Find synor.json in current directory or parent directories
|
||||
pub fn find(start: &Path) -> Option<PathBuf> {
|
||||
let mut current = start.to_path_buf();
|
||||
loop {
|
||||
let config_path = current.join("synor.json");
|
||||
if config_path.exists() {
|
||||
return Some(config_path);
|
||||
}
|
||||
if !current.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the output directory
|
||||
pub fn output_dir(&self) -> &str {
|
||||
self.build
|
||||
.as_ref()
|
||||
.and_then(|b| b.output.as_deref())
|
||||
.unwrap_or("dist")
|
||||
}
|
||||
}
|
||||
|
||||
/// Deploy a project to Synor Hosting.
|
||||
pub async fn deploy(
|
||||
name: Option<String>,
|
||||
output_dir: Option<PathBuf>,
|
||||
hosting_gateway: &str,
|
||||
skip_build: bool,
|
||||
format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
|
||||
// Find and load synor.json
|
||||
let config_path = SynorJson::find(&cwd);
|
||||
let config = if let Some(path) = &config_path {
|
||||
output::print_info(&format!("Found configuration: {}", path.display()));
|
||||
SynorJson::load(path)?
|
||||
} else {
|
||||
output::print_warning("No synor.json found, using defaults");
|
||||
SynorJson::default()
|
||||
};
|
||||
|
||||
// Determine deployment name
|
||||
let deploy_name = name
|
||||
.or_else(|| config.name.clone())
|
||||
.or_else(|| {
|
||||
cwd.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.ok_or_else(|| anyhow!("Could not determine deployment name"))?;
|
||||
|
||||
// Validate name
|
||||
validate_name(&deploy_name)?;
|
||||
|
||||
output::print_header(&format!("Deploying: {}", deploy_name));
|
||||
|
||||
// Run build if configured and not skipped
|
||||
if !skip_build {
|
||||
if let Some(build) = &config.build {
|
||||
run_build(build, &cwd)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine output directory
|
||||
let output_path = output_dir
|
||||
.unwrap_or_else(|| cwd.join(config.output_dir()));
|
||||
|
||||
if !output_path.exists() {
|
||||
return Err(anyhow!(
|
||||
"Output directory not found: {}\nRun your build command or specify --output",
|
||||
output_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Collect files
|
||||
let spinner = output::create_spinner("Collecting files...");
|
||||
let files = collect_files(&output_path)?;
|
||||
spinner.finish_and_clear();
|
||||
|
||||
if files.is_empty() {
|
||||
return Err(anyhow!("No files found in {}", output_path.display()));
|
||||
}
|
||||
|
||||
let total_size: u64 = files.iter().map(|f| f.size).sum();
|
||||
output::print_info(&format!(
|
||||
"Found {} files ({} total)",
|
||||
files.len(),
|
||||
output::format_size(total_size)
|
||||
));
|
||||
|
||||
// Upload files to storage
|
||||
let spinner = output::create_spinner("Uploading to Synor Storage...");
|
||||
let cid = upload_files(&output_path, &files, hosting_gateway).await?;
|
||||
spinner.finish_and_clear();
|
||||
|
||||
output::print_success(&format!("Uploaded with CID: {}", cid));
|
||||
|
||||
// Register deployment
|
||||
let spinner = output::create_spinner("Registering deployment...");
|
||||
let url = register_deployment(&deploy_name, &cid, hosting_gateway).await?;
|
||||
spinner.finish_and_clear();
|
||||
|
||||
// Build result
|
||||
let result = DeployResult {
|
||||
name: deploy_name.clone(),
|
||||
cid: cid.clone(),
|
||||
url: url.clone(),
|
||||
file_count: files.len(),
|
||||
total_size,
|
||||
};
|
||||
|
||||
// Output result
|
||||
match format {
|
||||
OutputFormat::Json => {
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
}
|
||||
OutputFormat::Text => {
|
||||
output::print_success("Deployment complete!");
|
||||
println!();
|
||||
output::print_kv("Name", &result.name);
|
||||
output::print_kv("CID", &result.cid);
|
||||
output::print_kv("URL", &result.url);
|
||||
output::print_kv("Files", &result.file_count.to_string());
|
||||
output::print_kv("Size", &output::format_size(result.total_size));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate deployment name.
|
||||
fn validate_name(name: &str) -> Result<()> {
|
||||
if name.is_empty() {
|
||||
return Err(anyhow!("Name cannot be empty"));
|
||||
}
|
||||
if name.len() > 63 {
|
||||
return Err(anyhow!("Name must be 63 characters or less"));
|
||||
}
|
||||
if !name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
|
||||
return Err(anyhow!(
|
||||
"Name must contain only lowercase letters, numbers, and hyphens"
|
||||
));
|
||||
}
|
||||
if name.starts_with('-') || name.ends_with('-') {
|
||||
return Err(anyhow!("Name cannot start or end with a hyphen"));
|
||||
}
|
||||
|
||||
// Reserved names
|
||||
const RESERVED: &[&str] = &[
|
||||
"www", "api", "app", "admin", "mail", "ftp", "ssh", "cdn",
|
||||
"storage", "gateway", "hosting", "node", "synor",
|
||||
];
|
||||
if RESERVED.contains(&name) {
|
||||
return Err(anyhow!("Name '{}' is reserved", name));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the build command.
|
||||
fn run_build(build: &BuildConfig, cwd: &Path) -> Result<()> {
|
||||
// Run install command if specified
|
||||
if let Some(install) = &build.install {
|
||||
output::print_info(&format!("Running: {}", install));
|
||||
let status = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(install)
|
||||
.current_dir(cwd)
|
||||
.status()
|
||||
.context("Failed to run install command")?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(anyhow!("Install command failed with status: {}", status));
|
||||
}
|
||||
}
|
||||
|
||||
// Run build command
|
||||
if let Some(command) = &build.command {
|
||||
output::print_info(&format!("Running: {}", command));
|
||||
let status = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(command)
|
||||
.current_dir(cwd)
|
||||
.status()
|
||||
.context("Failed to run build command")?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(anyhow!("Build command failed with status: {}", status));
|
||||
}
|
||||
output::print_success("Build complete");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Collect all files from the output directory.
|
||||
fn collect_files(dir: &Path) -> Result<Vec<DeployFile>> {
|
||||
let mut files = Vec::new();
|
||||
collect_files_recursive(dir, dir, &mut files)?;
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn collect_files_recursive(base: &Path, dir: &Path, files: &mut Vec<DeployFile>) -> Result<()> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
collect_files_recursive(base, &path, files)?;
|
||||
} else {
|
||||
let relative = path
|
||||
.strip_prefix(base)
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let metadata = fs::metadata(&path)?;
|
||||
let content_type = guess_content_type(&path);
|
||||
|
||||
files.push(DeployFile {
|
||||
path: relative,
|
||||
size: metadata.len(),
|
||||
content_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Guess content type from file extension.
|
||||
fn guess_content_type(path: &Path) -> String {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
match ext.as_str() {
|
||||
"html" | "htm" => "text/html",
|
||||
"css" => "text/css",
|
||||
"js" | "mjs" => "application/javascript",
|
||||
"json" => "application/json",
|
||||
"xml" => "application/xml",
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"avif" => "image/avif",
|
||||
"ico" => "image/x-icon",
|
||||
"woff" => "font/woff",
|
||||
"woff2" => "font/woff2",
|
||||
"ttf" => "font/ttf",
|
||||
"otf" => "font/otf",
|
||||
"eot" => "application/vnd.ms-fontobject",
|
||||
"pdf" => "application/pdf",
|
||||
"wasm" => "application/wasm",
|
||||
"txt" => "text/plain",
|
||||
"md" => "text/markdown",
|
||||
"map" => "application/json",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Upload files to Synor Storage.
|
||||
async fn upload_files(
|
||||
base_dir: &Path,
|
||||
files: &[DeployFile],
|
||||
gateway_url: &str,
|
||||
) -> Result<String> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Create a multipart form with all files
|
||||
let mut form = reqwest::multipart::Form::new();
|
||||
|
||||
for file in files {
|
||||
let file_path = base_dir.join(&file.path);
|
||||
let content = fs::read(&file_path)
|
||||
.with_context(|| format!("Failed to read {}", file_path.display()))?;
|
||||
|
||||
let part = reqwest::multipart::Part::bytes(content)
|
||||
.file_name(file.path.clone())
|
||||
.mime_str(&file.content_type)?;
|
||||
|
||||
form = form.part(file.path.clone(), part);
|
||||
}
|
||||
|
||||
// Upload to storage gateway
|
||||
let upload_url = format!("{}/api/upload", gateway_url.trim_end_matches('/'));
|
||||
|
||||
let response = client
|
||||
.post(&upload_url)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to upload files")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("Upload failed: {} - {}", status, body));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UploadResponse {
|
||||
cid: String,
|
||||
}
|
||||
|
||||
let result: UploadResponse = response.json().await?;
|
||||
Ok(result.cid)
|
||||
}
|
||||
|
||||
/// Register the deployment with the hosting gateway.
|
||||
async fn register_deployment(
|
||||
name: &str,
|
||||
cid: &str,
|
||||
gateway_url: &str,
|
||||
) -> Result<String> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RegisterRequest<'a> {
|
||||
name: &'a str,
|
||||
cid: &'a str,
|
||||
}
|
||||
|
||||
let register_url = format!("{}/api/register", gateway_url.trim_end_matches('/'));
|
||||
|
||||
let response = client
|
||||
.post(®ister_url)
|
||||
.json(&RegisterRequest { name, cid })
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to register deployment")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("Registration failed: {} - {}", status, body));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RegisterResponse {
|
||||
url: String,
|
||||
}
|
||||
|
||||
let result: RegisterResponse = response.json().await?;
|
||||
Ok(result.url)
|
||||
}
|
||||
|
||||
/// Initialize a new synor.json in the current directory.
|
||||
pub fn init(
|
||||
name: Option<String>,
|
||||
spa: bool,
|
||||
output: Option<String>,
|
||||
format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
let config_path = cwd.join("synor.json");
|
||||
|
||||
if config_path.exists() {
|
||||
return Err(anyhow!("synor.json already exists"));
|
||||
}
|
||||
|
||||
// Infer name from directory
|
||||
let name = name.or_else(|| {
|
||||
cwd.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_lowercase().replace(' ', "-"))
|
||||
});
|
||||
|
||||
// Detect framework and suggest build config
|
||||
let build_config = detect_framework(&cwd, output);
|
||||
|
||||
let config = SynorJson {
|
||||
name,
|
||||
build: Some(build_config),
|
||||
routes: if spa {
|
||||
Some(RoutesConfig {
|
||||
spa: true,
|
||||
clean_urls: true,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&config)?;
|
||||
fs::write(&config_path, &json)?;
|
||||
|
||||
match format {
|
||||
OutputFormat::Json => {
|
||||
println!("{}", json);
|
||||
}
|
||||
OutputFormat::Text => {
|
||||
output::print_success("Created synor.json");
|
||||
println!("{}", json);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Detect framework and return suggested build config.
|
||||
fn detect_framework(cwd: &Path, output: Option<String>) -> BuildConfig {
|
||||
// Check for common framework configs
|
||||
if cwd.join("next.config.js").exists() || cwd.join("next.config.mjs").exists() {
|
||||
return BuildConfig {
|
||||
command: Some("npm run build".to_string()),
|
||||
output: output.or(Some("out".to_string())),
|
||||
install: Some("npm install".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
if cwd.join("vite.config.js").exists() || cwd.join("vite.config.ts").exists() {
|
||||
return BuildConfig {
|
||||
command: Some("npm run build".to_string()),
|
||||
output: output.or(Some("dist".to_string())),
|
||||
install: Some("npm install".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
if cwd.join("astro.config.mjs").exists() {
|
||||
return BuildConfig {
|
||||
command: Some("npm run build".to_string()),
|
||||
output: output.or(Some("dist".to_string())),
|
||||
install: Some("npm install".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
if cwd.join("angular.json").exists() {
|
||||
return BuildConfig {
|
||||
command: Some("npm run build".to_string()),
|
||||
output: output.or(Some("dist".to_string())),
|
||||
install: Some("npm install".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
if cwd.join("svelte.config.js").exists() {
|
||||
return BuildConfig {
|
||||
command: Some("npm run build".to_string()),
|
||||
output: output.or(Some("build".to_string())),
|
||||
install: Some("npm install".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
// Default config
|
||||
BuildConfig {
|
||||
command: None,
|
||||
output: output.or(Some("dist".to_string())),
|
||||
install: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// List deployments.
|
||||
pub async fn list(gateway_url: &str, format: OutputFormat) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let list_url = format!("{}/api/deployments", gateway_url.trim_end_matches('/'));
|
||||
|
||||
let response = client
|
||||
.get(&list_url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to list deployments")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("Failed to list deployments: {} - {}", status, body));
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct Deployment {
|
||||
name: String,
|
||||
cid: String,
|
||||
url: String,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
let deployments: Vec<Deployment> = response.json().await?;
|
||||
|
||||
match format {
|
||||
OutputFormat::Json => {
|
||||
println!("{}", serde_json::to_string_pretty(&deployments)?);
|
||||
}
|
||||
OutputFormat::Text => {
|
||||
if deployments.is_empty() {
|
||||
output::print_info("No deployments found");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
output::print_header("Deployments");
|
||||
let headers = vec!["Name", "CID", "URL", "Created"];
|
||||
let rows: Vec<Vec<String>> = deployments
|
||||
.iter()
|
||||
.map(|d| {
|
||||
vec![
|
||||
d.name.clone(),
|
||||
output::format_hash(&d.cid),
|
||||
d.url.clone(),
|
||||
d.created_at.clone(),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
output::print_table(headers, rows);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a deployment.
|
||||
pub async fn delete(name: &str, gateway_url: &str, format: OutputFormat) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let delete_url = format!(
|
||||
"{}/api/deployments/{}",
|
||||
gateway_url.trim_end_matches('/'),
|
||||
name
|
||||
);
|
||||
|
||||
let response = client
|
||||
.delete(&delete_url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to delete deployment")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("Failed to delete deployment: {} - {}", status, body));
|
||||
}
|
||||
|
||||
match format {
|
||||
OutputFormat::Json => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"deleted": name
|
||||
}))?
|
||||
);
|
||||
}
|
||||
OutputFormat::Text => {
|
||||
output::print_success(&format!("Deleted deployment: {}", name));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_name_valid() {
|
||||
assert!(validate_name("myapp").is_ok());
|
||||
assert!(validate_name("my-app").is_ok());
|
||||
assert!(validate_name("my-app-123").is_ok());
|
||||
assert!(validate_name("a1b2c3").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_name_invalid() {
|
||||
assert!(validate_name("").is_err());
|
||||
assert!(validate_name("MyApp").is_err()); // uppercase
|
||||
assert!(validate_name("my_app").is_err()); // underscore
|
||||
assert!(validate_name("-myapp").is_err()); // starts with hyphen
|
||||
assert!(validate_name("myapp-").is_err()); // ends with hyphen
|
||||
assert!(validate_name("www").is_err()); // reserved
|
||||
assert!(validate_name("api").is_err()); // reserved
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_guess_content_type() {
|
||||
assert_eq!(
|
||||
guess_content_type(Path::new("index.html")),
|
||||
"text/html"
|
||||
);
|
||||
assert_eq!(
|
||||
guess_content_type(Path::new("style.css")),
|
||||
"text/css"
|
||||
);
|
||||
assert_eq!(
|
||||
guess_content_type(Path::new("app.js")),
|
||||
"application/javascript"
|
||||
);
|
||||
assert_eq!(
|
||||
guess_content_type(Path::new("image.png")),
|
||||
"image/png"
|
||||
);
|
||||
assert_eq!(
|
||||
guess_content_type(Path::new("data.wasm")),
|
||||
"application/wasm"
|
||||
);
|
||||
assert_eq!(
|
||||
guess_content_type(Path::new("unknown.xyz")),
|
||||
"application/octet-stream"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_synor_json_output_dir() {
|
||||
let config = SynorJson::default();
|
||||
assert_eq!(config.output_dir(), "dist");
|
||||
|
||||
let config = SynorJson {
|
||||
build: Some(BuildConfig {
|
||||
output: Some("build".to_string()),
|
||||
command: None,
|
||||
install: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(config.output_dir(), "build");
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
pub mod address;
|
||||
pub mod block;
|
||||
pub mod contract;
|
||||
pub mod deploy;
|
||||
pub mod governance;
|
||||
pub mod mining;
|
||||
pub mod network;
|
||||
|
|
|
|||
|
|
@ -154,6 +154,11 @@ enum Commands {
|
|||
#[command(subcommand)]
|
||||
Governance(GovernanceCommands),
|
||||
|
||||
// ==================== Hosting Commands ====================
|
||||
/// Deploy web applications to Synor Hosting
|
||||
#[command(subcommand)]
|
||||
Deploy(DeployCommands),
|
||||
|
||||
// ==================== Network Commands ====================
|
||||
/// Add a peer
|
||||
AddPeer {
|
||||
|
|
@ -431,6 +436,70 @@ enum GovernanceCommands {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum DeployCommands {
|
||||
/// Deploy the current project to Synor Hosting
|
||||
Push {
|
||||
/// Deployment name (defaults to directory name)
|
||||
#[arg(short, long)]
|
||||
name: Option<String>,
|
||||
|
||||
/// Output directory (defaults to synor.json build.output or "dist")
|
||||
#[arg(short, long)]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Hosting gateway URL
|
||||
#[arg(long, env = "SYNOR_HOSTING_URL", default_value = "http://127.0.0.1:8280")]
|
||||
gateway: String,
|
||||
|
||||
/// Skip running the build command
|
||||
#[arg(long)]
|
||||
skip_build: bool,
|
||||
},
|
||||
|
||||
/// Initialize synor.json in the current directory
|
||||
Init {
|
||||
/// Deployment name
|
||||
#[arg(short, long)]
|
||||
name: Option<String>,
|
||||
|
||||
/// Enable SPA mode (fallback to index.html)
|
||||
#[arg(long)]
|
||||
spa: bool,
|
||||
|
||||
/// Output directory
|
||||
#[arg(short, long)]
|
||||
output: Option<String>,
|
||||
},
|
||||
|
||||
/// List deployments
|
||||
List {
|
||||
/// Hosting gateway URL
|
||||
#[arg(long, env = "SYNOR_HOSTING_URL", default_value = "http://127.0.0.1:8280")]
|
||||
gateway: String,
|
||||
},
|
||||
|
||||
/// Delete a deployment
|
||||
Delete {
|
||||
/// Deployment name
|
||||
name: String,
|
||||
|
||||
/// Hosting gateway URL
|
||||
#[arg(long, env = "SYNOR_HOSTING_URL", default_value = "http://127.0.0.1:8280")]
|
||||
gateway: String,
|
||||
},
|
||||
|
||||
/// Show deployment info
|
||||
Info {
|
||||
/// Deployment name
|
||||
name: String,
|
||||
|
||||
/// Hosting gateway URL
|
||||
#[arg(long, env = "SYNOR_HOSTING_URL", default_value = "http://127.0.0.1:8280")]
|
||||
gateway: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
|
@ -494,6 +563,29 @@ async fn main() {
|
|||
// Governance commands
|
||||
Commands::Governance(cmd) => commands::governance::handle(&client, cmd, output).await,
|
||||
|
||||
// Deploy commands
|
||||
Commands::Deploy(cmd) => match cmd {
|
||||
DeployCommands::Push {
|
||||
name,
|
||||
output: out_dir,
|
||||
gateway,
|
||||
skip_build,
|
||||
} => commands::deploy::deploy(name, out_dir, &gateway, skip_build, output).await,
|
||||
DeployCommands::Init { name, spa, output: out_dir } => {
|
||||
commands::deploy::init(name, spa, out_dir, output)
|
||||
}
|
||||
DeployCommands::List { gateway } => commands::deploy::list(&gateway, output).await,
|
||||
DeployCommands::Delete { name, gateway } => {
|
||||
commands::deploy::delete(&name, &gateway, output).await
|
||||
}
|
||||
DeployCommands::Info { name, gateway } => {
|
||||
// TODO: Implement info command
|
||||
output::print_info(&format!("Deployment info for: {}", name));
|
||||
output::print_kv("Gateway", &gateway);
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
|
||||
// Network commands
|
||||
Commands::AddPeer { address } => {
|
||||
commands::network::add_peer(&client, &address, output).await
|
||||
|
|
|
|||
51
docs/PLAN/PHASE9-SynorHosting/01-Milestone-01-HostingCore.md
Normal file
51
docs/PLAN/PHASE9-SynorHosting/01-Milestone-01-HostingCore.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Milestone 01: Hosting Core
|
||||
|
||||
> On-chain name registry and domain verification for Synor Hosting
|
||||
|
||||
## Status: Complete
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Create synor-hosting crate structure
|
||||
- [x] Implement HostingName type with validation
|
||||
- [x] Implement NameRegistry for on-chain name→CID mapping
|
||||
- [x] Implement DomainVerifier with CNAME/TXT DNS verification
|
||||
- [x] Implement HostingRouter with host-based request routing
|
||||
- [x] Implement RouteConfig for SPA, redirects, and headers
|
||||
- [x] Create error types and config parsing (synor.json)
|
||||
- [x] Add comprehensive test coverage (23 tests)
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
crates/synor-hosting/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── lib.rs
|
||||
│ ├── error.rs
|
||||
│ ├── config.rs
|
||||
│ ├── registry.rs
|
||||
│ ├── domain.rs
|
||||
│ └── router.rs
|
||||
```
|
||||
|
||||
## Key Types
|
||||
|
||||
- `HostingName`: Validated deployment name (alphanumeric + hyphens, 3-63 chars)
|
||||
- `NameEntry`: Registry entry with CID, owner, expiry, custom domains
|
||||
- `NameRegistry`: In-memory registry for name→CID mapping
|
||||
- `DomainVerifier`: DNS verification for custom domains
|
||||
- `HostingRouter`: Routes requests by host header
|
||||
- `RouteConfig`: SPA mode, redirects, headers, error pages
|
||||
- `SynorJson`: Configuration file parser
|
||||
|
||||
## Validation Commands
|
||||
|
||||
```bash
|
||||
cargo test -p synor-hosting
|
||||
cargo clippy -p synor-hosting -- -D warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Completed: January 2026*
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
# Milestone 02: Hosting Gateway
|
||||
|
||||
> HTTP gateway server for serving hosted web applications
|
||||
|
||||
## Status: Complete
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Create HostingGateway server with Axum
|
||||
- [x] Implement request handler with host-based routing
|
||||
- [x] Implement content fetching from Synor Storage
|
||||
- [x] Add content type detection (20+ MIME types)
|
||||
- [x] Implement RateLimiter with token bucket algorithm
|
||||
- [x] Add CacheControl middleware for immutable assets
|
||||
- [x] Create SSL configuration structure
|
||||
- [x] Create hosting-gateway binary
|
||||
- [x] Create Docker configuration (Dockerfile + Caddy)
|
||||
- [x] Create docker-compose.hosting.yml
|
||||
- [x] Add comprehensive test coverage (37 tests total)
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
crates/synor-hosting/src/
|
||||
├── server/
|
||||
│ ├── mod.rs # HostingGateway, GatewayConfig
|
||||
│ ├── handler.rs # Request routing, content fetching
|
||||
│ ├── middleware.rs # RateLimiter, CacheControl
|
||||
│ └── ssl.rs # SSL configuration
|
||||
├── bin/
|
||||
│ └── hosting-gateway.rs
|
||||
|
||||
docker/hosting-gateway/
|
||||
├── Dockerfile # Multi-stage Rust build
|
||||
└── Caddyfile # Wildcard HTTPS for *.synor.cc
|
||||
|
||||
docker-compose.hosting.yml
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
- `HostingGateway`: Main HTTP server using Axum
|
||||
- `GatewayConfig`: Configuration (listen addr, domain, storage URL, rate limit)
|
||||
- `RateLimiter`: Token bucket rate limiting per client IP
|
||||
- `CacheControl`: Immutable asset detection for optimal caching
|
||||
- Caddy reverse proxy with automatic Let's Encrypt HTTPS
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Internet → Caddy (HTTPS) → HostingGateway → Storage Gateway → Storage Nodes
|
||||
↓
|
||||
*.synor.cc wildcard cert
|
||||
```
|
||||
|
||||
## Validation Commands
|
||||
|
||||
```bash
|
||||
cargo test -p synor-hosting
|
||||
cargo build -p synor-hosting --features server
|
||||
docker-compose -f docker-compose.hosting.yml build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Completed: January 2026*
|
||||
135
docs/PLAN/PHASE9-SynorHosting/01-Milestone-03-HostingCLI.md
Normal file
135
docs/PLAN/PHASE9-SynorHosting/01-Milestone-03-HostingCLI.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Milestone 03: Hosting CLI
|
||||
|
||||
> CLI integration for deploying web applications to Synor Hosting
|
||||
|
||||
## Status: Complete
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `synor deploy` command group to CLI
|
||||
- [x] Implement `deploy push` for uploading projects
|
||||
- [x] Implement `deploy init` for creating synor.json
|
||||
- [x] Implement `deploy list` for listing deployments
|
||||
- [x] Implement `deploy delete` for removing deployments
|
||||
- [x] Add synor.json parsing with framework detection
|
||||
- [x] Add build command execution
|
||||
- [x] Add multipart file upload to storage gateway
|
||||
- [x] Add deployment registration with hosting gateway
|
||||
- [x] Add name validation (reserved names, format)
|
||||
- [x] Add comprehensive test coverage
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
apps/cli/src/
|
||||
├── main.rs # DeployCommands enum
|
||||
├── commands/
|
||||
│ ├── mod.rs # deploy module export
|
||||
│ └── deploy.rs # Deploy command handlers
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### Deploy Push
|
||||
|
||||
```bash
|
||||
# Deploy current directory
|
||||
synor deploy push
|
||||
|
||||
# Deploy with custom name
|
||||
synor deploy push --name myapp
|
||||
|
||||
# Deploy from specific output directory
|
||||
synor deploy push --output build
|
||||
|
||||
# Skip build step
|
||||
synor deploy push --skip-build
|
||||
|
||||
# Use custom gateway
|
||||
synor deploy push --gateway http://hosting.example.com:8080
|
||||
```
|
||||
|
||||
### Deploy Init
|
||||
|
||||
```bash
|
||||
# Initialize synor.json with auto-detection
|
||||
synor deploy init
|
||||
|
||||
# Initialize as SPA
|
||||
synor deploy init --spa
|
||||
|
||||
# Initialize with specific name
|
||||
synor deploy init --name myproject
|
||||
```
|
||||
|
||||
### Other Commands
|
||||
|
||||
```bash
|
||||
# List all deployments
|
||||
synor deploy list
|
||||
|
||||
# Delete a deployment
|
||||
synor deploy delete myapp
|
||||
|
||||
# Get deployment info
|
||||
synor deploy info myapp
|
||||
```
|
||||
|
||||
## synor.json Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "myapp",
|
||||
"build": {
|
||||
"command": "npm run build",
|
||||
"output": "dist",
|
||||
"install": "npm install"
|
||||
},
|
||||
"routes": {
|
||||
"spa": true,
|
||||
"clean_urls": true
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"source": "/**",
|
||||
"headers": [
|
||||
{ "key": "X-Frame-Options", "value": "DENY" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/old",
|
||||
"destination": "/new",
|
||||
"status": 301
|
||||
}
|
||||
],
|
||||
"error_pages": {
|
||||
"404": "/404.html"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Framework Auto-Detection
|
||||
|
||||
The CLI automatically detects and configures:
|
||||
|
||||
| Framework | Config File | Output Dir |
|
||||
|-----------|-------------|------------|
|
||||
| Next.js | next.config.js | out |
|
||||
| Vite | vite.config.js | dist |
|
||||
| Astro | astro.config.mjs | dist |
|
||||
| Angular | angular.json | dist |
|
||||
| SvelteKit | svelte.config.js | build |
|
||||
|
||||
## Validation Commands
|
||||
|
||||
```bash
|
||||
cargo test -p synor-cli
|
||||
cargo run -p synor-cli -- deploy --help
|
||||
cargo run -p synor-cli -- deploy push --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Completed: January 2026*
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
| 6 | Quality Assurance | ✅ Complete | 100% | 3 |
|
||||
| 7 | Production Readiness | 🔄 In Progress | 85% | 3 |
|
||||
| 8 | Synor Storage L2 | ✅ Complete | 100% | 3 |
|
||||
| 9 | Synor Hosting | 🔄 In Progress | 75% | 3 |
|
||||
| 9 | Synor Hosting | ✅ Complete | 100% | 3 |
|
||||
|
||||
## Phase 7 Breakdown
|
||||
|
||||
|
|
@ -64,7 +64,7 @@
|
|||
|-----------|--------|----------|
|
||||
| Hosting Core | ✅ Complete | 100% |
|
||||
| Hosting Gateway | ✅ Complete | 100% |
|
||||
| Hosting CLI | ⏳ Pending | 0% |
|
||||
| Hosting CLI | ✅ Complete | 100% |
|
||||
|
||||
### Hosting Components
|
||||
|
||||
|
|
@ -78,8 +78,8 @@
|
|||
| Rate Limiting | 100% | Token bucket algorithm |
|
||||
| Cache Control | 100% | Immutable assets, SPA support |
|
||||
| Docker Deployment | 100% | Caddy + wildcard HTTPS |
|
||||
| CLI Deploy Command | 0% | `synor deploy` integration |
|
||||
| Admin Dashboard | 0% | Web UI for management |
|
||||
| CLI Deploy Command | 100% | `synor deploy push/init/list/delete` |
|
||||
| Admin Dashboard | 0% | Web UI for management (deferred) |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue