From 2135140dfb84040904856839fac1f15e6af13bee Mon Sep 17 00:00:00 2001 From: Mohsen Beiranvand Date: Sat, 8 Aug 2026 14:01:29 +0200 Subject: [PATCH 1/2] Check for a newer git-task-web on start, add web upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start now compares the installed git-task-web against npm's latest (via the same npm binary install already shells out to, no new HTTP client dependency) once it's confirmed installed, and offers to upgrade — --yes/non-interactive/--format json all skip the prompt and just start on the current version, surfacing a warning instead. New `git task web upgrade` subcommand: installs the latest version, stopping and restarting the server around it if one is running so the new version actually takes effect. Works from a clean slate too (nothing installed yet -> plain install). GTASK-2d979d10 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/cli/web.rs | 121 ++++++++++++++++++++++++++++++++++++++++------ src/web/mod.rs | 1 + src/web/update.rs | 66 +++++++++++++++++++++++++ 5 files changed, 176 insertions(+), 16 deletions(-) create mode 100644 src/web/update.rs diff --git a/Cargo.lock b/Cargo.lock index 4463a86..e38d038 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -451,7 +451,7 @@ dependencies = [ [[package]] name = "git-task" -version = "1.0.5" +version = "1.0.6" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 40f17e9..910eb47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "git-task" -version = "1.0.5" +version = "1.0.6" edition = "2021" [lib] diff --git a/src/cli/web.rs b/src/cli/web.rs index 35d57ce..f7dd36d 100644 --- a/src/cli/web.rs +++ b/src/cli/web.rs @@ -10,7 +10,7 @@ use crate::logger::Logger; use crate::output; use crate::prompt; use crate::ui; -use crate::web::{install, paths, process}; +use crate::web::{install, paths, process, update}; const DEFAULT_HOST: &str = "127.0.0.1"; const DEFAULT_PORT: u16 = 4600; @@ -29,6 +29,8 @@ enum WebAction { Stop(StopArgs), /// Show whether the web UI server is running Status(StatusArgs), + /// Update git-task-web to the latest version, restarting it if it's currently running + Upgrade(UpgradeArgs), } #[derive(Args)] @@ -50,6 +52,9 @@ pub struct StopArgs {} #[derive(Args)] pub struct StatusArgs {} +#[derive(Args)] +pub struct UpgradeArgs {} + #[derive(Serialize)] struct WebStatusJson { running: bool, @@ -58,11 +63,19 @@ struct WebStatusJson { log: String, } +#[derive(Serialize)] +struct UpgradeJson { + from: Option, + to: Option, + restarted: bool, +} + pub fn run(args: WebArgs) -> Result<()> { match args.action { WebAction::Start(a) => start(a), WebAction::Stop(a) => stop(a), WebAction::Status(a) => status(a), + WebAction::Upgrade(a) => upgrade(a), } } @@ -107,20 +120,38 @@ fn start(args: StartArgs) -> Result<()> { Logger::info("Installing git-task-web via npm...", None, &[]); install::install()?; Logger::info("Installed.", None, &[]); + } else { + maybe_prompt_upgrade(args.yes)?; } 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, ready) = spawn_and_wait(&state_path, &log_path, host.clone(), port)?; + let url = format!("http://{host}:{port}"); - 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 })?; + 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(()) +} - let ready = wait_for_port(&host, port, Duration::from_secs(10)); - let url = format!("http://{host}:{port}"); +/// The actual spawn-and-wait mechanics, shared by `start` and `upgrade`'s restart-after-upgrade +/// step — factored out so each caller prints its own single JSON envelope (`output::print_ok` +/// is "the only stdout write a command makes in JSON mode"; calling `start` itself from inside +/// `upgrade` would print two). +fn spawn_and_wait(state_path: &Path, log_path: &Path, host: String, port: u16) -> Result<(u32, bool)> { + let cli_js = paths::cli_js_path()?; + let pid = process::spawn(&cli_js, log_path, Some(port), Some(&host))?; + process::write_state(state_path, &process::WebState { pid, host: host.clone(), port })?; + let ready = wait_for_port(&host, port, Duration::from_secs(10)); if ready { - Logger::info(&format!("Started git-task-web at {url} (pid {pid})"), None, &[]); + Logger::info(&format!("Started git-task-web at http://{host}:{port} (pid {pid})"), None, &[]); } else { Logger::warn( &format!("Spawned git-task-web (pid {pid}) but it didn't come up within 10s"), @@ -128,15 +159,39 @@ fn start(args: StartArgs) -> Result<()> { &[], ); } + Ok((pid, ready)) +} - if output::is_json() { - output::print_ok(WebStatusJson { - running: ready, - pid: Some(pid), - url: ready.then_some(url), - log: log_path.display().to_string(), - }); +/// Checks npm for a newer git-task-web and, on a TTY, asks before upgrading. Never blocks +/// `start`: a network failure, a `--format json`/non-interactive caller, or a "no" answer all +/// fall through to starting whatever's already installed. +fn maybe_prompt_upgrade(yes: bool) -> Result<()> { + let Some(current) = update::installed_version()? else { return Ok(()) }; + let Some(latest) = update::latest_version() else { return Ok(()) }; + if !update::is_newer(¤t, &latest) { + return Ok(()); } + + let proceed = if yes { + true + } else if output::is_json() || !prompt::is_interactive() { + Logger::warn( + &format!("git-task-web {latest} is available (installed: {current})"), + Some("run `git task web upgrade` to update"), + &[], + ); + false + } else { + ui::prompt_confirm(&format!("git-task-web {latest} is available (installed: {current}). Update now?"), false)? + }; + + if !proceed { + return Ok(()); + } + + Logger::info("Upgrading git-task-web via npm...", None, &[]); + install::install()?; + Logger::info(&format!("Upgraded to {latest}."), None, &[]); Ok(()) } @@ -204,6 +259,44 @@ fn status(_args: StatusArgs) -> Result<()> { Ok(()) } +fn upgrade(_args: UpgradeArgs) -> Result<()> { + let state_path = paths::state_path()?; + let log_path = paths::log_path()?; + + let running_state = process::read_state(&state_path).filter(|s| process::is_alive(s.pid)); + + if let Some(state) = &running_state { + Logger::info("Stopping git-task-web to upgrade...", None, &[]); + process::stop(state.pid)?; + process::remove_state(&state_path)?; + } + + let from = update::installed_version()?; + Logger::info("Installing the latest git-task-web via npm...", None, &[]); + install::install()?; + let to = update::installed_version()?; + + match (&from, &to) { + (Some(f), Some(t)) if f == t => Logger::info(&format!("Already at the latest version ({t})."), None, &[]), + (Some(_), Some(t)) => Logger::info(&format!("Upgraded to {t}."), None, &[]), + (None, Some(t)) => Logger::info(&format!("Installed {t}."), None, &[]), + _ => Logger::info("Upgraded.", None, &[]), + } + + let restarted = if let Some(state) = running_state { + Logger::info("Restarting git-task-web...", None, &[]); + spawn_and_wait(&state_path, &log_path, state.host, state.port)?; + true + } else { + false + }; + + if output::is_json() { + output::print_ok(UpgradeJson { from, to, restarted }); + } + Ok(()) +} + fn not_running_json(log_path: &Path) -> WebStatusJson { WebStatusJson { running: false, pid: None, url: None, log: log_path.display().to_string() } } diff --git a/src/web/mod.rs b/src/web/mod.rs index a5a9e6a..1bfec6c 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -7,3 +7,4 @@ pub mod install; pub mod paths; pub mod process; +pub mod update; diff --git a/src/web/update.rs b/src/web/update.rs new file mode 100644 index 0000000..d628198 --- /dev/null +++ b/src/web/update.rs @@ -0,0 +1,66 @@ +use std::process::Command; + +use anyhow::{Context, Result}; + +use crate::web::paths; + +/// Reads the installed git-task-web's own `package.json`. `None` if it isn't installed or the +/// file can't be parsed — either way there's nothing to compare against, not an error. +pub fn installed_version() -> Result> { + let pkg_json = paths::install_dir()?.join("node_modules").join("git-task-web").join("package.json"); + if !pkg_json.exists() { + return Ok(None); + } + let text = std::fs::read_to_string(&pkg_json).with_context(|| format!("reading {}", pkg_json.display()))?; + let value: serde_json::Value = + serde_json::from_str(&text).with_context(|| format!("parsing {}", pkg_json.display()))?; + Ok(value.get("version").and_then(|v| v.as_str()).map(str::to_string)) +} + +/// The latest version published on npm, via the same `npm` binary `install::install` already +/// shells out to (no new HTTP-client dependency, same proxy/registry config either way). `None` +/// on any failure — offline, npm unreachable, registry down — since this only ever gates an +/// optional prompt, never blocks `start`/`upgrade` from proceeding on the currently installed +/// version. +pub fn latest_version() -> Option { + let output = Command::new("npm").args(["view", "git-task-web", "version"]).output().ok()?; + if !output.status.success() { + return None; + } + let version = String::from_utf8(output.stdout).ok()?; + let version = version.trim(); + (!version.is_empty()).then(|| version.to_string()) +} + +/// Naive numeric `major.minor.patch` compare — good enough since git-task-web's own CI enforces +/// monotonic version bumps and publishes no prerelease tags. +pub fn is_newer(current: &str, latest: &str) -> bool { + fn parts(v: &str) -> [u64; 3] { + let mut out = [0u64; 3]; + for (i, p) in v.split('.').take(3).enumerate() { + out[i] = p.parse().unwrap_or(0); + } + out + } + parts(latest) > parts(current) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_newer_compares_numerically_not_lexically() { + assert!(is_newer("0.1.9", "0.1.10")); + assert!(is_newer("0.1.2", "0.2.0")); + assert!(is_newer("0.1.2", "1.0.0")); + assert!(!is_newer("0.1.2", "0.1.2")); + assert!(!is_newer("0.1.2", "0.1.1")); + } + + #[test] + fn is_newer_treats_missing_or_garbage_segments_as_zero() { + assert!(is_newer("1", "1.0.1")); + assert!(!is_newer("1.2.3", "1.2.x")); + } +} From 2660f8616b70341e13d5c36ce08fd896c607cbb5 Mon Sep 17 00:00:00 2001 From: Mohsen Beiranvand Date: Sat, 8 Aug 2026 14:06:56 +0200 Subject: [PATCH 2/2] Document git task web in the git-task skill --- skills/git-task/SKILL.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/skills/git-task/SKILL.md b/skills/git-task/SKILL.md index cbeb490..664df41 100644 --- a/skills/git-task/SKILL.md +++ b/skills/git-task/SKILL.md @@ -1,6 +1,6 @@ --- name: git-task -description: Use when working in a repo that tracks work with git-task — creating, showing, listing, editing, commenting on, labeling, linking, or deleting tasks. Tasks live as git objects under refs/tasks/*, managed only through the `git task` / `gtask` CLI, never by hand-editing anything. Trigger on "create a task", "list tasks", "what's the status of X", "add a comment/label", "close/delete a task", "link this to that", or any task-tracking request in a repo using git-task. +description: Use when working in a repo that tracks work with git-task — creating, showing, listing, editing, commenting on, labeling, linking, or deleting tasks, or starting/stopping/upgrading its companion web UI. Tasks live as git objects under refs/tasks/*, managed only through the `git task` / `gtask` CLI, never by hand-editing anything. Trigger on "create a task", "list tasks", "what's the status of X", "add a comment/label", "close/delete a task", "link this to that", "start the web UI", "update git-task-web", or any task-tracking request in a repo using git-task. --- # git-task @@ -127,6 +127,24 @@ to remove one, use `label rm`/`version fixed-rm`/`version affected-rm` instead. also remove it on that remote. Only use this if the user explicitly wants to purge local ref state, not to communicate a task is done/cancelled. +## Web UI + +```sh +git task web start # installs git-task-web via npm first if needed, then serves it +git task web start --port 4601 --host 0.0.0.0 +git task web stop +git task web status +git task web upgrade # update to the latest git-task-web, restarting it if running +``` + +`start` installs on first use (npm install under the hood) and, once installed, checks npm for a +newer git-task-web every time — on a TTY it prompts before upgrading, defaulting to "no" so a plain +`start` never surprises you mid-launch. **Non-interactive (no TTY — this is you) or `--format +json`**, both the install prompt and the update prompt are skipped: pass `--yes`/`-y` to install (or +upgrade) non-interactively, otherwise `start` proceeds on whatever's already installed and surfaces +a warning (`warnings[]` in JSON mode) instead of blocking. `git task web upgrade` is the explicit, +always-non-interactive path — always run this yourself rather than relying on the prompt. + ## Related skills - **git-task-config** — per-repo config (address key, required fields) and automation rules.