Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "git-task"
version = "1.0.5"
version = "1.0.6"
edition = "2021"

[lib]
Expand Down
20 changes: 19 additions & 1 deletion skills/git-task/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down
121 changes: 107 additions & 14 deletions src/cli/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)]
Expand All @@ -50,6 +52,9 @@ pub struct StopArgs {}
#[derive(Args)]
pub struct StatusArgs {}

#[derive(Args)]
pub struct UpgradeArgs {}

#[derive(Serialize)]
struct WebStatusJson {
running: bool,
Expand All @@ -58,11 +63,19 @@ struct WebStatusJson {
log: String,
}

#[derive(Serialize)]
struct UpgradeJson {
from: Option<String>,
to: Option<String>,
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),
}
}

Expand Down Expand Up @@ -107,36 +120,78 @@ 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"),
Some(&format!("check {}", log_path.display())),
&[],
);
}
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(&current, &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(())
}

Expand Down Expand Up @@ -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() }
}
Expand Down
1 change: 1 addition & 0 deletions src/web/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
pub mod install;
pub mod paths;
pub mod process;
pub mod update;
66 changes: 66 additions & 0 deletions src/web/update.rs
Original file line number Diff line number Diff line change
@@ -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<Option<String>> {
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<String> {
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"));
}
}
Loading