use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::Value;
use crate::cron;
pub struct DeployOptions {
pub preview: bool,
pub skip_cron: bool,
}
pub fn deploy(root: &Path, options: &DeployOptions) -> Result<(), String> {
let root = fs::canonicalize(root).map_err(|error| format!("bad --root: {error}"))?;
let mut generated_vercel_config = None;
if root.join(cron::CONFIG_FILE).is_file() {
let summary = cron::generate(&root)?;
generated_vercel_config = Some(root.join(cron::VERCEL_CONFIG_FILE));
eprintln!("nextrs: generated {summary}");
if !options.preview && !options.skip_cron {
let crons = cron::discover_crons(&root)?;
let cloudflare: Vec<_> = crons
.iter()
.filter(|cron| cron.provider() == cron::Provider::Cloudflare)
.collect();
cron::preflight_cloudflare_credentials(&cloudflare)?;
}
} else {
eprintln!(
"nextrs: no {} — deploying with Vercel's default project config",
cron::CONFIG_FILE
);
}
let link = root.join(".vercel/project.json");
let link_json: Value = fs::read_to_string(&link)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.ok_or_else(|| {
format!(
"{} is not linked to a Vercel project (run `vercel link` in it)",
root.display()
)
})?;
let root_directory = link_json["settings"]["rootDirectory"]
.as_str()
.filter(|dir| !dir.is_empty())
.map(str::to_owned);
let mut envs: Vec<(&str, PathBuf)> = Vec::new();
let cwd = match &root_directory {
Some(dir) => {
let repo = root
.ancestors()
.skip(1)
.find(|anc| fs::canonicalize(anc.join(dir)).ok().as_deref() == Some(&root))
.ok_or_else(|| {
format!(
"the Vercel project's Root Directory is `{dir}` but no ancestor of {} contains it at that path",
root.display()
)
})?
.to_path_buf();
fs::create_dir_all(repo.join(".vercel"))
.map_err(|error| format!("cannot create {}/.vercel: {error}", repo.display()))?;
fs::copy(&link, repo.join(".vercel/project.json"))
.map_err(|error| format!("cannot copy project link: {error}"))?;
repo
}
None => {
envs.push(("CARGO_TARGET_DIR", root.join("target-vercel")));
root.clone()
}
};
let environment = if options.preview {
"preview"
} else {
"production"
};
let prod: &[&str] = if options.preview { &[] } else { &["--prod"] };
eprintln!("==> vercel pull (project settings + {environment} env)");
run(
&cwd,
&envs,
"vercel",
&["pull", "--yes", &format!("--environment={environment}")],
)?;
eprintln!(
"==> vercel build {} — local compile, incl. the Rust function",
prod.join(" ")
);
let local_config = generated_vercel_config
.as_ref()
.map(|path| path.to_string_lossy().into_owned());
let build_args = vercel_build_args(local_config.as_deref(), prod);
run(&cwd, &envs, "vercel", &build_args)?;
let functions = cwd.join(".vercel/output/functions");
let configs = find_files(&functions, ".vc-config.json");
if configs.is_empty() {
return Err(
"no function in .vercel/output — is cargo-zigbuild installed and zig reachable?".into(),
);
}
for config_path in configs {
bundle_function(&config_path, &cwd)?;
}
eprintln!("==> vercel deploy --prebuilt {}", prod.join(" "));
run(
&cwd,
&envs,
"vercel",
&[&["deploy", "--prebuilt"][..], prod].concat(),
)?;
eprintln!("nextrs: Vercel application deployed successfully");
if options.skip_cron || !root.join(cron::CONFIG_FILE).is_file() {
return Ok(());
}
if options.preview {
eprintln!(
"nextrs: preview deploy — skipping cron triggers (they point at the production URL)"
);
return Ok(());
}
cron::deploy(&root).map_err(|error| cron_recovery_error(&root, &error))
}
fn cron_recovery_error(root: &Path, error: &str) -> String {
format!(
"Vercel application deployed successfully, but Cloudflare cron deployment failed: {error}\nFix the cron deployment problem, then retry only that phase with `nextrs cron deploy --root {}`; the application does not need to be redeployed.",
root.display()
)
}
fn vercel_build_args<'a>(local_config: Option<&'a str>, prod: &'a [&'a str]) -> Vec<&'a str> {
let mut args = vec!["build"];
if let Some(path) = local_config {
args.extend(["--local-config", path]);
}
args.extend_from_slice(prod);
args
}
fn bundle_function(config_path: &Path, build_dir: &Path) -> Result<(), String> {
let text = fs::read_to_string(config_path)
.map_err(|error| format!("cannot read {}: {error}", config_path.display()))?;
let mut config: Value = serde_json::from_str(&text)
.map_err(|error| format!("{}: {error}", config_path.display()))?;
let handler = config["handler"]
.as_str()
.unwrap_or("executable")
.to_string();
let Some(source) = config["filePathMap"][&handler]
.as_str()
.map(|source| build_dir.join(source))
else {
return Ok(());
};
if !source.is_file() {
return Err(format!(
"function executable does not exist: {}",
source.display()
));
}
let destination = config_path.parent().unwrap().join(&handler);
fs::copy(&source, &destination).map_err(|error| {
format!(
"cannot copy {} → {}: {error}",
source.display(),
destination.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&destination)
.map_err(|e| e.to_string())?
.permissions();
perms.set_mode(perms.mode() | 0o111);
fs::set_permissions(&destination, perms).map_err(|e| e.to_string())?;
}
config.as_object_mut().unwrap().remove("filePathMap");
fs::write(
config_path,
format!("{}\n", serde_json::to_string_pretty(&config).unwrap()),
)
.map_err(|error| format!("cannot write {}: {error}", config_path.display()))?;
eprintln!(
"==> bundled {} as {}",
source.display(),
destination.display()
);
Ok(())
}
fn find_files(dir: &Path, name: &str) -> Vec<PathBuf> {
let mut found = Vec::new();
let Ok(entries) = fs::read_dir(dir) else {
return found;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
found.extend(find_files(&path, name));
} else if path.file_name().and_then(|n| n.to_str()) == Some(name) {
found.push(path);
}
}
found
}
fn run(cwd: &Path, envs: &[(&str, PathBuf)], program: &str, args: &[&str]) -> Result<(), String> {
let mut command = Command::new(program);
command.current_dir(cwd).args(args);
for (key, value) in envs {
command.env(key, value);
}
let status = command.status().map_err(|error| {
format!("failed to run {program} (is it installed? `npm i -g vercel`): {error}")
})?;
if status.success() {
Ok(())
} else {
Err(format!("{program} {} failed with {status}", args.join(" ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_uses_generated_local_config() {
assert_eq!(
vercel_build_args(Some("/app/.nextrs/vercel.json"), &["--prod"]),
[
"build",
"--local-config",
"/app/.nextrs/vercel.json",
"--prod"
]
);
}
#[test]
fn cron_failure_preserves_app_success_and_gives_retry_command() {
let root = Path::new("/repo/apps/demo");
let error = cron_recovery_error(root, "cloudflare: set schedules failed");
assert!(error.contains("Vercel application deployed successfully"));
assert!(error.contains("Cloudflare cron deployment failed"));
assert!(error.contains("nextrs cron deploy --root /repo/apps/demo"));
assert!(error.contains("does not need to be redeployed"));
}
#[test]
fn bundle_copies_executable_and_drops_file_path_map() {
let dir = std::env::temp_dir().join("nextrs-deploy-test-bundle");
let _ = fs::remove_dir_all(&dir);
let func = dir.join("api/index.func");
fs::create_dir_all(&func).unwrap();
let binary = dir.join("target/release/index");
fs::create_dir_all(binary.parent().unwrap()).unwrap();
fs::write(&binary, b"#!/bin/sh\n").unwrap();
let config = func.join(".vc-config.json");
fs::write(
&config,
serde_json::json!({
"handler": "bootstrap",
"filePathMap": { "bootstrap": "target/release/index" }
})
.to_string(),
)
.unwrap();
bundle_function(&config, &dir).unwrap();
assert!(func.join("bootstrap").is_file());
let rewritten: Value = serde_json::from_str(&fs::read_to_string(&config).unwrap()).unwrap();
assert!(rewritten.get("filePathMap").is_none());
assert_eq!(rewritten["handler"], "bootstrap");
assert_eq!(find_files(&dir, ".vc-config.json").len(), 1);
}
}