Skip to content

Commit fa3175e

Browse files
committed
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).
1 parent 7eae454 commit fa3175e

3 files changed

Lines changed: 202 additions & 1 deletion

File tree

src/command/install.zig

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,20 @@ fn installVersion(
176176
allocator.free(active);
177177
} else {
178178
try zvm.setBin(version);
179+
180+
// On Windows, ensure the bin directory is in the user PATH
181+
if (platform.isWindows()) {
182+
var bin_buf: [std.fs.max_path_bytes]u8 = undefined;
183+
const bin_path = zvm.binPath(&bin_buf);
184+
185+
if (platform.addToUserPath(zvm.io, bin_path)) |added| {
186+
if (added) {
187+
console.plain("Added zvm bin directory to PATH. Please restart your terminal for changes to take effect.", .{});
188+
}
189+
} else |err| {
190+
console.warn("Failed to update PATH ({s}). Please add {s} to your PATH manually.", .{ @errorName(err), bin_path });
191+
}
192+
}
179193
}
180194

181195
// Clean up the downloaded archive

src/command/use.zig

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
//! Updates the bin symlink in the data directory to point to the requested version directory.
33

44
const std = @import("std");
5-
const zvm_mod = @import("../core/zvm.zig");
5+
66
const Console = @import("../core/Console.zig");
7+
const platform = @import("../core/platform.zig");
8+
const zvm_mod = @import("../core/zvm.zig");
79

810
/// Switch to an installed Zig version by updating the bin symlink.
911
/// Prints an error if the requested version is not installed.
@@ -24,4 +26,18 @@ pub fn run(
2426

2527
try zvm.setBin(version);
2628
console.plain("Now using Zig {s}", .{version});
29+
30+
// On Windows, ensure the bin directory is in the user PATH
31+
if (platform.isWindows()) {
32+
var bin_buf: [std.fs.max_path_bytes]u8 = undefined;
33+
const bin_path = zvm.binPath(&bin_buf);
34+
35+
if (platform.addToUserPath(zvm.io, bin_path)) |added| {
36+
if (added) {
37+
console.plain("Added zvm bin directory to PATH. Please restart your terminal for changes to take effect.", .{});
38+
}
39+
} else |err| {
40+
console.warn("Failed to update PATH ({s}). Please add {s} to your PATH manually.", .{ @errorName(err), bin_path });
41+
}
42+
}
2743
}

src/core/platform.zig

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,20 @@ pub fn getArchiveExtension() []const u8 {
6161
};
6262
}
6363

