use crate::config::CliConfig;
use crate::project_config::ProjectConfig;
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
const CLI_DEPLOY_API_VERSION: u32 = 1;
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_override = Some(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<()> {
let client = config.http_client();
verify_platform_version(config, &client).await?;
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 wasm_artifact = if mode == "wasm" {
let artifact = build_wasm_artifact()?;
println!(
" Bundled WASM: {} bytes, hash: {}",
artifact.size,
&artifact.hash[..16]
);
Some(artifact)
} else {
None
};
let expected_wasm_hash = wasm_artifact.as_ref().map(|a| a.hash.clone());
let deploy_req = serde_json::json!({
"manifest": manifest,
"allow_destructive": allow_destructive,
"commit_hash": commit_hash,
"wasm_base64": wasm_artifact.as_ref().map(|a| a.base64.clone()),
"cli_version": env!("CARGO_PKG_VERSION"),
"api_version": CLI_DEPLOY_API_VERSION,
});
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"))?
.to_string();
if body["skipped"].as_bool() == Some(true) {
println!(
"No schema changes for {} v{} — manifest unchanged",
body["name"].as_str().unwrap_or("?"),
body["version"]
);
maybe_sync_configs(config, &service_id, project, env).await?;
if mode == "wasm" {
upload_wasm(config, &client, &service_id).await?;
}
verify_deploy_ready(config, &client, &service_id).await?;
return Ok(());
}
if let Some(ref expected) = expected_wasm_hash {
match body["wasm_hash"].as_str() {
Some(h) if h == expected => {}
Some(h) => {
eyre::bail!(
"WASM hash mismatch: CLI built {} but platform stored {}. \
This indicates corruption somewhere in the upload path.",
&expected[..16],
&h[..16.min(h.len())]
);
}
None => {
eyre::bail!(
"Platform did not echo wasm_hash in the deploy response. \
This usually means the platform is older than expected; \
check `curl {}/api/version`.",
config.api_url
);
}
}
}
maybe_sync_configs(config, &service_id, project, env).await?;
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 let Some(hash) = body["wasm_hash"].as_str() {
println!(" WASM hash: {}", &hash[..16.min(hash.len())]);
}
if manifest["on_migrate"].is_string() {
println!(
" on_migrate: {} (ran between phases)",
manifest["on_migrate"].as_str().unwrap_or("?")
);
}
verify_deploy_ready(config, &client, &service_id).await?;
Ok(())
}
async fn verify_platform_version(config: &CliConfig, client: &reqwest::Client) -> eyre::Result<()> {
let url = format!("{}/api/version", config.api_url);
let resp = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => {
eyre::bail!(
"Could not reach platform at {}: {}\n\
Check your CUFFLINK_API_URL / Cufflink.toml api_url and network connectivity.",
url,
e
);
}
};
if resp.status().as_u16() == 404 {
eyre::bail!(
"Platform at {} does not expose /api/version. \
This CLI (v{}) requires a platform that supports deploy api_version >= {}. \
Upgrade the platform image before deploying.",
config.api_url,
env!("CARGO_PKG_VERSION"),
CLI_DEPLOY_API_VERSION
);
}
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("Platform /api/version returned {}: {}", status, body);
}
let info: serde_json::Value = resp.json().await?;
let platform_api = info["api_version"].as_u64().unwrap_or(0) as u32;
if platform_api < CLI_DEPLOY_API_VERSION {
eyre::bail!(
"Platform deploy api_version is {} but this CLI (v{}) requires >= {}. \
Upgrade the platform image before deploying.",
platform_api,
env!("CARGO_PKG_VERSION"),
CLI_DEPLOY_API_VERSION
);
}
Ok(())
}
async fn verify_deploy_ready(
config: &CliConfig,
client: &reqwest::Client,
service_id: &str,
) -> eyre::Result<()> {
let url = format!("{}/api/services/{}/wasm/status", config.api_url, service_id);
let delays = [
Duration::from_millis(100),
Duration::from_millis(200),
Duration::from_millis(500),
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(4),
Duration::from_secs(8),
Duration::from_secs(14),
];
let mut last_body = String::new();
for delay in delays {
let resp = config
.auth_request(client, reqwest::Method::GET, &url)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!(
"Deploy verification failed ({} on wasm/status): {}",
status,
body
);
}
let body: serde_json::Value = resp.json().await?;
last_body = serde_json::to_string(&body).unwrap_or_default();
let is_wasm_service = body["wasm_hash"]
.as_str()
.map(|s| !s.is_empty())
.unwrap_or(false);
if !is_wasm_service {
return Ok(());
}
if body["db_state"].as_bool() == Some(true) {
println!(
" Deploy verified: db_state=true, local_cache={}",
body["local_cache"].as_bool().unwrap_or(false)
);
return Ok(());
}
tokio::time::sleep(delay).await;
}
eyre::bail!(
"Deploy verification timed out: wasm/status never reported db_state=true \
within 30s. Last status body: {}. The platform accepted the deploy but \
the WASM binary is not durably persisted — this is a bug. Check platform \
logs (`cufflink_deployment_wasm_missing_total` metric) and retry.",
last_body
);
}
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!("Resolving service configs...");
let local_envs = resolve_local_build_envs(project, env)?;
let mut build_envs: HashMap<String, String> = fetch_service_configs(config, service_name)
.await
.into_iter()
.collect();
build_envs.extend(local_envs);
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('.')
})
}
fn resolve_local_build_envs(
project: Option<&ProjectConfig>,
env: Option<&str>,
) -> eyre::Result<HashMap<String, String>> {
let project = match project {
Some(p) => p,
None => return Ok(HashMap::new()),
};
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(HashMap::new()),
};
super::config_cmd::resolve_local_envs(project, &env_name)
}
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())
}
struct WasmArtifact {
base64: String,
size: usize,
hash: String,
}
fn build_wasm_artifact() -> eyre::Result<WasmArtifact> {
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()?;
let wasm_bytes = std::fs::read(&wasm_path)?;
let size = wasm_bytes.len();
println!(" WASM artifact: {} ({} bytes)", wasm_path.display(), size);
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&wasm_bytes);
let hash = hex::encode(hasher.finalize());
use base64::Engine as _;
let base64 = base64::engine::general_purpose::STANDARD.encode(&wasm_bytes);
Ok(WasmArtifact { base64, size, hash })
}
async fn upload_wasm(
config: &CliConfig,
client: &reqwest::Client,
service_id: &str,
) -> eyre::Result<()> {
let artifact = build_wasm_artifact()?;
let expected_hash = artifact.hash.clone();
let upload_req = serde_json::json!({ "wasm_base64": artifact.base64 });
println!("Uploading WASM module...");
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?;
let echoed = body["wasm_hash"].as_str().unwrap_or("");
println!(" WASM hash: {}", echoed);
println!(" Size: {} bytes", body["size_bytes"]);
if echoed != expected_hash {
eyre::bail!(
"WASM hash mismatch on upload: CLI built {} but platform stored {}",
&expected_hash[..16],
&echoed[..16.min(echoed.len())]
);
}
} 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
)
}