use std::path::PathBuf; use std::process::Command; use anyhow::{Context, Result, bail}; use clap::{Parser, ValueEnum}; use serde::Serialize; #[derive(Debug, Clone, Copy, ValueEnum, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] enum InstallKind { Client, RemoteWorker, ConsoleSsh, WebCategory, PveWebadmin, } #[derive(Debug, Parser)] #[command(about = "Safe planner/apply wrapper for AW Linux install scripts")] struct Cli { #[arg(long, value_enum)] kind: InstallKind, #[arg(long)] legacy_script: PathBuf, #[arg(long, default_value = "192.0.2.13")] server_host: String, #[arg(long, default_value = "5600")] server_port: String, #[arg(long, default_value = "5")] poll_interval: String, #[arg(long, default_value = "0.13.2")] version: String, #[arg(long)] install_base: Option, #[arg(long, default_value_t = false)] force: bool, #[arg(long, default_value_t = false)] apply: bool, #[arg(long, default_value_t = false)] json: bool, } #[derive(Debug, Serialize)] struct Plan { apply: bool, kind: InstallKind, legacy_script: PathBuf, required_files: Vec, steps: Vec, missing_count: usize, } #[derive(Debug, Serialize)] struct Requirement { name: String, ok: bool, detail: String, } #[derive(Debug, Serialize)] struct Step { order: usize, name: String, mutation: bool, command: Vec, summary: String, } fn main() { let code = match run() { Ok(code) => code, Err(err) => { eprintln!("{err:#}"); 1 } }; std::process::exit(code); } fn run() -> Result { let cli = Cli::parse(); let plan = build_plan(&cli); if !cli.apply { print_plan(&plan, cli.json)?; return Ok(if plan.missing_count == 0 { 0 } else { 2 }); } if plan.missing_count > 0 { print_plan(&plan, cli.json)?; bail!("refusing --apply because required files are missing"); } let Some(step) = plan.steps.first() else { bail!("empty install plan"); }; let Some(program) = step.command.first() else { bail!("empty legacy command"); }; let status = Command::new(program) .args(&step.command[1..]) .status() .with_context(|| format!("run {}", shell_join(&step.command)))?; Ok(status.code().unwrap_or(1)) } fn build_plan(cli: &Cli) -> Plan { let required_files = vec![Requirement { name: "legacy_script".to_string(), ok: cli.legacy_script.is_file(), detail: cli.legacy_script.display().to_string(), }]; let command = legacy_command(cli); let steps = vec![Step { order: 1, name: format!("{:?}", cli.kind).to_lowercase(), mutation: true, summary: summary(cli.kind).to_string(), command, }]; let missing_count = required_files.iter().filter(|item| !item.ok).count(); Plan { apply: cli.apply, kind: cli.kind, legacy_script: cli.legacy_script.clone(), required_files, steps, missing_count, } } fn legacy_command(cli: &Cli) -> Vec { let mut command = vec![ "sh".to_string(), cli.legacy_script.display().to_string(), "--apply-legacy".to_string(), "--server-host".to_string(), cli.server_host.clone(), "--server-port".to_string(), cli.server_port.clone(), ]; match cli.kind { InstallKind::Client => { command.extend(["--version".to_string(), cli.version.clone()]); if let Some(path) = &cli.install_base { command.extend(["--install-base".to_string(), path.display().to_string()]); } if cli.force { command.push("--force".to_string()); } } InstallKind::RemoteWorker => { command.extend([ "--poll-interval".to_string(), cli.poll_interval.clone(), "--version".to_string(), cli.version.clone(), ]); } InstallKind::ConsoleSsh | InstallKind::WebCategory | InstallKind::PveWebadmin => { command.extend(["--poll-interval".to_string(), cli.poll_interval.clone()]); } } command } fn summary(kind: InstallKind) -> &'static str { match kind { InstallKind::Client => { "Install ActivityWatch Linux GUI watcher bundle and remote server config" } InstallKind::RemoteWorker => { "Install Linux client, console/SSH logger, and web category logger" } InstallKind::ConsoleSsh => "Install console command and SSH session logger", InstallKind::WebCategory => "Install Linux web category logger", InstallKind::PveWebadmin => "Install Proxmox webadmin logger service", } } fn print_plan(plan: &Plan, json: bool) -> Result<()> { if json { println!("{}", serde_json::to_string_pretty(plan)?); return Ok(()); } println!( "aw-linux-install: {}", if plan.apply { "apply" } else { "dry-run" } ); println!("kind: {:?}", plan.kind); println!("legacy_script: {}", plan.legacy_script.display()); println!("missing_inputs: {}", plan.missing_count); println!("required files:"); for item in &plan.required_files { println!( " [{}] {} - {}", if item.ok { "OK" } else { "MISS" }, item.name, item.detail ); } println!("planned steps:"); for step in &plan.steps { println!( " {:02}. MUTATION {} :: {}", step.order, step.summary, shell_join(&step.command) ); } if !plan.apply { println!("No install executed. Use --apply for explicit legacy install execution."); } Ok(()) } fn shell_join(command: &[String]) -> String { command .iter() .map(|part| { if part .chars() .all(|ch| ch.is_ascii_alphanumeric() || "-_./:=,".contains(ch)) { part.clone() } else { format!("'{}'", part.replace('\'', "'\\''")) } }) .collect::>() .join(" ") } #[cfg(test)] mod tests { use super::*; #[test] fn client_plan_preserves_version_and_force() { let dir = tempfile::tempdir().unwrap(); let script = dir.path().join("install.sh"); std::fs::write(&script, "#!/bin/sh\n").unwrap(); let cli = Cli { kind: InstallKind::Client, legacy_script: script, server_host: "192.0.2.13".to_string(), server_port: "5600".to_string(), poll_interval: "5".to_string(), version: "0.13.2".to_string(), install_base: Some(PathBuf::from("/tmp/aw")), force: true, apply: false, json: false, }; let plan = build_plan(&cli); let cmd = &plan.steps[0].command; assert!(cmd.contains(&"--apply-legacy".to_string())); assert!(cmd.contains(&"--install-base".to_string())); assert!(cmd.contains(&"--force".to_string())); } #[test] fn remote_worker_plan_includes_poll_and_version() { let dir = tempfile::tempdir().unwrap(); let script = dir.path().join("install.sh"); std::fs::write(&script, "#!/bin/sh\n").unwrap(); let cli = Cli { kind: InstallKind::RemoteWorker, legacy_script: script, server_host: "host".to_string(), server_port: "5600".to_string(), poll_interval: "9".to_string(), version: "0.13.3".to_string(), install_base: None, force: false, apply: false, json: false, }; let plan = build_plan(&cli); let joined = shell_join(&plan.steps[0].command); assert!(joined.contains("--poll-interval 9")); assert!(joined.contains("--version 0.13.3")); } }