generated from nhcarrigan/template
feat: add CLI update check with npm registry indicator
On app start, check the npm registry for the latest @anthropic-ai/claude-code version and compare against the installed version. If behind, the CLI badge turns amber with a pulsing up-arrow and a tooltip advising how to update. Also bumps SUPPORTED_CLI_VERSION to 2.1.72.
This commit is contained in:
@@ -662,6 +662,37 @@ pub async fn fetch_changelog() -> Result<Vec<ChangelogEntry>, String> {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_npm_cli_version(json: &str) -> Result<String, String> {
|
||||||
|
let data: serde_json::Value =
|
||||||
|
serde_json::from_str(json).map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
data.get("version")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or_else(|| "No version field in response".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_cli_latest_version() -> Result<String, String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.get("https://registry.npmjs.org/@anthropic-ai/claude-code/latest")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch CLI version: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Registry returned status: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||||
|
|
||||||
|
parse_npm_cli_version(&body)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct SavedFileInfo {
|
pub struct SavedFileInfo {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
@@ -2838,6 +2869,35 @@ mod tests {
|
|||||||
assert!(json.contains("null") || json.contains("release_notes"));
|
assert!(json.contains("null") || json.contains("release_notes"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== parse_npm_cli_version tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_npm_cli_version_valid() {
|
||||||
|
let json = r#"{"name":"@anthropic-ai/claude-code","version":"2.1.72","description":"Claude Code"}"#;
|
||||||
|
let result = parse_npm_cli_version(json).unwrap();
|
||||||
|
assert_eq!(result, "2.1.72");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_npm_cli_version_missing_field() {
|
||||||
|
let json = r#"{"name":"@anthropic-ai/claude-code","description":"no version here"}"#;
|
||||||
|
let result = parse_npm_cli_version(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_npm_cli_version_invalid_json() {
|
||||||
|
let result = parse_npm_cli_version("not json at all");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_npm_cli_version_non_string_version() {
|
||||||
|
let json = r#"{"version":123}"#;
|
||||||
|
let result = parse_npm_cli_version(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== SavedFileInfo struct tests ====================
|
// ==================== SavedFileInfo struct tests ====================
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ pub fn run() {
|
|||||||
list_skills,
|
list_skills,
|
||||||
check_for_updates,
|
check_for_updates,
|
||||||
fetch_changelog,
|
fetch_changelog,
|
||||||
|
check_cli_latest_version,
|
||||||
save_temp_file,
|
save_temp_file,
|
||||||
register_temp_file,
|
register_temp_file,
|
||||||
get_temp_files,
|
get_temp_files,
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
const SUPPORTED_CLI_VERSION = "2.1.53";
|
const SUPPORTED_CLI_VERSION = "2.1.72";
|
||||||
|
|
||||||
let installedVersion = $state("Loading...");
|
let installedVersion = $state("Loading...");
|
||||||
|
let latestNpmVersion = $state<string | null>(null);
|
||||||
|
|
||||||
function compareVersions(a: string, b: string): number {
|
function compareVersions(a: string, b: string): number {
|
||||||
const aParts = a.split(".").map(Number);
|
const aParts = a.split(".").map(Number);
|
||||||
@@ -32,6 +33,15 @@
|
|||||||
return "current";
|
return "current";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let updateAvailable = $derived.by(() => {
|
||||||
|
if (!latestNpmVersion || installedVersion === "Loading..." || installedVersion === "Unknown") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
|
||||||
|
if (!semverMatch) return false;
|
||||||
|
return compareVersions(semverMatch[1], latestNpmVersion) < 0;
|
||||||
|
});
|
||||||
|
|
||||||
async function fetchVersion() {
|
async function fetchVersion() {
|
||||||
try {
|
try {
|
||||||
const result = await invoke<string>("get_claude_version");
|
const result = await invoke<string>("get_claude_version");
|
||||||
@@ -42,13 +52,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchLatestNpmVersion() {
|
||||||
|
try {
|
||||||
|
const result = await invoke<string>("check_cli_latest_version");
|
||||||
|
latestNpmVersion = result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to check latest CLI version:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
fetchVersion();
|
fetchVersion();
|
||||||
|
fetchLatestNpmVersion();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="cli-versions">
|
<div class="cli-versions">
|
||||||
<div class="cli-version">
|
<div
|
||||||
|
class="cli-version {updateAvailable ? 'update-available' : ''}"
|
||||||
|
title={updateAvailable
|
||||||
|
? `Update available: ${latestNpmVersion} — run: npm install -g @anthropic-ai/claude-code`
|
||||||
|
: "Installed CLI version"}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
class="terminal-icon"
|
class="terminal-icon"
|
||||||
width="14"
|
width="14"
|
||||||
@@ -64,6 +89,22 @@
|
|||||||
<line x1="12" y1="19" x2="20" y2="19" />
|
<line x1="12" y1="19" x2="20" y2="19" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="version-text">CLI {displayVersion}</span>
|
<span class="version-text">CLI {displayVersion}</span>
|
||||||
|
{#if updateAvailable}
|
||||||
|
<svg
|
||||||
|
class="update-icon"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="17 11 12 6 7 11" />
|
||||||
|
<line x1="12" y1="6" x2="12" y2="18" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
|
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
|
||||||
@@ -135,6 +176,27 @@
|
|||||||
color: var(--error-color, #f44336);
|
color: var(--error-color, #f44336);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cli-version.update-available {
|
||||||
|
border-color: var(--warning-color, #ff9800);
|
||||||
|
color: var(--warning-color, #ff9800);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-icon {
|
.terminal-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
const SUPPORTED_CLI_VERSION = "2.1.53";
|
const SUPPORTED_CLI_VERSION = "2.1.72";
|
||||||
|
|
||||||
function compareVersions(a: string, b: string): number {
|
function compareVersions(a: string, b: string): number {
|
||||||
const aParts = a.split(".").map(Number);
|
const aParts = a.split(".").map(Number);
|
||||||
@@ -41,7 +41,7 @@ describe("SUPPORTED_CLI_VERSION", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("matches the expected audited version", () => {
|
it("matches the expected audited version", () => {
|
||||||
expect(SUPPORTED_CLI_VERSION).toBe("2.1.53");
|
expect(SUPPORTED_CLI_VERSION).toBe("2.1.72");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,7 +128,55 @@ describe("compareVersions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 0 for exactly the supported version", () => {
|
it("returns 0 for exactly the supported version", () => {
|
||||||
expect(compareVersions("2.1.53", SUPPORTED_CLI_VERSION)).toBe(0);
|
expect(compareVersions("2.1.72", SUPPORTED_CLI_VERSION)).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mirrors the updateAvailable derived logic in CliVersion.svelte
|
||||||
|
function isUpdateAvailable(installedVersion: string, latestNpmVersion: string | null): boolean {
|
||||||
|
if (!latestNpmVersion || installedVersion === "Loading..." || installedVersion === "Unknown") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
|
||||||
|
if (!semverMatch) return false;
|
||||||
|
return compareVersions(semverMatch[1], latestNpmVersion) < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("updateAvailable", () => {
|
||||||
|
it("returns false when latestNpmVersion is null", () => {
|
||||||
|
expect(isUpdateAvailable("2.1.70", null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when installed is Loading...", () => {
|
||||||
|
expect(isUpdateAvailable("Loading...", "2.1.72")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when installed is Unknown", () => {
|
||||||
|
expect(isUpdateAvailable("Unknown", "2.1.72")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when installed equals latest", () => {
|
||||||
|
expect(isUpdateAvailable("2.1.72", "2.1.72")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when installed is ahead of latest", () => {
|
||||||
|
expect(isUpdateAvailable("2.1.73", "2.1.72")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when installed is behind latest", () => {
|
||||||
|
expect(isUpdateAvailable("2.1.70", "2.1.72")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when installed has a lower minor version", () => {
|
||||||
|
expect(isUpdateAvailable("2.0.99", "2.1.72")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles version strings with extra info like '2.1.70 (build 123)'", () => {
|
||||||
|
expect(isUpdateAvailable("2.1.70 (build 123)", "2.1.72")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for unparseable installed version", () => {
|
||||||
|
expect(isUpdateAvailable("not-a-version", "2.1.72")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user