use crate::config::CliConfig;
use crate::project_config::ProjectConfig;
use std::path::Path;
use std::process::Command;
pub async fn run(
allow_destructive: bool,
commit_hash_override: Option<String>,
env: Option<&str>,
tenant_override: Option<&str>,
) -> eyre::Result<()> {
let mut config = CliConfig::load_with_env(env)?;
if let Some(tenant) = tenant_override {
config.tenant_slug = tenant.to_string();
}
if let Some(ref name) = config.env_name {
println!("Environment: {}", name);
}
let project = ProjectConfig::find_and_load()?;
let mode = project
.as_ref()
.and_then(|p| p.service.mode.as_deref())
.unwrap_or("rust");
let commit_hash = commit_hash_override.or_else(detect_git_commit_hash);
if mode == "web" {
let service_name = project
.as_ref()
.and_then(|p| p.service.name.as_deref())
.ok_or_else(|| eyre::eyre!("Web mode requires [service].name in Cufflink.toml"))?;
deploy_web(
&config,
service_name,
project.as_ref(),
commit_hash.as_deref(),
env,
)
.await
} else {
deploy_rust(
&config,
allow_destructive,
project.as_ref(),
commit_hash.as_deref(),
env,
)
.await
}
}
async fn deploy_rust(
config: &CliConfig,
allow_destructive: bool,
project: Option<&ProjectConfig>,
commit_hash: Option<&str>,
env: Option<&str>,
) -> eyre::Result<()> {
println!("Building service...");
let output = Command::new("cargo")
.args(["run", "--", "--emit-manifest"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eyre::bail!("Build failed:\n{}", stderr);
}
let stdout = String::from_utf8(output.stdout)?;
let manifest: serde_json::Value = serde_json::from_str(stdout.trim())
.map_err(|e| eyre::eyre!("Failed to parse manifest JSON: {}", e))?;
let service_name = manifest["name"].as_str().unwrap_or("unknown");
let mode = manifest["mode"].as_str().unwrap_or("crud");
println!("Deploying {} (mode: {})...", service_name, mode);
let deploy_req = serde_json::json!({
"manifest": manifest,
"allow_destructive": allow_destructive,
"commit_hash": commit_hash,
});
let client = config.http_client();
let resp = config
.auth_request(
&client,
reqwest::Method::POST,
&format!("{}/api/services/deploy", config.api_url),
)
.json(&deploy_req)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("Deploy failed ({}): {}", status, body);
}
let body: serde_json::Value = resp.json().await?;
let service_id = body["service_id"]
.as_str()
.ok_or_else(|| eyre::eyre!("No service_id in deploy response"))?;
maybe_sync_configs(config, service_id, project, env).await?;
if body["skipped"].as_bool() == Some(true) {
println!(
"No schema changes for {} v{} — manifest unchanged",
body["name"].as_str().unwrap_or("?"),
body["version"]
);
if mode != "wasm" {
return Ok(());
}
} else {
println!(
"Deployed {} v{}",
body["name"].as_str().unwrap_or("?"),
body["version"]
);
println!(" Schema changes: {}", body["schema_changes"]);
println!(" Deployment ID: {}", body["deployment_id"]);
println!(" Tenant: {}", body["tenant_slug"]);
if let Some(hash) = body["commit_hash"].as_str() {
println!(" Commit: {}", hash);
}
}
if mode == "wasm" {
upload_wasm(config, &client, service_id).await?;
}
Ok(())
}
async fn deploy_web(
config: &CliConfig,
service_name: &str,
project: Option<&ProjectConfig>,
commit_hash: Option<&str>,
env: Option<&str>,
) -> eyre::Result<()> {
println!("Deploying web app: {}", service_name);
let pkg_json = read_package_json()?;
let scripts = pkg_json["scripts"].as_object();
if let Some(s) = scripts {
if s.contains_key("typecheck") {
run_npm_script("typecheck", "Type checking")?;
}
if s.contains_key("lint") {
run_npm_script("lint", "Linting")?;
}
if s.contains_key("test") {
run_npm_script("test", "Running tests")?;
}
}
let version = pkg_json["version"].as_str().unwrap_or("0.1.0");
let manifest = serde_json::json!({
"name": service_name,
"mode": "web",
"version": version,
"tables": [],
});
let deploy_req = serde_json::json!({
"manifest": manifest,
"allow_destructive": false,
"commit_hash": commit_hash,
});
let client = config.http_client();
let resp = config
.auth_request(
&client,
reqwest::Method::POST,
&format!("{}/api/services/deploy", config.api_url),
)
.json(&deploy_req)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("Deploy failed ({}): {}", status, body);
}
let body: serde_json::Value = resp.json().await?;
let service_id = body["service_id"]
.as_str()
.ok_or_else(|| eyre::eyre!("No service_id in deploy response"))?;
println!(
"Registered {} v{}",
body["name"].as_str().unwrap_or("?"),
body["version"]
);
println!(" Deployment ID: {}", body["deployment_id"]);
println!(" Tenant: {}", body["tenant_slug"]);
if let Some(hash) = body["commit_hash"].as_str() {
println!(" Commit: {}", hash);
}
maybe_sync_configs(config, service_id, project, env).await?;
println!("Fetching service configs...");
let build_envs = fetch_service_configs(config, service_name).await;
if !build_envs.is_empty() {
println!(
" Injecting {} service configs into build",
build_envs.len()
);
}
check_pnpm_workspace()?;
println!("Building Next.js app...");
let pm = detect_package_manager();
let mut build_cmd = Command::new(&pm);
build_cmd.args(["run", "build"]);
for (key, value) in &build_envs {
build_cmd.env(key, value);
}
let build_output = build_cmd.output()?;
if !build_output.status.success() {
let stderr = String::from_utf8_lossy(&build_output.stderr);
let stdout = String::from_utf8_lossy(&build_output.stdout);
eyre::bail!("Build failed:\n{}\n{}", stdout, stderr);
}
println!(" Build complete");
if !Path::new(".next").exists() {
eyre::bail!("Next.js build output not found at .next/");
}
println!("Creating deployment artifact...");
let tarball = create_web_tarball()?;
let tarball_size = tarball.len();
println!(
" Artifact size: {} bytes ({:.1} MB)",
tarball_size,
tarball_size as f64 / 1_048_576.0
);
println!("Uploading web artifact...");
let resp = config
.auth_request(
&client,
reqwest::Method::POST,
&format!("{}/api/services/{}/web", config.api_url, service_id),
)
.header("Content-Type", "application/gzip")
.body(tarball)
.send()
.await?;
if resp.status().is_success() {
let body: serde_json::Value = resp.json().await?;
println!(
" Artifact hash: {}",
body["artifact_hash"].as_str().unwrap_or("?")
);
println!(" Size: {} bytes", body["size_bytes"]);
} else {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("Web artifact upload failed ({}): {}", status, body);
}
println!("\nWeb app deployed successfully!");
Ok(())
}
fn check_pnpm_workspace() -> eyre::Result<()> {
use crate::pnpm::{check_workspace_membership, WorkspaceCheck};
let cwd = std::env::current_dir()?;
if let WorkspaceCheck::NotIncluded {
workspace_file,
suggested_glob,
..
} = check_workspace_membership(&cwd)
{
eyre::bail!(
"This package is inside a pnpm workspace but is not listed in {}\n\n\
pnpm install will not have installed dependencies for this package,\n\
so the build will fail.\n\n\
Add \"{}\" to the packages list in {} and run pnpm install",
workspace_file.display(),
suggested_glob,
workspace_file.display(),
);
}
Ok(())
}
fn read_package_json() -> eyre::Result<serde_json::Value> {
let content = std::fs::read_to_string("package.json")
.map_err(|_| eyre::eyre!("package.json not found in current directory"))?;
serde_json::from_str(&content).map_err(|e| eyre::eyre!("Failed to parse package.json: {}", e))
}
fn run_npm_script(script: &str, label: &str) -> eyre::Result<()> {
println!("{}...", label);
let pm = detect_package_manager();
let output = Command::new(&pm).args(["run", script]).output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
eyre::bail!("{} failed:\n{}\n{}", label, stdout, stderr);
}
println!(" {} passed", label);
Ok(())
}
fn detect_package_manager() -> String {
let mut dir = std::env::current_dir().unwrap_or_default();
loop {
if dir.join("pnpm-lock.yaml").exists() {
return "pnpm".to_string();
}
if dir.join("yarn.lock").exists() {
return "yarn".to_string();
}
if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() {
return "bun".to_string();
}
if dir.join("package-lock.json").exists() {
return "npm".to_string();
}
if !dir.pop() {
break;
}
}
"npm".to_string()
}
fn create_web_tarball() -> eyre::Result<Vec<u8>> {
let cwd = std::env::current_dir()?;
let nm_dir = resolve_node_modules()?;
let staging = tempfile::tempdir()?;
let stage = staging.path();
let next_dir = cwd.join(".next");
if next_dir.exists() {
std::os::unix::fs::symlink(&next_dir, stage.join(".next"))?;
}
std::os::unix::fs::symlink(&nm_dir, stage.join("node_modules"))?;
std::os::unix::fs::symlink(cwd.join("package.json"), stage.join("package.json"))?;
let public_dir = cwd.join("public");
let has_public = public_dir.exists();
if has_public {
std::os::unix::fs::symlink(&public_dir, stage.join("public"))?;
}
let mut args = vec!["czfh", "-", ".next", "node_modules", "package.json"];
if has_public {
args.push("public");
}
let output = Command::new("tar")
.args(&args)
.current_dir(stage)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eyre::bail!("tar command failed: {}", stderr);
}
if let Some(parent) = nm_dir.parent() {
if parent.starts_with(std::env::temp_dir()) {
let _ = std::fs::remove_dir_all(parent);
}
}
Ok(output.stdout)
}
fn resolve_node_modules() -> eyre::Result<std::path::PathBuf> {
if let Some(root) = find_pnpm_workspace_root() {
println!(" Detected pnpm monorepo, creating pruned node_modules...");
let temp_dir = tempfile::tempdir()?;
let deploy_dir = temp_dir.keep();
let pkg_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string("package.json")?)?;
let pkg_name = pkg_json["name"]
.as_str()
.ok_or_else(|| eyre::eyre!("package.json missing 'name' field"))?;
let output = Command::new("pnpm")
.args([
"--filter",
pkg_name,
"deploy",
"--prod",
&deploy_dir.display().to_string(),
])
.current_dir(&root)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!(
" pnpm deploy failed, falling back to nearest node_modules: {}",
stderr
);
let _ = std::fs::remove_dir_all(&deploy_dir);
return find_node_modules();
}
let nm = deploy_dir.join("node_modules");
if nm.is_dir() {
println!(" Pruned node_modules ready");
return Ok(nm);
}
let _ = std::fs::remove_dir_all(&deploy_dir);
}
find_node_modules()
}
fn find_pnpm_workspace_root() -> Option<std::path::PathBuf> {
let mut dir = std::env::current_dir().ok()?;
loop {
if dir.join("pnpm-lock.yaml").exists() {
return Some(dir);
}
if !dir.pop() {
break;
}
}
None
}
fn find_node_modules() -> eyre::Result<std::path::PathBuf> {
let mut dir = std::env::current_dir()?;
loop {
let candidate = dir.join("node_modules");
if candidate.is_dir() && has_packages(&candidate) {
return Ok(candidate);
}
if !dir.pop() {
break;
}
}
eyre::bail!("node_modules not found in any parent directory")
}
fn has_packages(node_modules: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(node_modules) else {
return false;
};
entries.filter_map(|e| e.ok()).any(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
!name.starts_with('.')
})
}
async fn maybe_sync_configs(
config: &CliConfig,
service_id: &str,
project: Option<&ProjectConfig>,
env: Option<&str>,
) -> eyre::Result<()> {
let project = match project {
Some(p) => p,
None => return Ok(()),
};
let env_name = env
.map(|s| s.to_string())
.or_else(|| project.service.default_env.clone());
let env_name = match env_name {
Some(n) => n,
None => return Ok(()),
};
let env_config = match project.environments.get(&env_name) {
Some(c) => c,
None => return Ok(()),
};
if env_config.config.is_empty() && env_config.secrets.is_empty() {
return Ok(());
}
println!("Syncing configs...");
super::config_cmd::sync_to_platform(
config,
service_id,
&env_config.config,
&env_config.secrets,
project,
&env_name,
)
.await
}
async fn fetch_service_configs(config: &CliConfig, service_name: &str) -> Vec<(String, String)> {
let client = config.http_client();
let resp = match config
.auth_request(
&client,
reqwest::Method::GET,
&format!("{}/api/services", config.api_url),
)
.send()
.await
{
Ok(r) => r,
Err(_) => return Vec::new(),
};
let services: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let service_id = match services["services"].as_array().and_then(|arr| {
arr.iter()
.find(|s| s["name"].as_str() == Some(service_name))
.and_then(|s| s["id"].as_str())
}) {
Some(id) => id.to_string(),
None => return Vec::new(),
};
let resp = match config
.auth_request(
&client,
reqwest::Method::GET,
&format!("{}/api/services/{}/config", config.api_url, service_id),
)
.send()
.await
{
Ok(r) if r.status().is_success() => r,
_ => return Vec::new(),
};
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(_) => return Vec::new(),
};
body["configs"]
.as_array()
.map(|configs| {
configs
.iter()
.filter_map(|c| {
let key = c["key"].as_str()?;
let value = c["value"].as_str()?;
Some((key.to_string(), value.to_string()))
})
.collect()
})
.unwrap_or_default()
}
fn detect_git_commit_hash() -> Option<String> {
Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
async fn upload_wasm(
config: &CliConfig,
client: &reqwest::Client,
service_id: &str,
) -> eyre::Result<()> {
let rustup_check = Command::new("rustup")
.args(["target", "list", "--installed"])
.output()?;
let installed_targets = String::from_utf8_lossy(&rustup_check.stdout);
if !installed_targets.contains("wasm32-unknown-unknown") {
eyre::bail!("WASM target not installed. Run:\n rustup target add wasm32-unknown-unknown");
}
println!("Building WASM module...");
let build_output = Command::new("cargo")
.args([
"build",
"--lib",
"--target",
"wasm32-unknown-unknown",
"--release",
])
.output()?;
if !build_output.status.success() {
let stderr = String::from_utf8_lossy(&build_output.stderr);
eyre::bail!("WASM build failed:\n{}", stderr);
}
let wasm_path = find_wasm_artifact()?;
println!(" WASM artifact: {}", wasm_path.display());
let wasm_bytes = std::fs::read(&wasm_path)?;
let wasm_size = wasm_bytes.len();
use base64::Engine as _;
let wasm_base64 = base64::engine::general_purpose::STANDARD.encode(&wasm_bytes);
println!("Uploading WASM module ({} bytes)...", wasm_size);
let upload_req = serde_json::json!({
"wasm_base64": wasm_base64,
});
let resp = config
.auth_request(
client,
reqwest::Method::POST,
&format!("{}/api/services/{}/wasm", config.api_url, service_id),
)
.json(&upload_req)
.send()
.await?;
if resp.status().is_success() {
let body: serde_json::Value = resp.json().await?;
println!(
" WASM hash: {}",
body["wasm_hash"].as_str().unwrap_or("?")
);
println!(" Size: {} bytes", body["size_bytes"]);
} else {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("WASM upload failed ({}): {}", status, body);
}
Ok(())
}
fn find_wasm_artifact() -> eyre::Result<std::path::PathBuf> {
let metadata_output = Command::new("cargo")
.args(["metadata", "--format-version", "1", "--no-deps"])
.output()?;
if !metadata_output.status.success() {
eyre::bail!("Failed to run cargo metadata");
}
let metadata: serde_json::Value = serde_json::from_slice(&metadata_output.stdout)?;
let target_dir = metadata["target_directory"]
.as_str()
.ok_or_else(|| eyre::eyre!("Cannot determine target directory"))?;
let cwd = std::env::current_dir()?;
let cwd_str = cwd.to_string_lossy();
let crate_name = metadata["packages"]
.as_array()
.and_then(|pkgs| {
pkgs.iter().find(|p| {
p["manifest_path"]
.as_str()
.map(|mp| mp.starts_with(cwd_str.as_ref()))
.unwrap_or(false)
})
})
.and_then(|pkg| {
pkg["targets"]
.as_array()
.and_then(|targets| {
targets.iter().find(|t| {
t["crate_types"]
.as_array()
.map(|types| types.iter().any(|ct| ct.as_str() == Some("cdylib")))
.unwrap_or(false)
})
})
.and_then(|t| t["name"].as_str())
.or_else(|| pkg["name"].as_str())
})
.unwrap_or("unknown");
let wasm_name = crate_name.replace('-', "_");
let wasm_path = std::path::PathBuf::from(target_dir)
.join("wasm32-unknown-unknown")
.join("release")
.join(format!("{}.wasm", wasm_name));
if wasm_path.exists() {
return Ok(wasm_path);
}
eyre::bail!(
"WASM artifact not found at {:?}.\n\
Ensure your Cargo.toml has:\n \
[lib]\n \
crate-type = [\"cdylib\"]",
wasm_path
)
}