diff --git a/Cargo.lock b/Cargo.lock index 2b7b6c9..4463a86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -451,7 +451,7 @@ dependencies = [ [[package]] name = "git-task" -version = "1.0.4" +version = "1.0.5" dependencies = [ "anyhow", "assert_cmd", @@ -464,6 +464,7 @@ dependencies = [ "evalexpr", "git2", "inquire", + "libc", "predicates", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 7649064..40f17e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "git-task" -version = "1.0.4" +version = "1.0.5" edition = "2021" [lib] @@ -31,6 +31,7 @@ clap_mangen = "0.2" crossterm = { version = "0.29", default-features = false } inquire = { version = "0.9", features = ["date"] } chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] } +libc = "0.2" [dev-dependencies] tempfile = "3" diff --git a/src/cli/help.rs b/src/cli/help.rs index 07fc74e..51e51cc 100644 --- a/src/cli/help.rs +++ b/src/cli/help.rs @@ -14,7 +14,7 @@ const CATEGORIES: &[(&str, &[&str])] = &[ ("Sync", &["clone", "push", "pull"]), ("Repos & Projects", &["register", "unregister", "repos", "projects"]), ("Config", &["key", "fields", "automation"]), - ("Other", &["completions", "man", "skills", "help"]), + ("Other", &["completions", "man", "skills", "web", "help"]), ]; /// Builds the full `--help` / `-h` text: banner on top, then commands grouped into diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 14e135c..bb78915 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -31,6 +31,7 @@ mod sync_worker; mod target_repo; mod unregister; mod version; +mod web; mod whoami; mod wizard; @@ -115,6 +116,8 @@ enum Command { Man(man::ManArgs), /// Install this tool's bundled coding-agent skills (SKILL.md) into agent skill directories Skills(skills::SkillsArgs), + /// Manage the background web UI server (git-task-web): install, start, stop, status + Web(web::WebArgs), /// Show what identity a write would be attributed to (repo/global/effective config layers) Whoami(whoami::WhoamiArgs), /// Internal: detached background worker spawned by the `auto-sync` built-in automation. @@ -174,6 +177,7 @@ impl Cli { Command::Completions(args) => completions::run(args, bin_name), Command::Man(args) => man::run(args, bin_name), Command::Skills(args) => dispatch!("skills", skills::run(args)), + Command::Web(args) => dispatch!("web", web::run(args)), Command::Whoami(args) => dispatch!("whoami", whoami::run(args)), // Bypasses `dispatch!` deliberately — no JSON envelope, no output of any kind. Command::SyncWorker(args) => { diff --git a/src/cli/web.rs b/src/cli/web.rs new file mode 100644 index 0000000..35d57ce --- /dev/null +++ b/src/cli/web.rs @@ -0,0 +1,224 @@ +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::Path; +use std::time::{Duration, Instant}; + +use anyhow::{bail, Result}; +use clap::{Args, Subcommand}; +use serde::Serialize; + +use crate::logger::Logger; +use crate::output; +use crate::prompt; +use crate::ui; +use crate::web::{install, paths, process}; + +const DEFAULT_HOST: &str = "127.0.0.1"; +const DEFAULT_PORT: u16 = 4600; + +#[derive(Args)] +pub struct WebArgs { + #[command(subcommand)] + action: WebAction, +} + +#[derive(Subcommand)] +enum WebAction { + /// Start the web UI server in the background (installs it first if needed) + Start(StartArgs), + /// Stop the background web UI server + Stop(StopArgs), + /// Show whether the web UI server is running + Status(StatusArgs), +} + +#[derive(Args)] +pub struct StartArgs { + /// Port to serve on (defaults to git-task-web's own default, 4600) + #[arg(long)] + port: Option, + /// Host/address to bind (defaults to git-task-web's own default, 127.0.0.1) + #[arg(long)] + host: Option, + /// Install git-task-web without an interactive prompt, if it isn't installed yet + #[arg(short = 'y', long)] + yes: bool, +} + +#[derive(Args)] +pub struct StopArgs {} + +#[derive(Args)] +pub struct StatusArgs {} + +#[derive(Serialize)] +struct WebStatusJson { + running: bool, + pid: Option, + url: Option, + log: String, +} + +pub fn run(args: WebArgs) -> Result<()> { + match args.action { + WebAction::Start(a) => start(a), + WebAction::Stop(a) => stop(a), + WebAction::Status(a) => status(a), + } +} + +fn start(args: StartArgs) -> Result<()> { + let state_path = paths::state_path()?; + let log_path = paths::log_path()?; + + if let Some(state) = process::read_state(&state_path) { + if process::is_alive(state.pid) { + let url = format!("http://{}:{}", state.host, state.port); + Logger::info(&format!("Already running at {url} (pid {})", state.pid), None, &[]); + if output::is_json() { + output::print_ok(WebStatusJson { + running: true, + pid: Some(state.pid), + url: Some(url), + log: log_path.display().to_string(), + }); + } + return Ok(()); + } + // Stale state file from a crashed/killed process — clean it up and spawn fresh below. + let _ = process::remove_state(&state_path); + } + + if !install::is_installed()? { + let proceed = if args.yes { + true + } else if output::is_json() { + bail!("git-task-web isn't installed yet — re-run with --yes to install it non-interactively"); + } else if !prompt::is_interactive() { + bail!("git-task-web isn't installed yet — re-run with --yes to install it"); + } else { + ui::prompt_confirm("git-task-web isn't installed yet. Install it now via npm?", true)? + }; + + if !proceed { + Logger::info("Skipped install. Re-run with --yes (or accept the prompt) when you're ready.", None, &[]); + return Ok(()); + } + + Logger::info("Installing git-task-web via npm...", None, &[]); + install::install()?; + Logger::info("Installed.", None, &[]); + } + + let host = args.host.clone().unwrap_or_else(|| DEFAULT_HOST.to_string()); + let port = args.port.unwrap_or(DEFAULT_PORT); + let cli_js = paths::cli_js_path()?; + + let pid = process::spawn(&cli_js, &log_path, args.port, args.host.as_deref())?; + process::write_state(&state_path, &process::WebState { pid, host: host.clone(), port })?; + + let ready = wait_for_port(&host, port, Duration::from_secs(10)); + let url = format!("http://{host}:{port}"); + + if ready { + Logger::info(&format!("Started git-task-web at {url} (pid {pid})"), None, &[]); + } else { + Logger::warn( + &format!("Spawned git-task-web (pid {pid}) but it didn't come up within 10s"), + Some(&format!("check {}", log_path.display())), + &[], + ); + } + + if output::is_json() { + output::print_ok(WebStatusJson { + running: ready, + pid: Some(pid), + url: ready.then_some(url), + log: log_path.display().to_string(), + }); + } + Ok(()) +} + +fn stop(_args: StopArgs) -> Result<()> { + let state_path = paths::state_path()?; + let log_path = paths::log_path()?; + + let Some(state) = process::read_state(&state_path) else { + Logger::info("Not running.", None, &[]); + if output::is_json() { + output::print_ok(not_running_json(&log_path)); + } + return Ok(()); + }; + + if !process::is_alive(state.pid) { + let _ = process::remove_state(&state_path); + Logger::info("Not running (cleared a stale state file).", None, &[]); + if output::is_json() { + output::print_ok(not_running_json(&log_path)); + } + return Ok(()); + } + + process::stop(state.pid)?; + process::remove_state(&state_path)?; + Logger::info(&format!("Stopped git-task-web (pid {}).", state.pid), None, &[]); + if output::is_json() { + output::print_ok(not_running_json(&log_path)); + } + Ok(()) +} + +fn status(_args: StatusArgs) -> Result<()> { + let state_path = paths::state_path()?; + let log_path = paths::log_path()?; + + let Some(state) = process::read_state(&state_path) else { + Logger::info("Not running.", None, &[]); + if output::is_json() { + output::print_ok(not_running_json(&log_path)); + } + return Ok(()); + }; + + if !process::is_alive(state.pid) { + let _ = process::remove_state(&state_path); + Logger::info("Not running (cleared a stale state file).", None, &[]); + if output::is_json() { + output::print_ok(not_running_json(&log_path)); + } + return Ok(()); + } + + let url = format!("http://{}:{}", state.host, state.port); + Logger::info(&format!("Running at {url} (pid {}). Log: {}", state.pid, log_path.display()), None, &[]); + if output::is_json() { + output::print_ok(WebStatusJson { + running: true, + pid: Some(state.pid), + url: Some(url), + log: log_path.display().to_string(), + }); + } + Ok(()) +} + +fn not_running_json(log_path: &Path) -> WebStatusJson { + WebStatusJson { running: false, pid: None, url: None, log: log_path.display().to_string() } +} + +fn wait_for_port(host: &str, port: u16, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if let Ok(mut addrs) = (host, port).to_socket_addrs() { + if let Some(addr) = addrs.next() { + if TcpStream::connect_timeout(&addr, Duration::from_millis(300)).is_ok() { + return true; + } + } + } + std::thread::sleep(Duration::from_millis(200)); + } + false +} diff --git a/src/config/global.rs b/src/config/global.rs index d7dbcd6..867e2dc 100644 --- a/src/config/global.rs +++ b/src/config/global.rs @@ -282,3 +282,24 @@ pub fn config_dir() -> Result { fn config_path() -> Result { Ok(config_dir()?.join(CONFIG_FILE)) } + +/// `${GIT_TASK_DATA_DIR}` > `${XDG_DATA_HOME}/git-task` > `~/.local/share/git-task`. Holds +/// machine-local *runtime* state that isn't config — currently just `git task web`'s install, +/// PID/host/port, and log (see `crate::web::paths`). Mirrors `config_dir()`'s precedence shape +/// but is deliberately a separate directory (XDG data home, not config home), same distinction +/// XDG itself draws. +pub fn data_dir() -> Result { + if let Ok(dir) = std::env::var("GIT_TASK_DATA_DIR") { + return Ok(PathBuf::from(dir)); + } + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + if !xdg.is_empty() { + return Ok(PathBuf::from(xdg).join("git-task")); + } + } + let home = directories::BaseDirs::new() + .context("could not determine home directory")? + .home_dir() + .to_path_buf(); + Ok(home.join(".local").join("share").join("git-task")) +} diff --git a/src/lib.rs b/src/lib.rs index 7f6959a..322b5c2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ pub mod table; pub mod wrap; pub mod style; pub mod ui; +pub mod web; use clap::{CommandFactory, FromArgMatches}; diff --git a/src/ui.rs b/src/ui.rs index 1fd54d0..2c30789 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -4,7 +4,7 @@ use std::sync::OnceLock; use anyhow::Result; use chrono::NaiveDate; use inquire::ui::{calendar::CalendarRenderConfig, Attributes, Color, RenderConfig, StyleSheet, Styled}; -use inquire::{DateSelect, InquireError, Select, Text}; +use inquire::{Confirm, DateSelect, InquireError, Select, Text}; use crate::color; use crate::table; @@ -83,6 +83,12 @@ pub fn prompt_select(label: &str, options: Vec, current_index: us map_result(prompt.prompt()) } +/// Yes/no prompt, pre-highlighted on `default_val` — pressing enter accepts it. +pub fn prompt_confirm(label: &str, default_val: bool) -> Result { + let prompt = Confirm::new(label).with_default(default_val).with_render_config(theme()); + map_result(prompt.prompt()) +} + /// Arrow-key calendar picker, defaulting to today. `label` is shown above the calendar grid; /// month/day/year navigation and the min/max-date bounds are inquire's own defaults. pub fn prompt_date(label: &str) -> Result { diff --git a/src/web/install.rs b/src/web/install.rs new file mode 100644 index 0000000..5f8865a --- /dev/null +++ b/src/web/install.rs @@ -0,0 +1,65 @@ +use std::process::{Command, Stdio}; + +use anyhow::{bail, Context, Result}; + +use crate::web::paths; + +pub fn is_installed() -> Result { + Ok(paths::cli_js_path()?.exists()) +} + +/// Checks that `node`/`npm` are both on `PATH`, with an actionable error naming exactly what's +/// missing rather than surfacing npm's own much less clear failure mode. +fn require_prereqs() -> Result<()> { + for bin in ["node", "npm"] { + let found = + Command::new(bin).arg("--version").stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok(); + if !found { + bail!("`{bin}` isn't on PATH — install Node.js >= 20 from https://nodejs.org, then re-run this command"); + } + } + Ok(()) +} + +/// Installs git-task-web via `npm install --prefix git-task-web@latest`. +/// +/// In text mode, npm's own progress is streamed straight to the terminal (inherited stdio) — +/// this is a foreground, user-invoked, one-time setup step, unlike `sync`'s fully-silent +/// background worker. In `--format json` mode that same output would land on the same stdout as +/// the JSON envelope and corrupt it, so it's captured to `web.log` instead. +pub fn install() -> Result<()> { + require_prereqs()?; + let dir = paths::install_dir()?; + std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; + + let mut cmd = Command::new("npm"); + cmd.args(["install", "--prefix"]).arg(&dir).arg("git-task-web@latest"); + + let status = if crate::output::is_json() { + let log_path = paths::log_path()?; + let log_out = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .with_context(|| format!("opening {}", log_path.display()))?; + let log_err = log_out.try_clone().with_context(|| format!("opening {}", log_path.display()))?; + cmd.stdout(log_out).stderr(log_err).status() + } else { + cmd.status() + } + .context("running `npm install`")?; + + if !status.success() { + bail!("`npm install` failed (exit code {:?}) — see output above", status.code()); + } + + let cli_js = paths::cli_js_path()?; + if !cli_js.exists() { + bail!( + "npm install succeeded but {} is missing — git-task-web's package layout may have changed", + cli_js.display() + ); + } + + Ok(()) +} diff --git a/src/web/mod.rs b/src/web/mod.rs new file mode 100644 index 0000000..a5a9e6a --- /dev/null +++ b/src/web/mod.rs @@ -0,0 +1,9 @@ +//! Background-process machinery for `git task web` (install, spawn/stop/status of the companion +//! git-task-web server) — kept out of `automation`/`sync` because it isn't triggered by any +//! event or op batch, just directly invoked by `cli::web`. Repo-agnostic: unlike `sync`'s +//! per-repo worker, this manages a single server per machine, so its state lives under +//! `config::global::data_dir()` rather than any repo's `/git-task/`. + +pub mod install; +pub mod paths; +pub mod process; diff --git a/src/web/paths.rs b/src/web/paths.rs new file mode 100644 index 0000000..90ec802 --- /dev/null +++ b/src/web/paths.rs @@ -0,0 +1,30 @@ +use std::path::PathBuf; + +use anyhow::Result; + +use crate::config::global; + +/// Where git-task-web gets installed (`npm install --prefix`), under the shared global data +/// directory. +pub fn install_dir() -> Result { + Ok(global::data_dir()?.join("web")) +} + +/// The installed entrypoint npm lands the package's `bin` target at. Existence of this file *is* +/// "is it installed" — checked fresh each call rather than tracked as separate state that could +/// go stale. +pub fn cli_js_path() -> Result { + Ok(install_dir()?.join("node_modules").join("git-task-web").join("dist").join("server").join("cli.js")) +} + +/// ` ` of the running server, written by `start`, read by `stop`/`status`. +pub fn state_path() -> Result { + Ok(global::data_dir()?.join("web.state")) +} + +/// Combined stdout+stderr of the spawned server (and, in `--format json` mode, of the `npm +/// install` step too — see `install::install`). Kept, unlike the fully-silent `sync` worker, +/// since this is a user-invoked, long-lived, debuggable process. +pub fn log_path() -> Result { + Ok(global::data_dir()?.join("web.log")) +} diff --git a/src/web/process.rs b/src/web/process.rs new file mode 100644 index 0000000..0133636 --- /dev/null +++ b/src/web/process.rs @@ -0,0 +1,165 @@ +use std::fs::{self, File, OpenOptions}; +use std::io; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use anyhow::{Context, Result}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebState { + pub pid: u32, + pub host: String, + pub port: u16, +} + +/// Reads ` ` from `path`. `None` if the file is missing or malformed — a +/// corrupt/truncated file is treated the same as "not running," and cleaned up by the caller. +pub fn read_state(path: &Path) -> Option { + let text = fs::read_to_string(path).ok()?; + let mut parts = text.split_whitespace(); + let pid = parts.next()?.parse().ok()?; + let host = parts.next()?.to_string(); + let port = parts.next()?.parse().ok()?; + Some(WebState { pid, host, port }) +} + +pub fn write_state(path: &Path, state: &WebState) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, format!("{} {} {}", state.pid, state.host, state.port)) +} + +pub fn remove_state(path: &Path) -> io::Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } +} + +/// Spawns `node ` detached — reparented (not killed) when this process exits, same as +/// `sync::trigger`'s spawn — redirecting stdout+stderr to `log_path` (kept, unlike `sync`'s fully +/// nulled streams: this is a user-visible long-lived server whose output is worth keeping for +/// `stop`/`status` follow-up debugging). Returns the child's PID so the caller can persist it. +pub fn spawn(cli_js: &Path, log_path: &Path, port: Option, host: Option<&str>) -> Result { + if let Some(parent) = log_path.parent() { + fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + } + let log_out = OpenOptions::new() + .create(true) + .append(true) + .open(log_path) + .with_context(|| format!("opening {}", log_path.display()))?; + let log_err: File = log_out.try_clone().with_context(|| format!("opening {}", log_path.display()))?; + + let mut cmd = Command::new("node"); + cmd.arg(cli_js).stdin(Stdio::null()).stdout(log_out).stderr(log_err); + if let Some(port) = port { + cmd.env("GIT_TASK_WEB_PORT", port.to_string()); + } + if let Some(host) = host { + cmd.env("GIT_TASK_WEB_HOST", host); + } + + // A plain `spawn()` leaves the child in this process's process group/session — fine for + // `sync::trigger`'s short-lived worker (it finishes before anyone notices), fatal for a + // long-lived server: the invoking shell exiting (or, e.g., a job-control/terminal-close + // SIGHUP) can take the whole group down, child included. `setsid()` in the child before + // `exec` gives it its own session so it survives the parent CLI process exiting, same as + // real daemonization. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + unsafe { + cmd.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + } + + let child = cmd.spawn().context("spawning `node` for git-task-web")?; + Ok(child.id()) +} + +/// Signal 0 sends nothing — `kill(2)` still validates that a process with this PID exists (and +/// is signalable by us), which is exactly the liveness check needed for a stale-PID-file check. +#[cfg(unix)] +pub fn is_alive(pid: u32) -> bool { + unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } +} + +#[cfg(not(unix))] +pub fn is_alive(_pid: u32) -> bool { + false +} + +/// `SIGTERM`, then escalate to `SIGKILL` if it hasn't exited within 5s. +#[cfg(unix)] +pub fn stop(pid: u32) -> io::Result<()> { + unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) }; + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while is_alive(pid) && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + } + if is_alive(pid) { + unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) }; + } + Ok(()) +} + +#[cfg(not(unix))] +pub fn stop(_pid: u32) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Unsupported, "stopping the web server isn't supported on this platform yet")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn state_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("web.state"); + assert_eq!(read_state(&path), None); + + let state = WebState { pid: 4242, host: "127.0.0.1".to_string(), port: 4600 }; + write_state(&path, &state).unwrap(); + assert_eq!(read_state(&path), Some(state)); + + remove_state(&path).unwrap(); + assert_eq!(read_state(&path), None); + } + + #[test] + fn corrupt_state_file_reads_as_none() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("web.state"); + fs::write(&path, "not-a-pid").unwrap(); + assert_eq!(read_state(&path), None); + } + + #[test] + fn remove_state_missing_file_is_ok() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("web.state"); + assert!(remove_state(&path).is_ok()); + } + + #[cfg(unix)] + #[test] + fn current_process_is_alive() { + assert!(is_alive(std::process::id())); + } + + #[cfg(unix)] + #[test] + fn exited_process_is_not_alive() { + let mut child = Command::new("true").spawn().expect("spawn `true`"); + let pid = child.id(); + child.wait().unwrap(); + assert!(!is_alive(pid)); + } +}