diff --git a/CLAUDE.md b/CLAUDE.md index 976e36f..15ea9e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,8 +23,11 @@ empaquetada en **Tauri 2.x**. Estado: Fases 0, 1 y 2 completas (parser + UI web / `npm run build` **sin** `--prefix web` (un `--prefix web` buscaría `web/web/package.json` y falla). `src-tauri/` es su **propia raíz de workspace**: `cargo build` en la raíz NO arrastra el backend Tauri. - Build del frontend: `npm --prefix web run build`. -- **Tests**: 20 tests unitarios del parser en `src/lib.rs` (`cargo test --release`). Complementan - —no reemplazan— la verificación contra datos reales del skill `/verify-parser`. +- **Tests**: 24 tests unitarios en el crate del parser (`src/lib.rs` + `src/update.rs`, `cargo test --release`). + Complementan —no reemplazan— la verificación contra datos reales del skill `/verify-parser`. +- **Update check** (`src/update.rs`, capa de red opt-in y secundaria — el parser nunca la llama): consulta + el último Release de GitHub vía `curl` (sin dep HTTP) y **solo avisa** si hay versión nueva, nunca instala. + Expuesto en CLI (`--check-update [--json]`), Tauri (`check_update`/`open_url`) y la VersionBadge. ## Modelo de datos (lo NO obvio — léelo antes de tocar el parser) - Fuente de verdad: transcripts NATIVOS `~/.claude/projects//.jsonl`. **No se usa ningún hook.** diff --git a/README.md b/README.md index 9d5f41e..b7eb046 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,10 @@ installs `arrow.app` into `/Applications`, and clears the Gatekeeper quarantine > The installer strips the quarantine attribute of an **unsigned** binary so it opens without > friction — only run it if you trust this source. To **update**, re-run the one-liner; to -> **uninstall**, `rm -rf /Applications/arrow.app`. (There is no auto-update / package manager yet — -> a Homebrew Cask is on the [roadmap](ROADMAP.md).) +> **uninstall**, `rm -rf /Applications/arrow.app`. arrow **tells you when a newer release exists** +> (a dot on the version badge, or `arrow --check-update` from the CLI) but does not auto-install it +> yet — being unsigned, a silent in-app updater would fight Gatekeeper. A Homebrew Cask and a signed +> auto-updater are on the [roadmap](ROADMAP.md). **Manual:** grab the `.dmg` for your chip (`_aarch64` = Apple Silicon, `_x64` = Intel) from the [**Releases**](https://github.com/MrArcher23/arrow/releases/latest) page, open it, and drag arrow to @@ -133,10 +135,13 @@ cargo build --release # Normalized JSON (the contract the UI consumes) ./target/release/arrow --repo my-project --json + +# Is there a newer arrow release? (network; read-only, never installs) +./target/release/arrow --check-update ``` Options: `--projects-dir ` (defaults to `~/.claude/projects`), `--repo`, `--session`, -`--list`, `--json`, and `--content --file [--session ]` (emits `{before, after}` for a +`--list`, `--json`, `--check-update`, and `--content --file [--session ]` (emits `{before, after}` for a file, for the UI's diff view). ## Web UI (Phase 1) diff --git a/ROADMAP.md b/ROADMAP.md index cbec918..2e14407 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -285,6 +285,16 @@ todas deben respetar la honestidad del producto (no afirmar más de lo que el da que no existe un `.dmg` publicado todavía: el primer `v*` lo generará. **Pendiente:** correr ese primer tag, la firma/notarización (Apple Developer, ~99 USD/año) para quitar la fricción de Gatekeeper, y un **Homebrew Cask** (`brew install --cask`) con upgrade/uninstall. +- **Aviso de actualización (check-for-updates)** — ✅ implementado. arrow consulta el último Release de + GitHub y avisa si hay una versión más nueva, **sin descargar ni instalar nada** (honesto: unsigned ⇒ + un auto-updater silencioso pelearía con Gatekeeper, por eso solo *avisa*). Capa de red **opt-in y + secundaria** en `src/update.rs` (fuera del hot path del parser; shell-out a `curl`, sin dep HTTP + nueva, time-boxed, degrada con `error` en vez de panic). Expuesto en CLI (`--check-update [--json]`), + Tauri (comando `check_update` + `open_url` para abrir el release) y en la **VersionBadge** (punto verde + + "Update available → vX.Y.Z" + "Open release"). Tests de orden de versión en `update.rs`. + **Pendientes (siguen en backlog):** auto-instalación real vía `tauri-plugin-updater` (requiere keypair + de firma del updater + `latest.json` firmado en el job de release; mejor **tras** la notarización) y el + Homebrew Cask de arriba para el upgrade nativo en Mac. - **macOS sin verificar en hardware**: el código ya está adaptado a Mac (titlebar nativa + font-weight por OS; ver [MACOS.md](MACOS.md)) y la matrix ya **construirá** el `.dmg` en CI, pero el bloque `cfg(target_os = "macos")` y el `install.sh` aún **no se probaron en una Mac real** → falta correr diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8890e78..b73ac44 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -73,6 +73,38 @@ fn worktree_sizes(paths: Vec) -> std::collections::HashMap worktrees::worktree_sizes(&paths) } +/// Check GitHub for a newer arrow release (for the version badge's "update +/// available" hint). Network-bound but time-boxed; read-only — it never +/// downloads or installs. Runs off the UI thread via `spawn_blocking`. +#[tauri::command] +async fn check_update() -> arrow::UpdateStatus { + tauri::async_runtime::spawn_blocking(|| arrow::check_update(env!("CARGO_PKG_VERSION"))) + .await + .unwrap_or_else(|_| arrow::check_update(env!("CARGO_PKG_VERSION"))) +} + +/// Open a URL in the user's default browser (the "Open release" action). Only +/// `https://` URLs are honored, so the command can't be coaxed into launching a +/// local file or a `file://`/`javascript:` scheme. Argv-direct (no shell). +#[tauri::command] +fn open_url(url: String) -> Result<(), String> { + if !url.starts_with("https://") { + return Err("refusing to open a non-https URL".into()); + } + #[cfg(target_os = "macos")] + let (bin, args): (&str, Vec) = ("open", vec![url]); + #[cfg(target_os = "linux")] + let (bin, args): (&str, Vec) = ("xdg-open", vec![url]); + #[cfg(target_os = "windows")] + let (bin, args): (&str, Vec) = + ("cmd", vec!["/C".into(), "start".into(), String::new(), url]); + std::process::Command::new(bin) + .args(args) + .spawn() + .map(|_| ()) + .map_err(|e| format!("couldn't open the browser: {e}")) +} + /// Watcher nativo: vigila `~/.claude/projects` y, con debounce, emite /// `report-changed` para que el frontend refresque sin polling. /// @@ -158,7 +190,9 @@ pub fn run() { editors, open_in_editor, worktrees, - worktree_sizes + worktree_sizes, + check_update, + open_url ]) .setup(|app| { spawn_watcher(app.handle().clone()); diff --git a/src/lib.rs b/src/lib.rs index 56eae0b..a7bfce7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,12 @@ use serde::Serialize; use serde_json::Value; use walkdir::WalkDir; +// Opt-in, secondary network layer (release-update check). Kept out of the parser +// hot path — `build_report`/`file_content` never touch it; same separation as the +// git worktree layer. +pub mod update; +pub use update::{check_update, UpdateStatus}; + // --------------------------------------------------------------------------- // Modelo interno (acumulación) // --------------------------------------------------------------------------- diff --git a/src/main.rs b/src/main.rs index 8f02f72..b7cbddf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,10 @@ struct Cli { /// Ruta exacta del archivo (para --content) #[arg(long)] file: Option, + + /// Check GitHub for a newer arrow release (network; read-only, never installs) + #[arg(long)] + check_update: bool, } const RESET: &str = "\x1b[0m"; @@ -64,6 +68,35 @@ fn main() -> Result<()> { .clone() .unwrap_or_else(|| format!("{home}/.claude/projects")); + if cli.check_update { + let st = arrow::check_update(env!("CARGO_PKG_VERSION")); + if cli.json { + println!("{}", serde_json::to_string(&st)?); + } else if let Some(err) = &st.error { + println!("{YELLOW}Couldn't check for updates:{RESET} {err}"); + println!("{DIM}current: v{}{RESET}", st.current); + } else if st.update_available { + let latest = st.latest.as_deref().unwrap_or("?"); + println!( + "{GREEN}{BOLD}Update available:{RESET} v{} {DIM}→{RESET} {GREEN}v{latest}{RESET}", + st.current + ); + if let Some(url) = &st.url { + println!("{DIM}{url}{RESET}"); + } + println!( + "{DIM}macOS: re-run the installer · curl -fsSL \ + https://raw.githubusercontent.com/MrArcher23/arrow/main/install.sh | bash{RESET}" + ); + } else { + println!( + "{GREEN}arrow is up to date{RESET} {DIM}(v{}){RESET}", + st.current + ); + } + return Ok(()); + } + if cli.content { let target = cli .file diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..2ba89e4 --- /dev/null +++ b/src/update.rs @@ -0,0 +1,174 @@ +//! Update check — a lightweight "is there a newer release?" probe. +//! +//! arrow ships **unsigned** (no Apple notarization yet), so a silent in-app +//! auto-updater (tauri-plugin-updater) would fight Gatekeeper; that's deferred. +//! What we CAN do honestly is *tell* the user a newer version exists and point +//! them at the release. This module does exactly that and nothing more — it +//! never downloads or installs anything. +//! +//! It is an OPT-IN, secondary network layer, kept out of the parser's hot path +//! (`build_report`/`file_content` never call it) — same separation as the git +//! worktree layer. To avoid pulling an HTTP client into the core lib, it shells +//! out to `curl` (present on macOS and the Linux targets), argv-direct, with a +//! hard `--max-time`, and degrades gracefully (a missing curl, no network, a +//! rate-limit, or unparseable JSON all yield `error: Some(..)`, never a panic). + +use serde::Serialize; +use serde_json::Value; +use std::process::Command; + +/// GitHub repo that publishes arrow's releases (the upstream, not a fork). +const RELEASES_REPO: &str = "MrArcher23/arrow"; + +/// Outcome of an update check. `update_available` is only ever `true` when a +/// `latest` was fetched AND parsed AND is strictly newer than `current`. +#[derive(Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UpdateStatus { + /// The running version (e.g. "0.1.6"), without a leading "v". + pub current: String, + /// The latest published release tag, normalized without "v". `None` when the + /// check failed or no release exists. + pub latest: Option, + /// Strictly-newer latest than current. + pub update_available: bool, + /// Release page to open / share when an update is available. + pub url: Option, + /// Human-readable reason the check couldn't complete (network, no release, + /// curl missing). `None` on a clean check (whether or not an update exists). + pub error: Option, +} + +impl UpdateStatus { + fn failed(current: &str, error: impl Into) -> Self { + UpdateStatus { + current: normalize(current).to_string(), + latest: None, + update_available: false, + url: None, + error: Some(error.into()), + } + } +} + +/// Check whether a release newer than `current` exists. Network-bound but +/// time-boxed; safe to call from a button. Never downloads or installs. +pub fn check_update(current: &str) -> UpdateStatus { + let url = format!("https://api.github.com/repos/{RELEASES_REPO}/releases/latest"); + let out = Command::new("curl") + .args([ + "-fsSL", + "--max-time", + "6", + "-H", + "Accept: application/vnd.github+json", + // GitHub rejects requests without a User-Agent. + "-A", + "arrow-update-check", + &url, + ]) + .output(); + + let body = match out { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(), + // curl ran but the request failed (404 = no release yet, 403 = rate + // limited, etc.). `-f` makes curl exit non-zero on HTTP errors. + Ok(_) => { + return UpdateStatus::failed(current, "no published release found (or rate-limited)") + } + Err(_) => return UpdateStatus::failed(current, "couldn't run curl to check for updates"), + }; + + let json: Value = match serde_json::from_str(&body) { + Ok(v) => v, + Err(_) => return UpdateStatus::failed(current, "couldn't parse the release response"), + }; + + let latest_tag = match json.get("tag_name").and_then(Value::as_str) { + Some(t) if !t.is_empty() => t.to_string(), + _ => return UpdateStatus::failed(current, "the release had no tag"), + }; + let page = json + .get("html_url") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| format!("https://github.com/{RELEASES_REPO}/releases/latest")); + + let cur = normalize(current); + let lat = normalize(&latest_tag); + UpdateStatus { + current: cur.to_string(), + update_available: is_newer(lat, cur), + latest: Some(lat.to_string()), + url: Some(page), + error: None, + } +} + +/// Strip a leading "v"/"V" so "v0.1.6" and "0.1.6" compare equal. +fn normalize(v: &str) -> &str { + v.trim().strip_prefix(['v', 'V']).unwrap_or(v.trim()) +} + +/// Is `a` a strictly newer semver-ish version than `b`? Compares dot-separated +/// numeric components (missing components count as 0), so "0.2.0" > "0.1.9" and +/// "0.1.6" == "0.1.6". A non-numeric component stops the comparison conservatively +/// (returns false), so a weird tag never spuriously claims an update. +fn is_newer(a: &str, b: &str) -> bool { + let parse = |s: &str| -> Option> { + // Drop any pre-release/build suffix (e.g. "0.2.0-rc1" -> "0.2.0"). + let core = s.split(['-', '+']).next().unwrap_or(s); + core.split('.').map(|p| p.parse::().ok()).collect() + }; + match (parse(a), parse(b)) { + (Some(av), Some(bv)) => { + let n = av.len().max(bv.len()); + for i in 0..n { + let x = av.get(i).copied().unwrap_or(0); + let y = bv.get(i).copied().unwrap_or(0); + if x != y { + return x > y; + } + } + false + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_ordering() { + assert!(is_newer("0.2.0", "0.1.9")); + assert!(is_newer("0.1.7", "0.1.6")); + assert!(is_newer("1.0.0", "0.9.9")); + assert!(!is_newer("0.1.6", "0.1.6")); + assert!(!is_newer("0.1.6", "0.2.0")); + // Padding: "0.1" == "0.1.0". + assert!(!is_newer("0.1", "0.1.0")); + assert!(is_newer("0.1.1", "0.1")); + // Pre-release suffix dropped to the core. + assert!(is_newer("0.2.0-rc1", "0.1.9")); + // Non-numeric junk never claims an update. + assert!(!is_newer("nightly", "0.1.6")); + } + + #[test] + fn normalize_strips_v() { + assert_eq!(normalize("v0.1.6"), "0.1.6"); + assert_eq!(normalize("0.1.6"), "0.1.6"); + assert_eq!(normalize(" v0.1.6 "), "0.1.6"); + } + + #[test] + fn failed_status_is_honest() { + let s = UpdateStatus::failed("v0.1.6", "boom"); + assert_eq!(s.current, "0.1.6"); + assert!(!s.update_available); + assert!(s.latest.is_none()); + assert_eq!(s.error.as_deref(), Some("boom")); + } +} diff --git a/web/src/components/VersionBadge.svelte b/web/src/components/VersionBadge.svelte index 4299572..03c8d23 100644 --- a/web/src/components/VersionBadge.svelte +++ b/web/src/components/VersionBadge.svelte @@ -1,9 +1,25 @@
- {#if open} @@ -59,6 +96,54 @@ background: var(--hover); color: var(--fg); } + .badge.has-update { + color: var(--fg); + border-color: var(--green); + } + .udot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--green); + margin-left: 5px; + vertical-align: middle; + } + .update { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + padding: 6px 8px; + background: var(--chip); + border: 1px solid var(--green); + border-radius: 6px; + font-size: 12px; + } + .up-text { + color: var(--fg); + flex: 1; + min-width: 0; + } + .open { + font: inherit; + font-size: 11px; + color: #000; + background: var(--green); + border: 1px solid var(--green); + border-radius: 6px; + padding: 2px 9px; + cursor: pointer; + flex: none; + } + .open:hover { + opacity: 0.9; + } + .hint { + color: var(--dim); + font-size: 11px; + margin-top: 6px; + } .popover { position: absolute; top: calc(100% + 6px); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ff318df..67220bc 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core' -import type { Report, FileContent, Editor, RepoWorktrees } from './types' +import type { Report, FileContent, Editor, RepoWorktrees, UpdateStatus } from './types' // Única capa que conoce el transporte. Dos modos, MISMO contrato: // - App de escritorio (Tauri): llama al backend Rust vía invoke() — sin HTTP. @@ -75,3 +75,20 @@ export async function calcWorktreeSizes(paths: string[]): Promise>('worktree_sizes', { paths }) } + +// Check GitHub for a newer release (the version badge's "update available" hint). +// Tauri-only — in the browser dev build there's no app to update, so it returns a +// neutral "no check" result instead of hitting the network. +export async function checkUpdate(): Promise { + if (!inTauri) return null + return invoke('check_update') +} + +// Open an https URL in the default browser (the "Open release" action). Tauri-only. +export async function openUrl(url: string): Promise { + if (!inTauri) { + window.open(url, '_blank', 'noopener') + return + } + await invoke('open_url', { url }) +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 55be426..0db7223 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -68,3 +68,12 @@ export interface RepoWorktrees { defaultBranch: string | null // null = couldn't resolve → merge classification suppressed worktrees: Worktree[] } + +// Result of the release-update check (the version badge's "update available" hint). +export interface UpdateStatus { + current: string // running version, no leading "v" + latest: string | null // latest published tag, no "v"; null if the check failed + updateAvailable: boolean // a strictly-newer latest than current + url: string | null // release page to open/share + error: string | null // why the check failed (network, no release); null on success +}