Skip to content

Commit 7ee042d

Browse files
committed
feat(update): add periodic version check with 24h cache
Add update_check module that checks GitHub Releases for newer zvm versions. The check runs after install and mirrorlist commands (both already require network), and caches results for 24 hours in ~/.cache/zvm/_update_check to avoid redundant API calls. The hint is non-blocking and non-interactive — it prints a yellow notice like "A new version of zvm is available: v0.2.0" without prompting or interrupting the command flow. The upgrade command is excluded since it already performs its own version check. Refactor upgrade.zig to reuse versionGt, stripVPrefix, and extractVersionFromRelease from the new module, eliminating ~50 lines of duplicate version parsing logic.
1 parent 089bcfc commit 7ee042d

3 files changed

Lines changed: 208 additions & 70 deletions

File tree

src/command/upgrade.zig

Lines changed: 10 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -9,39 +9,7 @@ const zvm_mod = @import("../core/zvm.zig");
99
const terminal = @import("../core/terminal.zig");
1010
const platform = @import("../core/platform.zig");
1111
const http_client = @import("../network/http_client.zig");
12-
13-
/// GitHub Release API response structure (used for reference, parsed dynamically).
14-
const GithubRelease = struct {
15-
tag_name: []const u8,
16-
assets: []const Asset,
17-
18-
const Asset = struct {
19-
name: []const u8,
20-
browser_download_url: []const u8,
21-
};
22-
};
23-
24-
/// Compare two semver version strings (without 'v' prefix).
25-
/// Returns true if a > b.
26-
fn versionGt(a: []const u8, b: []const u8) bool {
27-
var a_iter = std.mem.splitScalar(u8, a, '.');
28-
var b_iter = std.mem.splitScalar(u8, b, '.');
29-
while (true) {
30-
const a_part = a_iter.next();
31-
const b_part = b_iter.next();
32-
if (a_part == null and b_part == null) return false;
33-
const a_val: u64 = if (a_part) |p| std.fmt.parseInt(u64, p, 10) catch 0 else 0;
34-
const b_val: u64 = if (b_part) |p| std.fmt.parseInt(u64, p, 10) catch 0 else 0;
35-
if (a_val > b_val) return true;
36-
if (a_val < b_val) return false;
37-
}
38-
}
39-
40-
/// Strip leading 'v' from a version string if present.
41-
fn stripVPrefix(version: []const u8) []const u8 {
42-
if (version.len > 0 and version[0] == 'v') return version[1..];
43-
return version;
44-
}
12+
const update_check = @import("../core/update_check.zig");
4513

