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"
|
argon2 = "0.5"
|
||||||
|
|
||||||
# HTTP client
|
# HTTP client
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json", "multipart"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
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 address;
|
||||||
pub mod block;
|
pub mod block;
|
||||||
pub mod contract;
|
pub mod contract;
|
||||||
|
pub mod deploy;
|
||||||
pub mod governance;
|
pub mod governance;
|
||||||
pub mod mining;
|
pub mod mining;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,11 @@ enum Commands {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Governance(GovernanceCommands),
|
Governance(GovernanceCommands),
|
||||||
|
|
||||||
|
// ==================== Hosting Commands ====================
|
||||||
|
/// Deploy web applications to Synor Hosting
|
||||||
|
#[command(subcommand)]
|
||||||
|
Deploy(DeployCommands),
|
||||||
|
|
||||||
// ==================== Network Commands ====================
|
// ==================== Network Commands ====================
|
||||||
/// Add a peer
|
/// Add a peer
|
||||||
AddPeer {
|
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]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
@ -494,6 +563,29 @@ async fn main() {
|
||||||
// Governance commands
|
// Governance commands
|
||||||
Commands::Governance(cmd) => commands::governance::handle(&client, cmd, output).await,
|
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
|
// Network commands
|
||||||
Commands::AddPeer { address } => {
|
Commands::AddPeer { address } => {
|
||||||
commands::network::add_peer(&client, &address, output).await
|
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 |
|
| 6 | Quality Assurance | ✅ Complete | 100% | 3 |
|
||||||
| 7 | Production Readiness | 🔄 In Progress | 85% | 3 |
|
| 7 | Production Readiness | 🔄 In Progress | 85% | 3 |
|
||||||
| 8 | Synor Storage L2 | ✅ Complete | 100% | 3 |
|
| 8 | Synor Storage L2 | ✅ Complete | 100% | 3 |
|
||||||
| 9 | Synor Hosting | 🔄 In Progress | 75% | 3 |
|
| 9 | Synor Hosting | ✅ Complete | 100% | 3 |
|
||||||
|
|
||||||
## Phase 7 Breakdown
|
## Phase 7 Breakdown
|
||||||
|
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
|-----------|--------|----------|
|
|-----------|--------|----------|
|
||||||
| Hosting Core | ✅ Complete | 100% |
|
| Hosting Core | ✅ Complete | 100% |
|
||||||
| Hosting Gateway | ✅ Complete | 100% |
|
| Hosting Gateway | ✅ Complete | 100% |
|
||||||
| Hosting CLI | ⏳ Pending | 0% |
|
| Hosting CLI | ✅ Complete | 100% |
|
||||||
|
|
||||||
### Hosting Components
|
### Hosting Components
|
||||||
|
|
||||||
|
|
@ -78,8 +78,8 @@
|
||||||
| Rate Limiting | 100% | Token bucket algorithm |
|
| Rate Limiting | 100% | Token bucket algorithm |
|
||||||
| Cache Control | 100% | Immutable assets, SPA support |
|
| Cache Control | 100% | Immutable assets, SPA support |
|
||||||
| Docker Deployment | 100% | Caddy + wildcard HTTPS |
|
| Docker Deployment | 100% | Caddy + wildcard HTTPS |
|
||||||
| CLI Deploy Command | 0% | `synor deploy` integration |
|
| CLI Deploy Command | 100% | `synor deploy push/init/list/delete` |
|
||||||
| Admin Dashboard | 0% | Web UI for management |
|
| Admin Dashboard | 0% | Web UI for management (deferred) |
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue