From fa3175e3760dd7bf475e42f78e25e58d837196a9 Mon Sep 17 00:00:00 2001 From: zcg Date: Tue, 28 Apr 2026 20:12:12 +0800 Subject: [PATCH] feat(windows): auto-add zvm bin to user PATH on use/install On Windows, automatically add the zvm bin directory (junction) to the user's PATH environment variable in the registry (HKCU\Environment) when running 'zvm use' or on first 'zvm install'. This eliminates the need for Windows users to manually configure PATH. New terminal sessions will have zig available immediately after switching versions with 'zvm use'. Implementation: - platform.zig: Add isInUserPath, addToUserPath, containsPathEntry, pathEqual functions for Windows registry PATH management - platform.zig: Add isWindows() and executableName() helpers - use.zig: Call addToUserPath after setBin on Windows - install.zig: Call addToUserPath after first-install auto-activate Uses 'reg query' to read and 'reg add' to write HKCU\Environment\PATH. Comparison is case-insensitive and slash-normalized (/ vs \\). Non-Windows platforms are unaffected (early return guards). --- src/command/install.zig | 14 ++++ src/command/use.zig | 18 ++++- src/core/platform.zig | 171 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 1 deletion(-) diff --git a/src/command/install.zig b/src/command/install.zig index 77102a8..0669cd4 100644 --- a/src/command/install.zig +++ b/src/command/install.zig @@ -176,6 +176,20 @@ fn installVersion( allocator.free(active); } else { try zvm.setBin(version); + + // On Windows, ensure the bin directory is in the user PATH + if (platform.isWindows()) { + var bin_buf: [std.fs.max_path_bytes]u8 = undefined; + const bin_path = zvm.binPath(&bin_buf); + + if (platform.addToUserPath(zvm.io, bin_path)) |added| { + if (added) { + console.plain("Added zvm bin directory to PATH. Please restart your terminal for changes to take effect.", .{}); + } + } else |err| { + console.warn("Failed to update PATH ({s}). Please add {s} to your PATH manually.", .{ @errorName(err), bin_path }); + } + } } // Clean up the downloaded archive diff --git a/src/command/use.zig b/src/command/use.zig index ba7d7bd..51de999 100644 --- a/src/command/use.zig +++ b/src/command/use.zig @@ -2,8 +2,10 @@ //! Updates the bin symlink in the data directory to point to the requested version directory. const std = @import("std"); -const zvm_mod = @import("../core/zvm.zig"); + const Console = @import("../core/Console.zig"); +const platform = @import("../core/platform.zig"); +const zvm_mod = @import("../core/zvm.zig"); /// Switch to an installed Zig version by updating the bin symlink. /// Prints an error if the requested version is not installed. @@ -24,4 +26,18 @@ pub fn run( try zvm.setBin(version); console.plain("Now using Zig {s}", .{version}); + + // On Windows, ensure the bin directory is in the user PATH + if (platform.isWindows()) { + var bin_buf: [std.fs.max_path_bytes]u8 = undefined; + const bin_path = zvm.binPath(&bin_buf); + + if (platform.addToUserPath(zvm.io, bin_path)) |added| { + if (added) { + console.plain("Added zvm bin directory to PATH. Please restart your terminal for changes to take effect.", .{}); + } + } else |err| { + console.warn("Failed to update PATH ({s}). Please add {s} to your PATH manually.", .{ @errorName(err), bin_path }); + } + } } diff --git a/src/core/platform.zig b/src/core/platform.zig index 71a4a45..6fc0d72 100644 --- a/src/core/platform.zig +++ b/src/core/platform.zig @@ -61,6 +61,20 @@ pub fn getArchiveExtension() []const u8 { }; } +/// Returns the platform-specific executable filename for a tool. +/// Windows executables must keep their .exe suffix so shells and editors can find them. +pub fn executableName(comptime base_name: []const u8) []const u8 { + return switch (builtin.os.tag) { + .windows => base_name ++ ".exe", + else => base_name, + }; +} + +/// Returns true when the current target uses Windows executable semantics. +pub fn isWindows() bool { + return builtin.os.tag == .windows; +} + /// Create a symbolic link at `link_path` pointing to `target`. /// Removes any existing file/link at `link_path` before creation. /// On Windows, creates a directory junction (no admin privileges required). @@ -212,3 +226,160 @@ pub fn copyFile(io: std.Io, src_path: []const u8, dst_path: []const u8) !void { _ = src_reader.interface.streamRemaining(&dst_writer.interface) catch return error.CopyFailed; try dst_writer.interface.flush(); } + +// ───────────────────────────────────────────────────────────────────────────── +// Windows PATH management — automatically add zvm bin directory to user PATH +// ───────────────────────────────────────────────────────────────────────────── + +/// Check if a directory is already in the Windows user PATH environment variable. +/// Performs case-insensitive comparison (Windows paths are case-insensitive). +/// Returns false on non-Windows platforms or on any error. +pub fn isInUserPath(io: std.Io, dir_path: []const u8) bool { + if (builtin.os.tag != .windows) return false; + + // Normalize dir_path to use backslashes + var dir_norm: [std.fs.max_path_bytes]u8 = undefined; + if (dir_path.len > dir_norm.len) return false; + @memcpy(dir_norm[0..dir_path.len], dir_path); + std.mem.replaceScalar(u8, dir_norm[0..dir_path.len], '/', '\\'); + const normalized = dir_norm[0..dir_path.len]; + + // Read current user PATH from registry + const read_result = std.process.run(std.heap.page_allocator, io, .{ + .argv = &.{ "reg", "query", "HKCU\\Environment", "/v", "PATH" }, + .stdout_limit = .limited(65536), + .stderr_limit = .limited(4096), + }) catch return false; + defer std.heap.page_allocator.free(read_result.stdout); + defer std.heap.page_allocator.free(read_result.stderr); + + if (read_result.term != .exited or read_result.term.exited != 0) return false; + + // Parse reg query output to find PATH value + // Output format: " PATH REG_EXPAND_SZ C:\Users\..." + const stdout = read_result.stdout; + var line_iter = std.mem.splitScalar(u8, stdout, '\n'); + while (line_iter.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \r\t"); + // Look for REG_EXPAND_SZ or REG_SZ and extract the value + if (std.mem.indexOf(u8, trimmed, "REG_EXPAND_SZ")) |idx| { + const value_start = idx + "REG_EXPAND_SZ".len; + const value = std.mem.trim(u8, trimmed[value_start..], " \t"); + return containsPathEntry(value, normalized); + } + if (std.mem.indexOf(u8, trimmed, "REG_SZ")) |idx| { + const value_start = idx + "REG_SZ".len; + const value = std.mem.trim(u8, trimmed[value_start..], " \t"); + return containsPathEntry(value, normalized); + } + } + + return false; +} + +/// Add a directory to the Windows user PATH environment variable in the registry. +/// Does nothing if the directory is already present. +/// Returns true if PATH was updated, false if already present. +/// No-op on non-Windows platforms (returns false). +pub fn addToUserPath(io: std.Io, dir_path: []const u8) !bool { + if (builtin.os.tag != .windows) return false; + + // Normalize dir_path to use backslashes + var dir_norm: [std.fs.max_path_bytes]u8 = undefined; + if (dir_path.len > dir_norm.len) return error.PathTooLong; + @memcpy(dir_norm[0..dir_path.len], dir_path); + std.mem.replaceScalar(u8, dir_norm[0..dir_path.len], '/', '\\'); + const normalized = dir_norm[0..dir_path.len]; + + // Read current user PATH from registry + const read_result = std.process.run(std.heap.page_allocator, io, .{ + .argv = &.{ "reg", "query", "HKCU\\Environment", "/v", "PATH" }, + .stdout_limit = .limited(65536), + .stderr_limit = .limited(4096), + }) catch return error.PathUpdateFailed; + defer std.heap.page_allocator.free(read_result.stdout); + defer std.heap.page_allocator.free(read_result.stderr); + + // If reg query failed (e.g., PATH doesn't exist), create initial PATH + if (read_result.term != .exited or read_result.term.exited != 0) { + return try createInitialUserPath(io, normalized); + } + + // Parse reg query output to find current PATH value + const stdout = read_result.stdout; + var current_path: []const u8 = ""; + var line_iter = std.mem.splitScalar(u8, stdout, '\n'); + while (line_iter.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \r\t"); + if (std.mem.indexOf(u8, trimmed, "REG_EXPAND_SZ")) |idx| { + const value_start = idx + "REG_EXPAND_SZ".len; + current_path = std.mem.trim(u8, trimmed[value_start..], " \t"); + break; + } + if (std.mem.indexOf(u8, trimmed, "REG_SZ")) |idx| { + const value_start = idx + "REG_SZ".len; + current_path = std.mem.trim(u8, trimmed[value_start..], " \t"); + break; + } + } + + // Check if already in PATH + if (containsPathEntry(current_path, normalized)) return false; + + // Build new PATH with the directory appended + var new_path_buf: [65536]u8 = undefined; + const separator = if (current_path.len > 0) ";" else ""; + const new_path = std.fmt.bufPrint(&new_path_buf, "{s}{s}{s}", .{ current_path, separator, normalized }) catch return error.PathTooLong; + + // Write updated PATH to registry using reg add + try writeUserPath(io, new_path); + + return true; +} + +/// Create an initial user PATH entry in the registry with just the given directory. +fn createInitialUserPath(io: std.Io, dir_path: []const u8) !bool { + try writeUserPath(io, dir_path); + return true; +} + +/// Write a value to the user PATH in the Windows registry. +fn writeUserPath(io: std.Io, path_value: []const u8) !void { + const result = std.process.run(std.heap.page_allocator, io, .{ + .argv = &.{ "reg", "add", "HKCU\\Environment", "/v", "PATH", "/t", "REG_EXPAND_SZ", "/d", path_value, "/f" }, + .stdout_limit = .limited(4096), + .stderr_limit = .limited(4096), + }) catch return error.PathUpdateFailed; + defer std.heap.page_allocator.free(result.stdout); + defer std.heap.page_allocator.free(result.stderr); + + if (result.term != .exited or result.term.exited != 0) + return error.PathUpdateFailed; +} + +/// Check if a semicolon-separated PATH string contains a specific entry. +/// Performs case-insensitive, slash-normalized comparison and trims trailing backslashes. +fn containsPathEntry(path_str: []const u8, entry: []const u8) bool { + var iter = std.mem.splitScalar(u8, path_str, ';'); + while (iter.next()) |part| { + var p = std.mem.trim(u8, part, " \t"); + // Trim trailing backslashes for comparison + while (p.len > 0 and p[p.len - 1] == '\\') { + p = p[0 .. p.len - 1]; + } + // Case-insensitive, slash-normalized comparison + if (pathEqual(p, entry)) return true; + } + return false; +} + +/// Compare two path strings case-insensitively, treating '/' and '\\' as equal. +fn pathEqual(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + for (0..a.len) |i| { + const ca: u8 = if (a[i] == '/') '\\' else a[i]; + const cb: u8 = if (b[i] == '/') '\\' else b[i]; + if (std.ascii.toLower(ca) != std.ascii.toLower(cb)) return false; + } + return true; +}