feat: initial cli and config
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{ArgGroup, Parser};
|
||||
|
||||
/// Deploy hobby projects from a local PC to a VPS over SSH.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "xboctploy", version, about)]
|
||||
#[command(group = ArgGroup::new("target").required(true).args(["project", "list"]))]
|
||||
pub struct Cli {
|
||||
/// Project name from deploy.toml
|
||||
pub project: Option<String>,
|
||||
/// Explicit path to the config file
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub config: Option<PathBuf>,
|
||||
/// Print planned steps without executing anything
|
||||
#[arg(long)]
|
||||
pub dry_run: bool,
|
||||
/// List configured projects and exit
|
||||
#[arg(long)]
|
||||
pub list: bool,
|
||||
}
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub const CONFIG_FILE_NAME: &str = "deploy.toml";
|
||||
pub const GLOBAL_DIR: &str = "xboctploy";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub projects: BTreeMap<String, Project>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Project {
|
||||
pub workdir: PathBuf,
|
||||
pub host: String,
|
||||
pub user: Option<String>,
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
pub key_path: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub local: Option<Commands>,
|
||||
#[serde(default)]
|
||||
pub sync: Vec<SyncRule>,
|
||||
#[serde(default)]
|
||||
pub remote: Option<Commands>,
|
||||
}
|
||||
|
||||
fn default_port() -> u16 {
|
||||
22
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Commands {
|
||||
pub commands: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SyncRule {
|
||||
pub source: PathBuf,
|
||||
pub target: PathBuf,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn validate(mut self) -> Result<Self> {
|
||||
if self.projects.is_empty() {
|
||||
bail!("no [projects.*] sections defined");
|
||||
}
|
||||
for (name, project) in &mut self.projects {
|
||||
if project.host.trim().is_empty() {
|
||||
bail!("project '{name}': host must not be empty");
|
||||
}
|
||||
for (phase, cmds) in [
|
||||
("local", &mut project.local),
|
||||
("remote", &mut project.remote),
|
||||
] {
|
||||
if let Some(c) = cmds
|
||||
&& c.commands.is_empty()
|
||||
{
|
||||
bail!("project '{name}': [{phase}] section present but 'commands' is empty");
|
||||
}
|
||||
}
|
||||
project.workdir = expand_tilde(&project.workdir)
|
||||
.with_context(|| format!("project '{name}': invalid workdir"))?;
|
||||
if let Some(kp) = &mut project.key_path {
|
||||
*kp = expand_tilde(kp)
|
||||
.with_context(|| format!("project '{name}': invalid key_path"))?;
|
||||
}
|
||||
for rule in &mut project.sync {
|
||||
rule.source = expand_tilde(&rule.source)
|
||||
.with_context(|| format!("project '{name}': invalid sync source"))?;
|
||||
}
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expand_tilde(path: &Path) -> Result<PathBuf> {
|
||||
let Some(home) = home::home_dir() else {
|
||||
return Ok(path.to_path_buf());
|
||||
};
|
||||
let text = path.to_str().context("path is not valid UTF-8")?;
|
||||
if text == "~" {
|
||||
return Ok(home);
|
||||
}
|
||||
if let Some(rest) = text.strip_prefix("~/").or_else(|| text.strip_prefix("~\\")) {
|
||||
return Ok(home.join(rest));
|
||||
}
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
|
||||
fn pick_config(cwd: &Path, home: Option<&Path>) -> Option<PathBuf> {
|
||||
let local = cwd.join(CONFIG_FILE_NAME);
|
||||
if local.is_file() {
|
||||
return Some(local);
|
||||
}
|
||||
let global = home?
|
||||
.join(".config")
|
||||
.join(GLOBAL_DIR)
|
||||
.join(CONFIG_FILE_NAME);
|
||||
global.is_file().then_some(global)
|
||||
}
|
||||
|
||||
pub fn resolve(explicit: Option<&Path>) -> Result<PathBuf> {
|
||||
if let Some(p) = explicit {
|
||||
return p
|
||||
.is_file()
|
||||
.then_some(p.to_path_buf())
|
||||
.with_context(|| format!("config not found: {}", p.display()));
|
||||
}
|
||||
pick_config(Path::new("."), home::home_dir().as_deref()).with_context(|| {
|
||||
format!("{CONFIG_FILE_NAME} not found (looked in ./ and ~/.config/{GLOBAL_DIR}/)")
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Config> {
|
||||
let raw =
|
||||
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
|
||||
let cfg: Config =
|
||||
toml::from_str(&raw).with_context(|| format!("invalid TOML in {}", path.display()))?;
|
||||
cfg.validate()
|
||||
.with_context(|| format!("invalid config {}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(s: &str) -> Result<Config> {
|
||||
toml::from_str::<Config>(s)?.validate()
|
||||
}
|
||||
|
||||
const VALID: &str = r#"
|
||||
[projects.web]
|
||||
workdir = "~/code/web"
|
||||
host = "vps"
|
||||
|
||||
[projects.web.local]
|
||||
commands = ["npm run build"]
|
||||
|
||||
[[projects.web.sync]]
|
||||
source = "dist"
|
||||
target = "/var/www/web"
|
||||
|
||||
[projects.web.remote]
|
||||
commands = ["pm2 restart web"]
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn valid_config_parses_with_defaults() {
|
||||
let cfg = parse(VALID).unwrap();
|
||||
let web = cfg.projects.get("web").unwrap();
|
||||
assert_eq!(web.port, 22);
|
||||
assert!(web.user.is_none());
|
||||
assert_eq!(
|
||||
web.local.as_ref().unwrap().commands,
|
||||
vec!["npm run build".to_string()]
|
||||
);
|
||||
assert_eq!(web.sync.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_toml_is_rejected() {
|
||||
assert!(toml::from_str::<Config>("this is [not toml").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_field_is_rejected() {
|
||||
let s = r#"
|
||||
[projects.web]
|
||||
workdir = "."
|
||||
host = "vps"
|
||||
hostt = "typo"
|
||||
"#;
|
||||
assert!(toml::from_str::<Config>(s).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_projects_table_is_rejected() {
|
||||
let s = "[projects]\n";
|
||||
assert!(parse(s).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_commands_section_is_rejected() {
|
||||
let s = r#"
|
||||
[projects.web]
|
||||
workdir = "."
|
||||
host = "vps"
|
||||
[projects.web.remote]
|
||||
commands = []
|
||||
"#;
|
||||
assert!(parse(s).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_expands_to_home() {
|
||||
let home = home::home_dir().unwrap();
|
||||
assert_eq!(expand_tilde(Path::new("~")).unwrap(), home);
|
||||
assert_eq!(
|
||||
expand_tilde(Path::new("~/.ssh/id_rsa")).unwrap(),
|
||||
home.join(".ssh").join("id_rsa")
|
||||
);
|
||||
assert_eq!(
|
||||
expand_tilde(Path::new("/abs/path")).unwrap(),
|
||||
PathBuf::from("/abs/path")
|
||||
);
|
||||
}
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("xboctploy-tests-{}-{tag}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_config_wins_over_global() {
|
||||
let root = tmpdir("resolve");
|
||||
let cwd = root.join("proj");
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(home.join(".config").join(GLOBAL_DIR)).unwrap();
|
||||
let local = cwd.join(CONFIG_FILE_NAME);
|
||||
let global = home.join(".config").join(GLOBAL_DIR).join(CONFIG_FILE_NAME);
|
||||
fs::write(&global, "").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
pick_config(&cwd, Some(&home)).as_deref(),
|
||||
Some(global.as_path())
|
||||
);
|
||||
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
fs::write(&local, "").unwrap();
|
||||
assert_eq!(
|
||||
pick_config(&cwd, Some(&home)).as_deref(),
|
||||
Some(local.as_path())
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_everywhere_yields_none() {
|
||||
let root = tmpdir("resolve-none");
|
||||
let cwd = root.join("proj");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
assert_eq!(pick_config(&cwd, Some(&root.join("nope"))), None);
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
mod cli;
|
||||
mod config;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use colored::Colorize;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = cli::Cli::parse();
|
||||
match run(&cli) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(err) => {
|
||||
eprintln!("{} {err:#}", "error:".red().bold());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(cli: &cli::Cli) -> Result<()> {
|
||||
let path = config::resolve(cli.config.as_deref())?;
|
||||
let cfg = config::load(&path)?;
|
||||
|
||||
if cli.list {
|
||||
println!("config: {}", path.display());
|
||||
println!("configured projects:");
|
||||
for name in cfg.projects.keys() {
|
||||
println!(" - {name}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let name = cli
|
||||
.project
|
||||
.as_deref()
|
||||
.expect("clap guarantees <PROJECT> or --list");
|
||||
let project = cfg
|
||||
.projects
|
||||
.get(name)
|
||||
.with_context(|| format!("project '{name}' not found in {}", path.display()))?;
|
||||
|
||||
let target = match &project.user {
|
||||
Some(user) => format!("{user}@{}:{}", project.host, project.port),
|
||||
None => format!("{}:{}", project.host, project.port),
|
||||
};
|
||||
println!(
|
||||
"deploying '{name}' to {target}: {} local cmd(s), {} sync rule(s), {} remote cmd(s)",
|
||||
project.local.as_ref().map_or(0, |c| c.commands.len()),
|
||||
project.sync.len(),
|
||||
project.remote.as_ref().map_or(0, |c| c.commands.len()),
|
||||
);
|
||||
if cli.dry_run {
|
||||
println!("dry-run plan:");
|
||||
for cmd in project
|
||||
.local
|
||||
.as_ref()
|
||||
.map_or([].as_slice(), |c| c.commands.as_slice())
|
||||
{
|
||||
println!(" local : {cmd}");
|
||||
}
|
||||
for rule in &project.sync {
|
||||
println!(
|
||||
" sync : {} -> {}",
|
||||
rule.source.display(),
|
||||
rule.target.display()
|
||||
);
|
||||
}
|
||||
for cmd in project
|
||||
.remote
|
||||
.as_ref()
|
||||
.map_or([].as_slice(), |c| c.commands.as_slice())
|
||||
{
|
||||
println!(" remote: {cmd}");
|
||||
}
|
||||
} else {
|
||||
println!("stage 2 not implemented yet: local command execution");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user