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
3 changes: 2 additions & 1 deletion Cargo.lock

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

3 changes: 2 additions & 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.4"
version = "1.0.5"
edition = "2021"

[lib]
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/cli/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ mod sync_worker;
mod target_repo;
mod unregister;
mod version;
mod web;
mod whoami;
mod wizard;

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) => {
Expand Down
224 changes: 224 additions & 0 deletions src/cli/web.rs
Original file line number Diff line number Diff line change
@@ -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<u16>,
/// Host/address to bind (defaults to git-task-web's own default, 127.0.0.1)
#[arg(long)]
host: Option<String>,
/// 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<u32>,
url: Option<String>,
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
}
21 changes: 21 additions & 0 deletions src/config/global.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,3 +282,24 @@ pub fn config_dir() -> Result<PathBuf> {
fn config_path() -> Result<PathBuf> {
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<PathBuf> {
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"))
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub mod table;
pub mod wrap;
pub mod style;
pub mod ui;
pub mod web;

use clap::{CommandFactory, FromArgMatches};

Expand Down
8 changes: 7 additions & 1 deletion src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -83,6 +83,12 @@ pub fn prompt_select<T: Display>(label: &str, options: Vec<T>, 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<bool> {
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<NaiveDate> {
Expand Down
Loading
Loading