4614
/// Search for the extracted binary inside self_dir, including subdirectories.
4715
/// The archive may extract into a versioned directory like zvm-v0.1.1-aarch64-macos/.
@@ -81,7 +49,7 @@ pub fn run(
8149
try stdout.print("Checking for zvm updates...\n", .{});
8250
try stdout.flush();
8351

84-
// Fetch latest release info from GitHub API
52+
// Fetch latest release info from GitHub API (single request)
8553
const proxy = zvm.settings.proxy;
8654
const release_json = http_client.downloadToMemoryWithProxy(allocator, zvm.io, zvm.environ_map, "https://api.github.com/repos/lispking/zvm/releases/latest", proxy) catch {
8755
try terminal.printError(stderr, "Failed to check for updates");
@@ -97,49 +65,21 @@ pub fn run(
9765
};
9866
defer parsed.deinit();
9967

100-
// Extract version info from release.
101-
// The "latest" release has tag_name="latest" (not a real version),
102-
// so we extract the actual version from the body: "Latest stable release (vX.Y.Z)."
10368
const release = parsed.value;
104-
const release_obj = switch (release) {
105-
.object => |obj| obj,
106-
else => {
107-
try terminal.printError(stderr, "Invalid release response");
108-
return;
109-
},
110-
};
11169

112-
var latest_version: []const u8 = "unknown";
113-
// Try to extract version from body: "Latest stable release (vX.Y.Z)."
114-
if (release_obj.get("body")) |body_val| {
115-
if (body_val == .string) {
116-
const body = body_val.string;
117-
if (std.mem.indexOf(u8, body, "(")) |open| {
118-
if (std.mem.indexOf(u8, body[open..], ")")) |close| {
119-
latest_version = body[open + 1 .. open + close];
120-
}
121-
}
122-
}
123-
}
124-
// Fallback to tag_name if body parsing failed and tag_name looks like a version
125-
if (std.mem.eql(u8, latest_version, "unknown")) {
126-
if (release_obj.get("tag_name")) |tag| {
127-
if (tag == .string) {
128-
const t = tag.string;
129-
if (t.len > 0 and t[0] == 'v') {
130-
latest_version = t;
131-
}
132-
}
133-
}
134-
}
70+
// Extract version from release JSON (reuses shared logic)
71+
const latest_version = update_check.extractVersionFromRelease(release) orelse {
72+
try terminal.printError(stderr, "Invalid release response");
73+
return;
74+
};
13575

13676
try stdout.print("Latest version: {s}\n", .{latest_version});
13777
try stdout.flush();
13878

13979
// Compare versions and skip download if already up-to-date
140-
const current_stripped = stripVPrefix(current_version);
141-
const latest_stripped = stripVPrefix(latest_version);
142-
if (!versionGt(latest_stripped, current_stripped)) {
80+
const current_stripped = update_check.stripVPrefix(current_version);
81+
const latest_stripped = update_check.stripVPrefix(latest_version);
82+
if (!update_check.versionGt(latest_stripped, current_stripped)) {
14383
try terminal.printSuccess(stdout, "Already up-to-date!");
14484
try stdout.flush();
14585
return;

src/core/update_check.zig

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
//! Periodic update check for zvm self-updates.
2+
//! Caches the latest version from GitHub Releases in the cache directory.
3+
//! Only re-checks if the cache is older than 24 hours to avoid unnecessary
4+
//! network requests. Prints a non-intrusive hint when a newer version exists.
5+
6+
const std = @import("std");
7+
const build_options = @import("build_options");
8+
const zvm_mod = @import("zvm.zig");
9+
const http_client = @import("../network/http_client.zig");
10+
const terminal = @import("terminal.zig");
11+
12+
/// Cache file name for the update check result.
13+
const cache_filename = "_update_check";
14+
15+
/// How often to re-check for updates (24 hours in seconds).
16+
const check_interval_secs: i64 = 86400;
17+
18+
/// Current zvm version, injected at build time.
19+
const VERSION = build_options.version;
20+
21+
/// Compare two semver version strings (without 'v' prefix).
22+
/// Returns true if a > b.
23+
pub fn versionGt(a: []const u8, b: []const u8) bool {
24+
var a_iter = std.mem.splitScalar(u8, a, '.');
25+
var b_iter = std.mem.splitScalar(u8, b, '.');
26+
while (true) {
27+
const a_part = a_iter.next();
28+
const b_part = b_iter.next();
29+
if (a_part == null and b_part == null) return false;
30+
const a_val: u64 = if (a_part) |p| std.fmt.parseInt(u64, p, 10) catch 0 else 0;
31+
const b_val: u64 = if (b_part) |p| std.fmt.parseInt(u64, p, 10) catch 0 else 0;
32+
if (a_val > b_val) return true;
33+
if (a_val < b_val) return false;
34+
}
35+
}
36+
37+
/// Strip leading 'v' from a version string if present.
38+
pub fn stripVPrefix(version: []const u8) []const u8 {
39+
if (version.len > 0 and version[0] == 'v') return version[1..];
40+
return version;
41+
}
42+
43+
/// Extract the actual version from a GitHub release JSON body.
44+
/// The "latest" release has tag_name="latest", so we extract from body:
45+
/// "Latest stable release (vX.Y.Z)."
46+
pub fn extractVersionFromRelease(json: std.json.Value) ?[]const u8 {
47+
const obj = switch (json) {
48+
.object => |o| o,
49+
else => return null,
50+
};
51+
52+
// Try body first: "Latest stable release (vX.Y.Z)."
53+
if (obj.get("body")) |body_val| {
54+
if (body_val == .string) {
55+
const body = body_val.string;
56+
if (std.mem.indexOf(u8, body, "(")) |open| {
57+
if (std.mem.indexOf(u8, body[open..], ")")) |close| {
58+
return body[open + 1 .. open + close];
59+
}
60+
}
61+
}
62+
}
63+
64+
// Fallback to tag_name if it looks like a version
65+
if (obj.get("tag_name")) |tag| {
66+
if (tag == .string) {
67+
const t = tag.string;
68+
if (t.len > 0 and t[0] == 'v') return t;
69+
}
70+
}
71+
72+
return null;
73+
}
74+
75+
/// Fetch the latest zvm version from GitHub Releases API.
76+
/// Returns an owned string that the caller must free.
77+
pub fn fetchLatestVersion(
78+
allocator: std.mem.Allocator,
79+
io: std.Io,
80+
environ_map: *std.process.Environ.Map,
81+
proxy: []const u8,
82+
) ![]const u8 {
83+
const release_json = try http_client.downloadToMemoryWithProxy(
84+
allocator,
85+
io,
86+
environ_map,
87+
"https://api.github.com/repos/lispking/zvm/releases/latest",
88+
proxy,
89+
);
90+
defer allocator.free(release_json);
91+
92+
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, release_json, .{
93+
.ignore_unknown_fields = true,
94+
});
95+
defer parsed.deinit();
96+
97+
const version = extractVersionFromRelease(parsed.value) orelse return error.InvalidResponse;
98+
return allocator.dupe(u8, version);
99+
}
100+
101+
/// Read the cached latest version and check timestamp.
102+
/// Returns the cached version string (slice into `content_buf`) if valid, null otherwise.
103+
fn readCache(allocator: std.mem.Allocator, io: std.Io, cache_path: []const u8) ?[]const u8 {
104+
const file = std.Io.Dir.cwd().openFile(io, cache_path, .{}) catch return null;
105+
defer file.close(io);
106+
107+
var read_buf: [256]u8 = undefined;
108+
var reader = file.reader(io, &read_buf);
109+
const content = reader.interface.allocRemaining(allocator, .limited(256)) catch return null;
110+
111+
// Format: "<timestamp>\n<version>"
112+
const newline = std.mem.indexOfScalar(u8, content, '\n') orelse return null;
113+
const ts_str = content[0..newline];
114+
const cached_version = std.mem.trim(u8, content[newline + 1 ..], " \n\r");
115+
if (cached_version.len == 0) return null;
116+
117+
const timestamp = std.fmt.parseInt(i64, ts_str, 10) catch return null;
118+
const now = std.Io.Clock.Timestamp.now(io, .real).raw.toSeconds();
119+
if (now - timestamp > check_interval_secs) return null;
120+
121+
return cached_version;
122+
}
123+
124+
/// Write the latest version and current timestamp to the cache file.
125+
fn writeCache(io: std.Io, cache_path: []const u8, version: []const u8) void {
126+
const file = std.Io.Dir.cwd().createFile(io, cache_path, .{}) catch return;
127+
defer file.close(io);
128+
129+
const now = std.Io.Clock.Timestamp.now(io, .real).raw.toSeconds();
130+
131+
var buf: [512]u8 = undefined;
132+
var writer = file.writer(io, &buf);
133+
writer.interface.print("{d}\n{s}\n", .{ now, version }) catch {};
134+
writer.interface.flush() catch {};
135+
}
136+
137+
/// Check if a newer zvm version is available.
138+
/// Uses a 24-hour cache to avoid hammering the GitHub API.
139+
/// Returns an owned string (latest version) if an update is available, null otherwise.
140+
pub fn checkForUpdate(
141+
allocator: std.mem.Allocator,
142+
io: std.Io,
143+
environ_map: *std.process.Environ.Map,
144+
cache_dir: []const u8,
145+
proxy: []const u8,
146+
) ?[]const u8 {
147+
var path_buf: [std.fs.max_path_bytes * 2]u8 = undefined;
148+
const cache_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ cache_dir, cache_filename }) catch return null;
149+
150+
// Try reading from cache first
151+
var content_buf: [256]u8 = undefined;
152+
_ = &content_buf;
153+
if (readCache(allocator, io, cache_path)) |cached_version| {
154+
const current = stripVPrefix(VERSION);
155+
const latest = stripVPrefix(cached_version);
156+
if (versionGt(latest, current)) {
157+
return allocator.dupe(u8, cached_version) catch null;
158+
}
159+
return null;
160+
}
161+
162+
// Cache miss or stale — fetch from GitHub
163+
const latest = fetchLatestVersion(allocator, io, environ_map, proxy) catch return null;
164+
165+
// Update cache
166+
writeCache(io, cache_path, latest);
167+
168+
// Compare with current version
169+
const current = stripVPrefix(VERSION);
170+
const latest_stripped = stripVPrefix(latest);
171+
if (versionGt(latest_stripped, current)) {
172+
return latest;
173+
}
174+
175+
allocator.free(latest);
176+
return null;
177+
}
178+
179+
/// Print a non-intrusive update hint if a newer version is available.
180+
/// Designed to be called after commands that already use the network.
181+
pub fn printUpdateHint(
182+
allocator: std.mem.Allocator,
183+
io: std.Io,
184+
environ_map: *std.process.Environ.Map,
185+
cache_dir: []const u8,
186+
proxy: []const u8,
187+
stdout: *std.Io.Writer,
188+
) void {
189+
const latest = checkForUpdate(allocator, io, environ_map, cache_dir, proxy) orelse return;
190+
defer allocator.free(latest);
191+
192+
terminal.println(stdout, .yellow, "A new version of zvm is available: {s} (current: v{s})", .{ latest, VERSION }) catch {};
193+
stdout.print("Run `zvm upgrade` to update.\n\n", .{}) catch {};
194+
stdout.flush() catch {};
195+
}

src/main.zig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const std = @import("std");
77
const cli = @import("cli.zig");
88
const zvm_mod = @import("core/zvm.zig");
99
const terminal = @import("core/terminal.zig");
10+
const update_check = @import("core/update_check.zig");
1011
const errors = @import("core/errors.zig");
1112
const platform = @import("core/platform.zig");
1213
const build_options = @import("build_options");
@@ -80,6 +81,7 @@ pub fn main(init: std.process.Init) !void {
8081
.install => |inst| {
8182
const install = @import("command/install.zig");
8283
install.run(&zvm, allocator, inst.version, inst.flags, stdout, stderr) catch |err| commandFail(stderr, err);
84+
update_check.printUpdateHint(allocator, zvm.io, zvm.environ_map, zvm.cache_dir, zvm.settings.proxy, stdout);
8385
},
8486
.use => |use_cmd| {
8587
const use_mod = @import("command/use.zig");
@@ -117,6 +119,7 @@ pub fn main(init: std.process.Init) !void {
117119
.mirrorlist => |ml_cmd| {
118120
const mirrorlist = @import("command/mirrorlist.zig");
119121
mirrorlist.run(&zvm, allocator, ml_cmd.url, stdout, stderr) catch |err| commandFail(stderr, err);
122+
update_check.printUpdateHint(allocator, zvm.io, zvm.environ_map, zvm.cache_dir, zvm.settings.proxy, stdout);
120123
},
121124
.proxy => |proxy_cmd| {
122125
const proxy_mod = @import("command/proxy.zig");

0 commit comments

Comments
 (0)