feat: add deploy from current work directory command

This commit is contained in:
2026-08-25 11:08:11 +05:00
parent 5126e10e9d
commit 608dc5f624
4 changed files with 98 additions and 8 deletions
+5
View File
@@ -24,6 +24,7 @@ xboctploy --list-servers # показать серверы из конфига
xboctploy --pick # интерактивно выбрать проект (нечёткий поиск)
xboctploy --dry-run demo # напечатать план деплоя без выполнения
xboctploy demo # задеплоить проект demo
xboctploy --this # задеплоить проект текущей папки (глобальный конфиг)
```
Вывод:
@@ -44,6 +45,10 @@ Deploy successful! (total 2.3s)
2. `./deploy.toml` в текущей папке — удобно держать рядом с проектом и в тестах;
3. глобальный `~/.config/xboctploy/deploy.toml` — запуск «из любой папки».
`--this` деплоит проект, чей `workdir` совпадает с текущей папкой; конфиг
ищется по обычной цепочке, так что временный локальный `./deploy.toml`
переопределяет глобальный — создали файл, задеплоились, удалили.
Схема:
```toml
+4 -1
View File
@@ -6,7 +6,7 @@ use clap::{ArgGroup, Parser};
#[derive(Debug, Parser)]
#[command(name = "xboctploy", version, about)]
#[command(arg_required_else_help = true)]
#[command(group = ArgGroup::new("target").required(true).args(["project", "list", "list_servers", "pick"]))]
#[command(group = ArgGroup::new("target").required(true).args(["project", "list", "list_servers", "pick", "this"]))]
pub struct Cli {
/// Project name from deploy.toml
pub project: Option<String>,
@@ -25,4 +25,7 @@ pub struct Cli {
/// Interactively pick a project to deploy (fuzzy search)
#[arg(long)]
pub pick: bool,
/// Deploy the project whose workdir matches the current folder (global config)
#[arg(long)]
pub this: bool,
}
+67 -5
View File
@@ -168,16 +168,42 @@ pub fn expand_tilde(path: &Path) -> Result<PathBuf> {
Ok(path.to_path_buf())
}
pub fn display(path: &Path) -> String {
let Some(home) = home::home_dir() else {
return path.display().to_string();
};
let Ok(rest) = path.strip_prefix(&home) else {
return path.display().to_string();
};
format!("~/{}", rest.to_string_lossy().replace('\\', "/"))
}
pub fn global_path(home: Option<&Path>) -> Option<PathBuf> {
let path = home?
.join(".config")
.join(GLOBAL_DIR)
.join(CONFIG_FILE_NAME);
path.is_file().then_some(path)
}
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)
global_path(home)
}
pub fn find_by_cwd<'a>(cfg: &'a Config, cwd: &Path) -> Option<&'a str> {
let current = fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
cfg.projects.iter().find_map(|(name, project)| {
let wd = canonicalized(&project.workdir);
(wd == current).then_some(name.as_str())
})
}
fn canonicalized(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
pub fn resolve(explicit: Option<&Path>) -> Result<PathBuf> {
@@ -383,6 +409,18 @@ commands = []
);
}
#[test]
fn display_abbreviates_home_with_tilde() {
let home = home::home_dir().unwrap();
let inside = home.join(".config").join(GLOBAL_DIR).join(CONFIG_FILE_NAME);
assert_eq!(
display(&inside),
format!("~/.config/{GLOBAL_DIR}/{CONFIG_FILE_NAME}")
);
let outside = home.ancestors().nth(1).unwrap();
assert_eq!(display(outside), outside.display().to_string());
}
fn tmpdir(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("xboctploy-tests-{}-{tag}", std::process::id()));
@@ -424,4 +462,28 @@ commands = []
assert_eq!(pick_config(&cwd, Some(&root.join("nope"))), None);
let _ = fs::remove_dir_all(root);
}
#[test]
fn finds_project_whose_workdir_matches_cwd() {
let root = tmpdir("find-cwd");
let proj = root.join("proj");
fs::create_dir_all(&proj).unwrap();
let s = format!(
r#"
[projects.web]
workdir = '{}'
host = "vps"
[projects.api]
workdir = '~/code/api'
host = "vps"
"#,
proj.display()
);
let cfg = toml::from_str::<Config>(&s).unwrap().validate().unwrap();
assert_eq!(find_by_cwd(&cfg, &proj), Some("web"));
assert_eq!(find_by_cwd(&cfg, &root.join("other")), None);
let _ = fs::remove_dir_all(root);
}
}
+22 -2
View File
@@ -28,11 +28,15 @@ fn main() -> ExitCode {
}
fn run(cli: &cli::Cli) -> Result<()> {
if cli.this {
return deploy_this(cli.dry_run);
}
let path = config::resolve(cli.config.as_deref())?;
let cfg = config::load(&path)?;
if cli.list_servers {
println!("config: {}", path.display());
println!("config: {}", config::display(&path));
println!("configured servers:");
for (name, server) in &cfg.servers {
let mut line = format!(" - {name} {}", server.host);
@@ -48,7 +52,7 @@ fn run(cli: &cli::Cli) -> Result<()> {
}
if cli.list {
println!("config: {}", path.display());
println!("config: {}", config::display(&path));
println!("configured projects:");
for name in cfg.projects.keys() {
println!(" - {name}");
@@ -74,6 +78,22 @@ fn run(cli: &cli::Cli) -> Result<()> {
deploy::deploy(&name, project, cli.dry_run)
}
fn deploy_this(dry_run: bool) -> Result<()> {
let path = config::resolve(None)?;
let cfg = config::load(&path)?;
let cwd = std::env::current_dir().context("cannot determine current directory")?;
let Some(name) = config::find_by_cwd(&cfg, &cwd) else {
bail!(
"no project in {} has workdir matching {}",
config::display(&path),
cwd.display()
);
};
println!("config: {}", config::display(&path));
deploy::deploy(name, &cfg.projects[name], dry_run)
}
fn pick_project(cfg: &config::Config) -> Result<Option<String>> {
use dialoguer::FuzzySelect;