64+
/// Returns the platform-specific executable filename for a tool.
65+
/// Windows executables must keep their .exe suffix so shells and editors can find them.
66+
pub fn executableName(comptime base_name: []const u8) []const u8 {
67+
return switch (builtin.os.tag) {
68+
.windows => base_name ++ ".exe",
69+
else => base_name,
70+
};
71+
}
72+
73+
/// Returns true when the current target uses Windows executable semantics.
74+
pub fn isWindows() bool {
75+
return builtin.os.tag == .windows;
76+
}
77+
6478
/// Create a symbolic link at `link_path` pointing to `target`.
6579
/// Removes any existing file/link at `link_path` before creation.
6680
/// 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 {
212226
_ = src_reader.interface.streamRemaining(&dst_writer.interface) catch return error.CopyFailed;
213227
try dst_writer.interface.flush();
214228
}
229+
230+
// ─────────────────────────────────────────────────────────────────────────────
231+
// Windows PATH management — automatically add zvm bin directory to user PATH
232+
// ─────────────────────────────────────────────────────────────────────────────
233+
234+
/// Check if a directory is already in the Windows user PATH environment variable.
235+
/// Performs case-insensitive comparison (Windows paths are case-insensitive).
236+
/// Returns false on non-Windows platforms or on any error.
237+
pub fn isInUserPath(io: std.Io, dir_path: []const u8) bool {
238+
if (builtin.os.tag != .windows) return false;
239+
240+
// Normalize dir_path to use backslashes
241+
var dir_norm: [std.fs.max_path_bytes]u8 = undefined;
242+
if (dir_path.len > dir_norm.len) return false;
243+
@memcpy(dir_norm[0..dir_path.len], dir_path);
244+
std.mem.replaceScalar(u8, dir_norm[0..dir_path.len], '/', '\\');
245+
const normalized = dir_norm[0..dir_path.len];
246+
247+
// Read current user PATH from registry
248+
const read_result = std.process.run(std.heap.page_allocator, io, .{
249+
.argv = &.{ "reg", "query", "HKCU\\Environment", "/v", "PATH" },
250+
.stdout_limit = .limited(65536),
251+
.stderr_limit = .limited(4096),
252+
}) catch return false;
253+
defer std.heap.page_allocator.free(read_result.stdout);
254+
defer std.heap.page_allocator.free(read_result.stderr);
255+
256+
if (read_result.term != .exited or read_result.term.exited != 0) return false;
257+
258+
// Parse reg query output to find PATH value
259+
// Output format: " PATH REG_EXPAND_SZ C:\Users\..."
260+
const stdout = read_result.stdout;
261+
var line_iter = std.mem.splitScalar(u8, stdout, '\n');
262+
while (line_iter.next()) |line| {
263+
const trimmed = std.mem.trim(u8, line, " \r\t");
264+
// Look for REG_EXPAND_SZ or REG_SZ and extract the value
265+
if (std.mem.indexOf(u8, trimmed, "REG_EXPAND_SZ")) |idx| {
266+
const value_start = idx + "REG_EXPAND_SZ".len;
267+
const value = std.mem.trim(u8, trimmed[value_start..], " \t");
268+
return containsPathEntry(value, normalized);
269+
}
270+
if (std.mem.indexOf(u8, trimmed, "REG_SZ")) |idx| {
271+
const value_start = idx + "REG_SZ".len;
272+
const value = std.mem.trim(u8, trimmed[value_start..], " \t");
273+
return containsPathEntry(value, normalized);
274+
}
275+
}
276+
277+
return false;
278+
}
279+
280+
/// Add a directory to the Windows user PATH environment variable in the registry.
281+
/// Does nothing if the directory is already present.
282+
/// Returns true if PATH was updated, false if already present.
283+
/// No-op on non-Windows platforms (returns false).
284+
pub fn addToUserPath(io: std.Io, dir_path: []const u8) !bool {
285+
if (builtin.os.tag != .windows) return false;
286+
287+
// Normalize dir_path to use backslashes
288+
var dir_norm: [std.fs.max_path_bytes]u8 = undefined;
289+
if (dir_path.len > dir_norm.len) return error.PathTooLong;
290+
@memcpy(dir_norm[0..dir_path.len], dir_path);
291+
std.mem.replaceScalar(u8, dir_norm[0..dir_path.len], '/', '\\');
292+
const normalized = dir_norm[0..dir_path.len];
293+
294+
// Read current user PATH from registry
295+
const read_result = std.process.run(std.heap.page_allocator, io, .{
296+
.argv = &.{ "reg", "query", "HKCU\\Environment", "/v", "PATH" },
297+
.stdout_limit = .limited(65536),
298+
.stderr_limit = .limited(4096),
299+
}) catch return error.PathUpdateFailed;
300+
defer std.heap.page_allocator.free(read_result.stdout);
301+
defer std.heap.page_allocator.free(read_result.stderr);
302+
303+
// If reg query failed (e.g., PATH doesn't exist), create initial PATH
304+
if (read_result.term != .exited or read_result.term.exited != 0) {
305+
return try createInitialUserPath(io, normalized);
306+
}
307+
308+
// Parse reg query output to find current PATH value
309+
const stdout = read_result.stdout;
310+
var current_path: []const u8 = "";
311+
var line_iter = std.mem.splitScalar(u8, stdout, '\n');
312+
while (line_iter.next()) |line| {
313+
const trimmed = std.mem.trim(u8, line, " \r\t");
314+
if (std.mem.indexOf(u8, trimmed, "REG_EXPAND_SZ")) |idx| {
315+
const value_start = idx + "REG_EXPAND_SZ".len;
316+
current_path = std.mem.trim(u8, trimmed[value_start..], " \t");
317+
break;
318+
}
319+
if (std.mem.indexOf(u8, trimmed, "REG_SZ")) |idx| {
320+
const value_start = idx + "REG_SZ".len;
321+
current_path = std.mem.trim(u8, trimmed[value_start..], " \t");
322+
break;
323+
}
324+
}
325+
326+
// Check if already in PATH
327+
if (containsPathEntry(current_path, normalized)) return false;
328+
329+
// Build new PATH with the directory appended
330+
var new_path_buf: [65536]u8 = undefined;
331+
const separator = if (current_path.len > 0) ";" else "";
332+
const new_path = std.fmt.bufPrint(&new_path_buf, "{s}{s}{s}", .{ current_path, separator, normalized }) catch return error.PathTooLong;
333+
334+
// Write updated PATH to registry using reg add
335+
try writeUserPath(io, new_path);
336+
337+
return true;
338+
}
339+
340+
/// Create an initial user PATH entry in the registry with just the given directory.
341+
fn createInitialUserPath(io: std.Io, dir_path: []const u8) !bool {
342+
try writeUserPath(io, dir_path);
343+
return true;
344+
}
345+
346+
/// Write a value to the user PATH in the Windows registry.
347+
fn writeUserPath(io: std.Io, path_value: []const u8) !void {
348+
const result = std.process.run(std.heap.page_allocator, io, .{
349+
.argv = &.{ "reg", "add", "HKCU\\Environment", "/v", "PATH", "/t", "REG_EXPAND_SZ", "/d", path_value, "/f" },
350+
.stdout_limit = .limited(4096),
351+
.stderr_limit = .limited(4096),
352+
}) catch return error.PathUpdateFailed;
353+
defer std.heap.page_allocator.free(result.stdout);
354+
defer std.heap.page_allocator.free(result.stderr);
355+
356+
if (result.term != .exited or result.term.exited != 0)
357+
return error.PathUpdateFailed;
358+
}
359+
360+
/// Check if a semicolon-separated PATH string contains a specific entry.
361+
/// Performs case-insensitive, slash-normalized comparison and trims trailing backslashes.
362+
fn containsPathEntry(path_str: []const u8, entry: []const u8) bool {
363+
var iter = std.mem.splitScalar(u8, path_str, ';');
364+
while (iter.next()) |part| {
365+
var p = std.mem.trim(u8, part, " \t");
366+
// Trim trailing backslashes for comparison
367+
while (p.len > 0 and p[p.len - 1] == '\\') {
368+
p = p[0 .. p.len - 1];
369+
}
370+
// Case-insensitive, slash-normalized comparison
371+
if (pathEqual(p, entry)) return true;
372+
}
373+
return false;
374+
}
375+
376+
/// Compare two path strings case-insensitively, treating '/' and '\\' as equal.
377+
fn pathEqual(a: []const u8, b: []const u8) bool {
378+
if (a.len != b.len) return false;
379+
for (0..a.len) |i| {
380+
const ca: u8 = if (a[i] == '/') '\\' else a[i];
381+
const cb: u8 = if (b[i] == '/') '\\' else b[i];
382+
if (std.ascii.toLower(ca) != std.ascii.toLower(cb)) return false;
383+
}
384+
return true;
385+
}

0 commit comments

Comments
 (0)