├── .vscode ├── settings.json └── launch.json ├── deno.jsonc ├── .gitignore ├── cli ├── config_test.go ├── version_test.go ├── use_test.go ├── meta │ ├── version.go │ ├── errors.go │ ├── link_unix.go │ ├── cta.go │ └── link_win.go ├── uninstall.go ├── clean.go ├── fileperms_win.go ├── fileperms_unix.go ├── install_test.go ├── sync.go ├── install_extract_test.go ├── error.go ├── use.go ├── run.go ├── ls.go ├── config.go ├── version.go ├── settings.go ├── upgrade.go └── install.go ├── RELEASING.md ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── go.yml │ ├── daily_canary.yml │ └── install-script.yml └── FUNDING.yml ├── LICENSE ├── go.mod ├── GEMINI.md ├── CONTRIBUTING.MD ├── install.ps1 ├── go.sum ├── install.sh ├── deno.lock ├── main.go └── README.md /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.unstable": true 4 | } 5 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "build": "deno run -A build.ts", 4 | "clean": "rm -r build" 5 | }, 6 | "imports": { "@std/path": "jsr:@std/path@^0.219.1" } 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | zig-cache/ 2 | zig-out/ 3 | build/ 4 | dist/ 5 | demo/ 6 | zvm 7 | zvm.exe 8 | testdata/ 9 | .env* 10 | *.syso 11 | zvm-* 12 | .idea/ 13 | .gemini 14 | .codex 15 | .claude -------------------------------------------------------------------------------- /cli/config_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import "testing" 4 | 5 | func TestValidateVMUalias(t *testing.T) { 6 | if !validVmuAlis("mach") { 7 | t.Errorf("mach: should be true") 8 | } 9 | 10 | if !validVmuAlis("default") { 11 | t.Errorf("default: should be true") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing ZVM 2 | 3 | Releasing ZVM is meant to be simple. There are only 5 steps. 4 | 5 | 1. Update the `` in `cli/meta/version.go` 6 | 2. Create a new `git tag ` 7 | 3. Push the code to said version `git push origin ` 8 | 4. Build the releases with `deno task build` 9 | 5. Draft a new release on GitHub and upload the archives 10 | -------------------------------------------------------------------------------- /cli/version_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import "testing" 4 | 5 | func TestStripExcessSlashes(t *testing.T) { 6 | old := "https://releases.zigtools.org//v1/zls/select-version" 7 | new := cleanURL(old) 8 | 9 | if new != "https://releases.zigtools.org/v1/zls/select-version" { 10 | t.Errorf("expected https://releases.zigtools.org/v1/zls/select-version. Got %s", new) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /cli/use_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestSymlinkExists(t *testing.T) { 9 | if err := os.Symlink("use_test.go", "symlink.test"); err != nil { 10 | t.Error(err) 11 | } 12 | 13 | stat, err := os.Lstat("symlink.test") 14 | if err != nil { 15 | t.Errorf("%q: %s", err, stat.Name()) 16 | } 17 | 18 | defer os.Remove("symlink.test") 19 | } 20 | -------------------------------------------------------------------------------- /cli/meta/version.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | package meta 5 | 6 | import ( 7 | "fmt" 8 | "runtime" 9 | ) 10 | 11 | const ( 12 | VERSION = "v0.8.11" 13 | 14 | // VERSION = "v0.0.0" // For testing zvm upgrade 15 | 16 | ) 17 | 18 | var VerCopy = fmt.Sprintf("%s %s/%s", VERSION, runtime.GOOS, runtime.GOARCH) 19 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Package", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${fileDirname}" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /cli/meta/errors.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | package meta 5 | 6 | import "errors" 7 | 8 | var ( 9 | ErrWinEscToAdmin = errors.New("unable to rerun as Windows Administrator") 10 | ErrEscalatedSymlink = errors.New("unable to symlink as Administrator") 11 | ErrEscalatedHardlink = errors.New("unable to hardlink as Administrator") 12 | ) 13 | -------------------------------------------------------------------------------- /cli/meta/link_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | // Copyright 2025 Tristan Isham. All rights reserved. 4 | // Use of this source code is governed by the MIT 5 | // license that can be found in the LICENSE file. 6 | package meta 7 | 8 | import "os" 9 | 10 | // Link is a wrapper around Go's os.Symlink and os.Link functions, 11 | // On Windows, if Link is unable to create a symlink it will attempt to create a 12 | // hardlink before trying its automatic privilege escalation. 13 | func Link(oldname, newname string) error { 14 | return os.Symlink(oldname, newname) 15 | } 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** A clear and 10 | concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** A clear and concise description of what you 13 | want to happen. 14 | 15 | **Describe alternatives you've considered** A clear and concise description of 16 | any alternative solutions or features you've considered. 17 | 18 | **Additional context** Add any other context or screenshots about the feature 19 | request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: ["master"] 9 | pull_request: 10 | branches: ["master"] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v3 20 | with: 21 | go-version: 1.22 22 | - name: Install dependencies 23 | run: | 24 | go get . 25 | 26 | - name: Format 27 | run: go fmt ./... 28 | 29 | - name: Build 30 | run: go build -v . 31 | 32 | - name: Test 33 | run: go test -v ./... 34 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [tristanisham] 4 | patreon: # tristanisham # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | polar: tristanisham 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /cli/uninstall.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | package cli 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | ) 11 | 12 | // Uninstall removes the specified Zig version from the ZVM base directory. 13 | func (z *ZVM) Uninstall(version string) error { 14 | root, err := os.OpenRoot(z.baseDir) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | if _, err := root.Stat(version); err == nil { 20 | if err := root.RemoveAll(version); err != nil { 21 | return err 22 | } 23 | fmt.Printf("✔ Uninstalled %s.\nRun `zvm ls` to view installed versions.\n", version) 24 | return nil 25 | } 26 | fmt.Printf("Version: %s not found locally.\nHere are your installed versions:\n", version) 27 | return z.ListVersions() 28 | } 29 | -------------------------------------------------------------------------------- /cli/clean.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | package cli 5 | 6 | import ( 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | // Clean removes any compressed archives (.zip, .xz, .tar) from the ZVM base directory 12 | // to save disk space. It leaves the installed version directories intact. 13 | func (z *ZVM) Clean() error { 14 | dir, err := os.ReadDir(z.baseDir) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | for _, entry := range dir { 20 | if filepath.Ext(entry.Name()) == ".zip" || filepath.Ext(entry.Name()) == ".xz" || filepath.Ext(entry.Name()) == ".tar" { 21 | if err := os.Remove(filepath.Join(z.baseDir, entry.Name())); err != nil { 22 | return err 23 | } 24 | } 25 | } 26 | 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** A clear and concise description of what the bug is. 10 | 11 | **To Reproduce** Steps to reproduce the behavior: 12 | 13 | 1. Run `zvm ...` 2 Scroll to '....' 14 | 2. See error 15 | 16 | **Expected behavior** A clear and concise description of what you expected to 17 | happen. 18 | 19 | **Screenshots** If applicable, add screenshots to help explain your problem. 20 | 21 | **Desktop (please complete the following information):** 22 | 23 | - OS: [e.g. Windows] 24 | - Architecture [e.g. x86, arm64] 25 | - Version [e.g. v.0.5.7] 26 | 27 | **Configuration:** 28 | 29 | - VMU (Version Map URL) or alias [e.g. default, mach] 30 | 31 | **Additional context** Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright © 2025 Tristan Isham 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /cli/fileperms_win.go: -------------------------------------------------------------------------------- 1 | //go:build !linux 2 | 3 | // Copyright 2025 Tristan Isham. All rights reserved. 4 | // Use of this source code is governed by the MIT 5 | // license that can be found in the LICENSE file. 6 | 7 | package cli 8 | 9 | import "os" 10 | 11 | // canModifyFile checks if the current user has permission to modify the given file path. 12 | // On Windows, it primarily checks the read-only attribute and user permissions. 13 | func canModifyFile(path string) (bool, error) { 14 | fileInfo, err := os.Stat(path) 15 | if err != nil { 16 | return false, err 17 | } 18 | 19 | // Get the file's permission mode 20 | perm := fileInfo.Mode().Perm() 21 | 22 | // Check if the file is writable by the current user 23 | if perm&0200 != 0 { 24 | return true, nil 25 | } 26 | 27 | // if runtime.GOOS == "linux" { 28 | // // If the file isn't globally writable, check if it's writable by the file's group 29 | // if perm&0020 != 0 { 30 | // currentUser, err := user.Current() 31 | // if err != nil { 32 | // return false, err 33 | // } 34 | // fileGroup, err := user.LookupGroupId(fmt.Sprint(fileInfo.Sys().(*syscall.Stat_t).Gid)) 35 | // if err != nil { 36 | // return false, err 37 | // } 38 | // if currentUser.Gid == fileGroup.Gid { 39 | // return true, nil 40 | // } 41 | // } 42 | // } 43 | 44 | return false, nil 45 | } 46 | -------------------------------------------------------------------------------- /cli/fileperms_unix.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | // Copyright 2025 Tristan Isham. All rights reserved. 4 | // Use of this source code is governed by the MIT 5 | // license that can be found in the LICENSE file. 6 | 7 | package cli 8 | 9 | import ( 10 | "fmt" 11 | "os" 12 | "os/user" 13 | "runtime" 14 | "syscall" 15 | ) 16 | 17 | // canModifyFileLinux is the same as canModifyFile but with an aditional syscall 18 | // that can be used to determine if a file is editable because of 19 | // group permissions. 20 | func canModifyFile(path string) (bool, error) { 21 | fileInfo, err := os.Stat(path) 22 | if err != nil { 23 | return false, err 24 | } 25 | 26 | // Get the file's permission mode 27 | perm := fileInfo.Mode().Perm() 28 | 29 | // Check if the file is writable by the current user 30 | if perm&0200 != 0 { 31 | return true, nil 32 | } 33 | 34 | if runtime.GOOS == "linux" { 35 | // If the file isn't globally writable, check if it's writable by the file's group 36 | if perm&0020 != 0 { 37 | currentUser, err := user.Current() 38 | if err != nil { 39 | return false, err 40 | } 41 | fileGroup, err := user.LookupGroupId(fmt.Sprint(fileInfo.Sys().(*syscall.Stat_t).Gid)) 42 | if err != nil { 43 | return false, err 44 | } 45 | if currentUser.Gid == fileGroup.Gid { 46 | return true, nil 47 | } 48 | } 49 | } 50 | 51 | return false, nil 52 | } 53 | -------------------------------------------------------------------------------- /cli/install_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | package cli 6 | 7 | import ( 8 | "testing" 9 | ) 10 | 11 | func TestExtract(t *testing.T) { 12 | copy := "zpm:zl:s@te@st" 13 | result := ExtractInstall(copy) 14 | if result.Site != "zpm" { 15 | t.Fatalf("Recieved '%q'. Wanted %q", result.Site, "zpm") 16 | } 17 | 18 | if result.Package != "zl:s" { 19 | t.Fatalf("Recieved %q. Wanted %q", result.Package, "zl:s") 20 | } 21 | 22 | if result.Version != "te@st" { 23 | t.Fatalf("Recieved %q. Wanted %q", result.Version, "te@st") 24 | } 25 | } 26 | 27 | func TestSitePkg(t *testing.T) { 28 | copy := "zpm:zl:s" 29 | result := ExtractInstall(copy) 30 | if result.Site != "zpm" { 31 | t.Fatalf("Recieved '%q'. Wanted %q", result.Site, "zpm") 32 | } 33 | 34 | if result.Package != "zl:s" { 35 | t.Fatalf("Recieved %q. Wanted %q", result.Package, "zl:s") 36 | } 37 | 38 | if result.Version != "" { 39 | t.Fatalf("Recieved %q. Wanted %q", result.Version, "") 40 | } 41 | } 42 | 43 | func TestPkg(t *testing.T) { 44 | copy := "zls@11" 45 | result := ExtractInstall(copy) 46 | if result.Site != "" { 47 | t.Fatalf("Recieved '%q'. Wanted %q", result.Site, "") 48 | } 49 | 50 | if result.Package != "zls" { 51 | t.Fatalf("Recieved %q. Wanted %q", result.Package, "zls") 52 | } 53 | 54 | if result.Version != "11" { 55 | t.Fatalf("Recieved %q. Wanted %q", result.Version, "11") 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tristanisham/zvm 2 | 3 | go 1.25.5 4 | 5 | require ( 6 | github.com/charmbracelet/lipgloss v1.1.0 7 | github.com/charmbracelet/log v0.4.2 8 | github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 9 | github.com/schollz/progressbar/v3 v3.18.0 10 | github.com/tristanisham/clr v0.0.0-20221004001624-00ee60046d85 11 | golang.org/x/mod v0.30.0 12 | golang.org/x/sys v0.38.0 13 | ) 14 | 15 | require ( 16 | github.com/charmbracelet/colorprofile v0.3.3 // indirect 17 | github.com/charmbracelet/x/ansi v0.11.2 // indirect 18 | github.com/charmbracelet/x/cellbuf v0.0.14 // indirect 19 | github.com/charmbracelet/x/term v0.2.2 // indirect 20 | github.com/clipperhouse/displaywidth v0.6.1 // indirect 21 | github.com/clipperhouse/stringish v0.1.1 // indirect 22 | github.com/clipperhouse/uax29/v2 v2.3.0 // indirect 23 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 24 | golang.org/x/crypto v0.45.0 // indirect 25 | ) 26 | 27 | require ( 28 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 29 | github.com/go-logfmt/logfmt v0.6.1 // indirect 30 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect 31 | github.com/mattn/go-isatty v0.0.20 // indirect 32 | github.com/mattn/go-runewidth v0.0.19 // indirect 33 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 34 | github.com/muesli/termenv v0.16.0 // indirect 35 | github.com/rivo/uniseg v0.4.7 // indirect 36 | github.com/urfave/cli/v3 v3.6.1 37 | golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect 38 | golang.org/x/term v0.37.0 // indirect 39 | ) 40 | -------------------------------------------------------------------------------- /cli/sync.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | package cli 6 | 7 | import ( 8 | "bufio" 9 | "fmt" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | ) 14 | 15 | // Sync parses the build.zig file in the current directory to find a "zvm-lock" configuration 16 | // and switches to the specified Zig version. 17 | func (z *ZVM) Sync() error { 18 | cwd, err := os.Getwd() 19 | if err != nil { 20 | cwd = "." 21 | } 22 | 23 | buildZigPath := filepath.Join(cwd, "build.zig") 24 | if _, err := os.Stat(buildZigPath); err != nil { 25 | return fmt.Errorf("build.zig not found in %q", buildZigPath) 26 | } 27 | 28 | buildFile, err := os.Open(buildZigPath) 29 | if err != nil { 30 | return fmt.Errorf("error opening build.zig: %q", err) 31 | } 32 | defer buildFile.Close() 33 | 34 | scanner := bufio.NewScanner(buildFile) 35 | 36 | for scanner.Scan() { 37 | line := scanner.Text() 38 | 39 | if !strings.HasPrefix(strings.TrimSpace(line), "//!") { 40 | continue 41 | } 42 | 43 | variable := line[3:] 44 | variablePieces := strings.Split(strings.TrimSpace(variable), ":") 45 | if len(variablePieces) > 2 { 46 | return fmt.Errorf("improper config variable formatting: %q. Should be key: value", variablePieces) 47 | } 48 | 49 | switch strings.ToLower(strings.TrimSpace(variablePieces[0])) { 50 | case "zvm-lock": 51 | val := strings.TrimSpace(variablePieces[1]) 52 | if err := z.Use(val); err != nil { 53 | return err 54 | } 55 | } 56 | } 57 | 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /cli/install_extract_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestExtractInstall(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | input string 11 | want installRequest 12 | }{ 13 | { 14 | name: "Package only", 15 | input: "zig", 16 | want: installRequest{Package: "zig"}, 17 | }, 18 | { 19 | name: "Package with version", 20 | input: "zig@0.12.0", 21 | want: installRequest{Package: "zig", Version: "0.12.0"}, 22 | }, 23 | { 24 | name: "Site and package", 25 | input: "github:tristanisham/myrepo", 26 | want: installRequest{Site: "github", Package: "tristanisham/myrepo"}, 27 | }, 28 | { 29 | name: "Site, package, and version", 30 | input: "github:tristanisham/myrepo@main", 31 | want: installRequest{Site: "github", Package: "tristanisham/myrepo", Version: "main"}, 32 | }, 33 | { 34 | name: "Empty string", 35 | input: "", 36 | want: installRequest{}, 37 | }, 38 | { 39 | name: "Only at symbol", 40 | input: "@", 41 | want: installRequest{Version: ""}, // Package will be empty, version is empty 42 | }, 43 | { 44 | name: "Only colon symbol", 45 | input: ":", 46 | want: installRequest{Site: "", Package: ""}, // Site will be empty, package will be empty 47 | }, 48 | { 49 | name: "Site with empty package", 50 | input: "site:", 51 | want: installRequest{Site: "site", Package: ""}, 52 | }, 53 | { 54 | name: "Site with empty package and version", 55 | input: "site:@1.0", 56 | want: installRequest{Site: "site", Package: "", Version: "1.0"}, 57 | }, 58 | } 59 | 60 | for _, tt := range tests { 61 | t.Run(tt.name, func(t *testing.T) { 62 | got := ExtractInstall(tt.input) 63 | if got.Site != tt.want.Site || got.Package != tt.want.Package || got.Version != tt.want.Version { 64 | t.Errorf("ExtractInstall(%q) got = %+v, want %+v", tt.input, got, tt.want) 65 | } 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /.github/workflows/daily_canary.yml: -------------------------------------------------------------------------------- 1 | name: Daily Canary Test 2 | 3 | on: 4 | schedule: 5 | - cron: '0 14 * * *' # Runs at 9:00 AM EST (14:00 UTC) every day 6 | workflow_dispatch: 7 | 8 | jobs: 9 | integration-test: 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | # Test on all major OSs to ensure your installer works everywhere 14 | os: [ubuntu-latest, windows-latest, macos-latest] 15 | 16 | runs-on: ${{ matrix.os }} 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v3 23 | with: 24 | go-version: 1.25.5 25 | 26 | - name: Build ZVM 27 | run: go build -o zvm main.go 28 | 29 | - name: Test Install (Master) 30 | shell: bash 31 | run: | 32 | # Add current directory to PATH so we can call ./zvm easily 33 | export PATH=$PWD:$PATH 34 | 35 | echo "Installing Zig master..." 36 | ./zvm install master 37 | 38 | # Verify the file exists (sanity check) 39 | # Note: On Windows this path might differ slightly, but zvm manages it. 40 | # We will trust zvm ls or running zig directly. 41 | 42 | echo "Verifying installation..." 43 | ./zvm ls 44 | 45 | # Use the installed version 46 | ./zvm use master 47 | 48 | # Test if 'zig' command works 49 | # We need to source the env or add the zvm bin path manually for the test session 50 | # The standard path is usually ~/.zvm/bin 51 | export PATH="$HOME/.zvm/bin:$PATH" 52 | 53 | echo "Checking Zig version..." 54 | zig version 55 | 56 | - name: Test Install (Stable) 57 | shell: bash 58 | run: | 59 | export PATH=$PWD:$PATH 60 | export PATH="$HOME/.zvm/bin:$PATH" 61 | 62 | echo "Installing Zig 0.13.0 (Example Stable)..." 63 | ./zvm install 0.13.0 64 | ./zvm use 0.13.0 65 | zig version 66 | -------------------------------------------------------------------------------- /cli/error.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | package cli 6 | 7 | import ( 8 | "errors" 9 | ) 10 | 11 | var ( 12 | // ErrMissingBundlePath is returned when the download path for a version's bundle cannot be found. 13 | ErrMissingBundlePath = errors.New("bundle download path not found") 14 | // ErrUnsupportedSystem is returned when the current operating system or architecture is not supported by Zig. 15 | ErrUnsupportedSystem = errors.New("unsupported system for Zig") 16 | // ErrUnsupportedVersion is returned when the requested Zig version is not available. 17 | ErrUnsupportedVersion = errors.New("unsupported Zig version") 18 | // ErrMissingInstallPathEnv is returned when the ZVM_INSTALL environment variable is missing. 19 | ErrMissingInstallPathEnv = errors.New("env 'ZVM_INSTALL' is not set") 20 | // ErrFailedUpgrade is returned when the self-upgrade process fails. 21 | ErrFailedUpgrade = errors.New("failed to self-upgrade zvm") 22 | // ErrInvalidVersionMap is returned when the fetched version map JSON is invalid or corrupted. 23 | ErrInvalidVersionMap = errors.New("invalid version map format") 24 | // ErrInvalidInput is returned when the user provides invalid input arguments. 25 | ErrInvalidInput = errors.New("invalid input") 26 | // ErrDownloadFail is returned when fetching Zig, or constructing a target URL to fetch Zig, fails. 27 | ErrDownloadFail = errors.New("failed to download Zig") 28 | // ErrNoZlsVersion is returned when the ZLS release worker returns an error or no version is found. 29 | ErrNoZlsVersion = errors.New("zls release worker returned error") 30 | // ErrMissingVersionInfo is returned when version information is missing from the API response. 31 | ErrMissingVersionInfo = errors.New("version info not found") 32 | // ErrMissingShasum is returned when the SHA256 checksum is missing for a download. 33 | ErrMissingShasum = errors.New("shasum not found") 34 | // ErrZigNotInstalled is returned when the `zig` executable cannot be found in the PATH. 35 | ErrZigNotInstalled = errors.New("exec `zig` not found on $PATH") 36 | ) 37 | -------------------------------------------------------------------------------- /cli/meta/cta.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | package meta 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | 10 | "github.com/charmbracelet/lipgloss" 11 | "github.com/charmbracelet/log" 12 | ) 13 | 14 | // CtaFatal prints an aesthetic CTA and exits with an error. 15 | func CtaFatal(err error) { 16 | style := lipgloss.NewStyle(). 17 | Bold(true). 18 | Foreground(lipgloss.Color("#FAFAFA")). 19 | Background(lipgloss.Color("#db0913")). 20 | Width(10). 21 | MarginTop(1). 22 | MarginBottom(1). 23 | Align(lipgloss.Center) 24 | fmt.Println(style.Render("Error")) 25 | log.Error(err) 26 | 27 | blueLink := lipgloss.NewStyle(). 28 | Foreground(lipgloss.Color("#0000EE")). 29 | Bold(true). 30 | Underline(true) 31 | 32 | yellowText := lipgloss.NewStyle(). 33 | Foreground(lipgloss.Color("#fee12b")) 34 | 35 | fmt.Printf("\nIf you're experiencing a bug, run %s. If there's a new version of ZVM, we may have already fixed your bug in a new release :)\n", yellowText.Render("zvm upgrade")) 36 | fmt.Printf("Otherwise, please report this error as a GitHub issue.\n%s\n", blueLink.Render("https://github.com/tristanisham/zvm/issues/\n")) 37 | os.Exit(1) 38 | } 39 | 40 | // CtaUpgradeAvailable prints an aesthetic notice. 41 | func CtaUpgradeAvailable(tag string) { 42 | style := lipgloss.NewStyle(). 43 | Bold(true). 44 | Foreground(lipgloss.Color("#FAFAFA")). 45 | Background(lipgloss.Color("#6FA8DC")). 46 | Width(10). 47 | MarginTop(1). 48 | MarginBottom(1). 49 | Align(lipgloss.Center) 50 | fmt.Println(style.Render("Notice")) 51 | 52 | blueLink := lipgloss.NewStyle(). 53 | Foreground(lipgloss.Color("#0000EE")). 54 | Bold(true). 55 | Underline(true) 56 | 57 | yellowText := lipgloss.NewStyle(). 58 | Foreground(lipgloss.Color("#fee12b")) 59 | 60 | fmt.Printf("\nZVM %s is available. You are currently on %s.\n\nRun %s or download the latest release at\n%s\n\n", blueLink.Render(tag), blueLink.Render(VERSION), yellowText.Render("zvm upgrade"), blueLink.Render("https://github.com/tristanisham/zvm/releases/latest")) 61 | } 62 | 63 | // CtaGeneric prints an aesthetic generic notice. 64 | func CtaGeneric(header string, text string) { 65 | style := lipgloss.NewStyle(). 66 | Bold(true). 67 | Foreground(lipgloss.Color("#FAFAFA")). 68 | Background(lipgloss.Color("#6FA8DC")). 69 | Width(10). 70 | MarginTop(1). 71 | MarginBottom(1). 72 | Align(lipgloss.Center) 73 | fmt.Println(style.Render(header)) 74 | 75 | fmt.Println(text) 76 | } 77 | -------------------------------------------------------------------------------- /cli/use.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | package cli 6 | 7 | import ( 8 | "bufio" 9 | "errors" 10 | "fmt" 11 | "os" 12 | "path/filepath" 13 | 14 | "strings" 15 | 16 | "github.com/charmbracelet/log" 17 | "github.com/tristanisham/zvm/cli/meta" 18 | ) 19 | 20 | // Use switches the active Zig version to the specified one. 21 | // If the version is not installed, it prompts the user to install it. 22 | func (z *ZVM) Use(ver string) error { 23 | if err := z.getVersion(ver); err != nil { 24 | if errors.Is(err, os.ErrNotExist) { 25 | 26 | fmt.Printf("It looks like %s isn't installed. Would you like to install it? [y/n]\n", ver) 27 | if getConfirmation() { 28 | if err = z.Install(ver, false, true); err != nil { 29 | return err 30 | } 31 | } else { 32 | return fmt.Errorf("version %s is not installed", ver) 33 | } 34 | } 35 | } 36 | 37 | return z.setBin(ver) 38 | } 39 | 40 | // setBin updates the symbolic link 'bin' in the ZVM base directory to point to the specified version's bin directory. 41 | func (z *ZVM) setBin(ver string) error { 42 | // .zvm/master 43 | version_path := filepath.Join(z.baseDir, ver) 44 | binDir := filepath.Join(z.baseDir, "bin") 45 | 46 | // Came across https://pkg.go.dev/os#Lstat 47 | // which is specifically to check symbolic links. 48 | // Seemed like the more appropriate solution here 49 | stat, err := os.Lstat(binDir) 50 | 51 | // Actually we need to check if the symbolic link to ~/.zvm/bin 52 | // exists yet, otherwise we get err: 53 | // 54 | // CreateFile C:\Users\gs\.zvm\bin: The system cannot find the file specified. 55 | // 56 | // which leads to evaluation of the else case (line 59) and to an early return 57 | // therefore the the initial symbolic link is never created. 58 | if stat != nil { 59 | if err == nil { 60 | log.Debugf("Removing old %s", binDir) 61 | if err := os.Remove(binDir); err != nil { 62 | return err 63 | } 64 | } else { 65 | return fmt.Errorf("%w: %s", err, stat.Name()) 66 | } 67 | } 68 | 69 | if err := meta.Link(version_path, binDir); err != nil { 70 | return err 71 | } 72 | 73 | log.Debug("Use", "version", ver) 74 | return nil 75 | } 76 | 77 | // getConfirmation prompts the user for a yes/no confirmation via stdin. 78 | func getConfirmation() bool { 79 | reader := bufio.NewReader(os.Stdin) 80 | text, _ := reader.ReadString('\n') 81 | 82 | answer := strings.TrimSpace(strings.ToLower(text)) 83 | return answer == "y" || answer == "ye" || answer == "yes" 84 | } 85 | -------------------------------------------------------------------------------- /cli/meta/link_win.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | // Copyright 2025 Tristan Isham. All rights reserved. 4 | // Use of this source code is governed by the MIT 5 | // license that can be found in the LICENSE file. 6 | package meta 7 | 8 | import ( 9 | // "bytes" 10 | "errors" 11 | "os" 12 | 13 | // "os/exec" 14 | "strings" 15 | "syscall" 16 | 17 | // "github.com/charmbracelet/log" 18 | "github.com/charmbracelet/log" 19 | "golang.org/x/sys/windows" 20 | ) 21 | 22 | // becomeAdmin attempts to re-run the current executable with administrative privileges using "runas". 23 | func becomeAdmin() error { 24 | verb := "runas" 25 | exe, _ := os.Executable() 26 | cwd, _ := os.Getwd() 27 | args := strings.Join(os.Args[1:], " ") 28 | 29 | verbPtr, _ := syscall.UTF16PtrFromString(verb) 30 | exePtr, _ := syscall.UTF16PtrFromString(exe) 31 | cwdPtr, _ := syscall.UTF16PtrFromString(cwd) 32 | argPtr, _ := syscall.UTF16PtrFromString(args) 33 | 34 | var showCmd int32 = 1 // SW_NORMAL 35 | 36 | err := windows.ShellExecute(0, verbPtr, exePtr, argPtr, cwdPtr, showCmd) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | return nil 42 | } 43 | 44 | // isAdmin checks if the current process has administrative privileges. 45 | func isAdmin() bool { 46 | _, err := os.Open("\\\\.\\PHYSICALDRIVE0") 47 | 48 | return err == nil 49 | } 50 | 51 | // Link is a wrapper around Go's os.Symlink and os.Link functions, 52 | // On Windows, if Link is unable to create a symlink it will attempt to create a 53 | // hardlink before trying its automatic privilege escalation. 54 | func Link(oldname, newname string) error { 55 | // Attempt to do a regular symlink if allowed by user's permissions 56 | if err := os.Symlink(oldname, newname); err != nil { 57 | // If that fails, try to create an old hardlink. 58 | if err := os.Link(oldname, newname); err == nil { 59 | return nil 60 | } 61 | // If creating a hardlink fails, check to see if the user is an admin. 62 | // If they're not an admin, try to become an admin and retry making a symlink. 63 | if !isAdmin() { 64 | log.Error("Symlink & Hardlink failed", "admin", false) 65 | 66 | // If not already admin, try to become admin 67 | if adminErr := becomeAdmin(); adminErr != nil { 68 | return errors.Join(ErrWinEscToAdmin, adminErr, err) 69 | } 70 | 71 | if err := os.Symlink(oldname, newname); err != nil { 72 | if err := os.Link(oldname, newname); err == nil { 73 | return nil 74 | } 75 | 76 | return errors.Join(ErrEscalatedSymlink, ErrEscalatedHardlink, err) 77 | } 78 | 79 | return nil 80 | } 81 | 82 | return errors.Join(ErrEscalatedSymlink, ErrEscalatedHardlink, err) 83 | 84 | } 85 | 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /GEMINI.md: -------------------------------------------------------------------------------- 1 | # Project Context: ZVM (Zig Version Manager) 2 | 3 | ## Project Overview 4 | `zvm` is a cross-platform version manager for the Zig programming language, written in Go. It allows users to install, switch between, and manage multiple versions of Zig (including master/nightly builds and tagged releases) and ZLS (Zig Language Server). It is designed to be simple, fast, and standalone, with minimal dependencies (only `tar` on Unix systems). 5 | 6 | ## Architecture & Tech Stack 7 | * **Language:** Go (v1.25.3) 8 | * **Build System:** Deno (`build.ts`) is used for cross-platform compilation and bundling, though standard `go build` works for local development. 9 | * **Key Libraries:** 10 | * `github.com/urfave/cli/v3`: CLI application framework. 11 | * `github.com/charmbracelet/log` & `lipgloss`: Styled terminal output and logging. 12 | * `github.com/jedisct1/go-minisign`: Verifying Zig signatures. 13 | 14 | ### Core Components 15 | * **`main.go`**: The application entry point. Configures the CLI commands (`install`, `use`, `ls`, `clean`, `upgrade`, `vmu`) and flags. 16 | * **`cli/` Package**: Contains the core business logic. 17 | * **`config.go`**: Handles initialization (`Initialize`), configuration loading, and the `ZVM` struct definition. 18 | * **`install.go`**: Logic for downloading, verifying (shasum/minisign), and extracting Zig versions. 19 | * **`use.go`**: Manages switching versions (symlinking). 20 | * **`ls.go`**: Listing installed and available remote versions. 21 | * **`settings.go`**: Manages `~/.zvm/settings.json`. 22 | * **`upgrade.go`**: Self-upgrade functionality. 23 | 24 | ## Building and Running 25 | 26 | ### Prerequisites 27 | * Go 1.25+ 28 | * Deno (optional, for release builds) 29 | 30 | ### Development Commands 31 | * **Run locally:** 32 | ```bash 33 | go run main.go [command] 34 | ``` 35 | * **Build locally:** 36 | ```bash 37 | go build -o zvm main.go 38 | ``` 39 | * **Run Tests:** 40 | ```bash 41 | go test ./... 42 | ``` 43 | * **Cross-Platform Build (Release):** 44 | ```bash 45 | deno run -A build.ts 46 | ``` 47 | This script compiles `zvm` for Windows, Linux, macOS, *BSD, and Solaris, creating artifacts in the `build/` directory. 48 | 49 | ## Development Conventions 50 | * **Formatting:** Strict adherence to `go fmt`. 51 | * **Naming:** use `camelCase` for all variables, functions, and fields. 52 | * **Visibility:** Default to private (lowercase) for functions/variables unless they *must* be exported for external package use. 53 | * **Environment:** The application expects to operate within `~/.zvm` (or `ZVM_PATH`) and uses `ZVM_INSTALL` and `PATH` modifications to function correctly. 54 | 55 | ## Key Files 56 | * `main.go`: CLI definition and entry point. 57 | * `cli/config.go`: Main `ZVM` struct and initialization logic. 58 | * `build.ts`: Release build script (TypeScript/Deno). 59 | * `CONTRIBUTING.MD`: Contribution guidelines and coding standards. 60 | -------------------------------------------------------------------------------- /.github/workflows/install-script.yml: -------------------------------------------------------------------------------- 1 | name: Test ZVM Install Script 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | workflow_dispatch: 9 | jobs: 10 | test-install: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | shell: [bash, zsh, fish] 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v3 18 | 19 | - name: Install additional shell (if needed) 20 | run: | 21 | sudo apt-get update 22 | if [ "${{ matrix.shell }}" = "zsh" ]; then 23 | sudo apt-get install -y zsh 24 | elif [ "${{ matrix.shell }}" = "fish" ]; then 25 | sudo apt-get install -y fish 26 | fi 27 | 28 | - name: Test install script in ${{ matrix.shell }} 29 | shell: bash 30 | run: | 31 | set -euxo pipefail 32 | 33 | # Create a temporary home directory for testing. 34 | TEST_HOME=$(mktemp -d) 35 | echo "Using temporary home directory: $TEST_HOME" 36 | export HOME="$TEST_HOME" 37 | 38 | # Create the appropriate shell configuration file and set $SHELL accordingly. 39 | if [ "${{ matrix.shell }}" = "bash" ]; then 40 | touch "$HOME/.bashrc" 41 | echo "Using Bash startup file: $HOME/.bashrc" 42 | export SHELL="/bin/bash" 43 | elif [ "${{ matrix.shell }}" = "zsh" ]; then 44 | touch "$HOME/.zshrc" 45 | echo "Using Zsh startup file: $HOME/.zshrc" 46 | export SHELL="/bin/zsh" 47 | elif [ "${{ matrix.shell }}" = "fish" ]; then 48 | mkdir -p "$HOME/.config/fish" 49 | touch "$HOME/.config/fish/config.fish" 50 | echo "Using Fish startup file: $HOME/.config/fish/config.fish" 51 | export SHELL="/usr/bin/fish" 52 | fi 53 | 54 | # Stub out the download part of the install script to avoid network calls. 55 | # This replaces the install_latest function definition with a stub. 56 | sed -i 's/install_latest() {/install_latest() {\n echo "Skipping download in test";\n return 0;/' install.sh 57 | 58 | # Run the install script. 59 | bash install.sh 60 | 61 | # Determine which configuration file should have been updated and verify the expected content. 62 | if [ "${{ matrix.shell }}" = "fish" ]; then 63 | CONFIG_FILE="$HOME/.config/fish/config.fish" 64 | grep -q 'set -gx ZVM_INSTALL "$HOME/.zvm/self"' "$CONFIG_FILE" 65 | echo "Fish configuration updated successfully." 66 | elif [ "${{ matrix.shell }}" = "zsh" ]; then 67 | CONFIG_FILE="$HOME/.zshrc" 68 | grep -q 'export ZVM_INSTALL="$HOME/.zvm/self"' "$CONFIG_FILE" 69 | echo "Zsh configuration updated successfully." 70 | else 71 | CONFIG_FILE="$HOME/.bashrc" 72 | grep -q 'export ZVM_INSTALL="$HOME/.zvm/self"' "$CONFIG_FILE" 73 | echo "Bash configuration updated successfully." 74 | fi 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.MD: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for contributing to ZVM. I'm just one guy, so all contributions are 4 | welcome and appreciated. However, in order to save us all some time, I've 5 | decided to write up some basic rules for contributing that I'd like all future 6 | P.R.'s to follow. 7 | 8 | 1. Please run `go fmt .` before submitting your P.R. Go is a very opinionated 9 | language, and how you define function can affect visibilty across the whole 10 | program. Unless your function needs to be used by a different package, 11 | default to private (lowercase names). You should also use camelCase for all 12 | your variables, functions, and fields. Submissions using snake case, kebab 13 | case, or any other case will be refused until adjusted to camel case. 14 | 15 | 2. I have a lot of obligations besides this repository so P.R.s may languish for 16 | a few days or weeks while I deal with my external obligations. It is 17 | important to me that you know that this is a very actively developed 18 | repository and all P.R.s will be reviewed within a month of submission. 19 | Probably sooner. If you would like to talk to me about your P.R. feel free to 20 | [email](mailto:tristan.isham@hey.com) me or reach out on Discord or Twitter. 21 | I check all regularly. 22 | 23 | I hope you feel comfortable contributing to ZVM and know that I greatly 24 | appreaciate every contribution. I'm just one person so seeing community members 25 | care enough to write code for this project warms my heart. Thank you in advance. 26 | 27 | ## AI 28 | 29 | Regardless of your thoughts on generative AI, as [Linus Torvalds said](https://www.youtube.com/watch?v=mfv0V1SxbNA), "The genie is out of the bottle". 30 | ZVM itself employs Gemini Code Assist in this repo to assist with evaluating new PRs for vulnerabilies, styling defects, and other issues that can be address by the pull request's author. 31 | It does not make any decisions or write any code without manual review and approval. 32 | 33 | If you are submitting a pull request to ZVM that was written with generative AI, I ask you to follow these rules, or have your P.R. rejected and future contributions sidelined. 34 | 35 | ### 1. Know what the AI wrote. 36 | If you cannot explain, identify, or defend the choices and code written by your AI agent, you should not be making pull request. ZVM is a tool installed on thousands of computers. That is, in part, due to the trust our users 37 | have in the quality and security of the application. **I do not take that lightly**. AI cannot be held responsible for decisions it makes, but you will be. 38 | This is not a vibe-coded app, and contributors are expected to have the requisite skills that, if AI were not to exist, write the code themselves, or at least write it in a language they are already familiar with. 39 | 40 | ### 2. Clearly Disclose AI usage 41 | P.R.s that are written with Generative AI and that do not disclose it will not be merged. If it is found out that generative AI was used and not disclosed in a pull request, your future contributions will be sidelined. 42 | 43 | ### 3. Do not add AI metadata in your P.R. 44 | P.R.s that add AI metadata such as `AGENT.md` or `Claude.md` will not be merged. 45 | -------------------------------------------------------------------------------- /cli/run.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | package cli 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "runtime" 13 | "slices" 14 | "strings" 15 | 16 | "github.com/charmbracelet/log" 17 | ) 18 | 19 | // Run the given Zig compiler with the provided arguments 20 | func (z *ZVM) Run(version string, cmd []string) error { 21 | log.Debug("Run", "version", version, "cmds", strings.Join(cmd, ", ")) 22 | if len(version) == 0 { 23 | return fmt.Errorf("no zig version provided. If you want to run your set version of Zig, please use 'zig'") 24 | // zig, err := z.zigPath() 25 | // log.Debug("Run", "zig path", zig) 26 | // if err != nil { 27 | // return fmt.Errorf("%w: no Zig version found; %w", ErrMissingBundlePath, err) 28 | // } 29 | 30 | // return z.runZig("bin", cmd) 31 | } 32 | 33 | installedVersions, err := z.GetInstalledVersions() 34 | if err != nil { 35 | return err 36 | } 37 | 38 | if slices.Contains(installedVersions, version) { 39 | return z.runZig(version, cmd) 40 | } else { 41 | rawVersionStructure, err := z.fetchVersionMap() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | _, err = getTarPath(version, &rawVersionStructure) 47 | if err != nil { 48 | if errors.Is(err, ErrUnsupportedVersion) { 49 | return fmt.Errorf("%s: %q", err, version) 50 | } else { 51 | return err 52 | } 53 | } 54 | 55 | fmt.Printf("It looks like %s isn't installed. Would you like to install it? [y/n]\n", version) 56 | 57 | if getConfirmation() { 58 | if err = z.Install(version, false, true); err != nil { 59 | return err 60 | } 61 | return z.runZig(version, cmd) 62 | } else { 63 | return fmt.Errorf("version %s is not installed", version) 64 | } 65 | } 66 | 67 | } 68 | 69 | // runZig executes the Zig compiler for the specified version with the given arguments. 70 | func (z *ZVM) runZig(version string, cmd []string) error { 71 | zigExe := "zig" 72 | if runtime.GOOS == "windows" { 73 | zigExe = "zig.exe" 74 | } 75 | 76 | bin := strings.TrimSpace(filepath.Join(z.baseDir, version, zigExe)) 77 | 78 | log.Debug("runZig", "bin", bin) 79 | if stat, err := os.Lstat(bin); err != nil { 80 | 81 | name := version 82 | if stat != nil { 83 | name = stat.Name() 84 | } 85 | return fmt.Errorf("%w: %s", err, name) 86 | } 87 | 88 | // the logging here really muddies up the output of the Zig compiler 89 | // and adds a lot of noise. For that reason this function exits with 90 | // the zig compilers exit code 91 | if err := execute(bin, cmd); err != nil { 92 | return err 93 | } 94 | 95 | return nil 96 | } 97 | 98 | // Execute the given Zig command with a specified compiler 99 | func execute(bin string, cmd []string) error { 100 | // zvm run 0.14.0 build run --help 101 | if len(bin) == 0 { 102 | return fmt.Errorf("compiler binary cannot be empty") 103 | } 104 | 105 | zig := exec.Command(bin, cmd...) 106 | zig.Stdin, zig.Stdout, zig.Stderr = os.Stdin, os.Stdout, os.Stderr 107 | 108 | if err := zig.Run(); err != nil { 109 | if err2, ok := err.(*exec.ExitError); ok { 110 | os.Exit(err2.ExitCode()) 111 | } else { 112 | return fmt.Errorf("error executing command '%s': %w", cmd, err2) 113 | } 114 | } 115 | 116 | return nil 117 | } 118 | -------------------------------------------------------------------------------- /cli/ls.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | package cli 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "slices" 13 | "strings" 14 | 15 | "github.com/charmbracelet/log" 16 | "golang.org/x/mod/semver" 17 | 18 | "github.com/tristanisham/clr" 19 | ) 20 | 21 | // ListVersions prints the installed Zig versions and marks the current version. 22 | func (z *ZVM) ListVersions() error { 23 | if err := z.Clean(); err != nil { 24 | return err 25 | } 26 | cmd := exec.Command("zig", "version") 27 | var zigVersion strings.Builder 28 | cmd.Stdout = &zigVersion 29 | err := cmd.Run() 30 | if err != nil { 31 | log.Debug(err) 32 | } 33 | 34 | version := zigVersion.String() 35 | 36 | installedVersions, err := z.GetInstalledVersions() 37 | if err != nil { 38 | return err 39 | } 40 | 41 | if len(installedVersions) == 0 { 42 | cmdHelp := "zvm ls --all" 43 | if z.Settings.UseColor { 44 | cmdHelp = clr.Blue(cmdHelp) 45 | } 46 | fmt.Printf("No local Zig installs. Run `%s` to list all available-to-install versions of Zig.\n", cmdHelp) 47 | } 48 | 49 | for _, key := range installedVersions { 50 | if key == strings.TrimSpace(version) || key == "master" && strings.Contains(version, "-dev.") { 51 | if z.Settings.UseColor { 52 | // Should just check bin for used version 53 | fmt.Println(clr.Green(key)) 54 | } else { 55 | fmt.Printf("%s [x]", key) 56 | } 57 | } else { 58 | fmt.Println(key) 59 | } 60 | } 61 | 62 | return nil 63 | } 64 | 65 | // GetInstalledVersions returns a slice of strings containing the names of 66 | // all installed Zig versions found in the base directory. 67 | func (z *ZVM) GetInstalledVersions() ([]string, error) { 68 | dir, err := os.ReadDir(z.baseDir) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | versions := make([]string, 0, len(dir)) 74 | for _, key := range dir { 75 | if key.IsDir() { 76 | switch key.Name() { 77 | case "bin", "self": 78 | continue 79 | default: 80 | versions = append(versions, key.Name()) 81 | } 82 | } 83 | } 84 | return versions, nil 85 | } 86 | 87 | // ListRemoteAvailable lists all available Zig versions from the remote version map, 88 | // indicating which ones are already installed and which have ZLS support. 89 | func (z ZVM) ListRemoteAvailable() error { 90 | zigVersions, err := z.fetchVersionMap() 91 | if err != nil { 92 | return err 93 | } 94 | 95 | zlsVersions, err := z.fetchZlsTaggedVersionMap() 96 | if err != nil { 97 | return err 98 | } 99 | 100 | installedVersions, err := z.GetInstalledVersions() 101 | if err != nil { 102 | return err 103 | } 104 | 105 | options := make([]string, 0, len(zigVersions)) 106 | 107 | // add 'v' prefix for sorting. 108 | for key := range zigVersions { 109 | options = append(options, "v"+key) 110 | } 111 | 112 | semver.Sort(options) 113 | slices.Reverse(options) 114 | 115 | fmt.Printf("%-12s%-12s%s\n", "Version", "Installed", "ZLS") 116 | 117 | for _, version := range options { 118 | stripped := version[1:] 119 | 120 | if stripped == "master" { 121 | continue 122 | } 123 | 124 | installed := "" 125 | if slices.Contains(installedVersions, stripped) { 126 | installed = "[installed]" 127 | } 128 | 129 | zlsInfo := "" 130 | if _, ok := zlsVersions[stripped]; ok { 131 | zlsInfo = "(zls tagged)" 132 | } 133 | 134 | fmt.Printf("%-12s%-12s%s\n", stripped, installed, zlsInfo) 135 | } 136 | 137 | if _, ok := zigVersions["master"]; ok { 138 | var remoteVersion string 139 | if master, ok := zigVersions["master"]; ok { 140 | if versionInfo, ok := master["version"].(string); ok { 141 | remoteVersion = versionInfo 142 | } 143 | } 144 | 145 | zlsInfo := "" 146 | if _, ok := zlsVersions["master"]; ok { 147 | zlsInfo = "(zls tagged)" 148 | } 149 | fmt.Printf("%-12s%-12s%s\n", fmt.Sprintf("master (remote) (%s)", remoteVersion), "", zlsInfo) 150 | 151 | // Check if master is installed and print local version 152 | if slices.Contains(installedVersions, "master") { 153 | targetZig := strings.TrimSpace(filepath.Join(z.baseDir, "master", "zig")) 154 | cmd := exec.Command(targetZig, "version") 155 | var zigVersion strings.Builder 156 | cmd.Stdout = &zigVersion 157 | err := cmd.Run() 158 | if err != nil { 159 | log.Warn(err) 160 | } else { 161 | localVersion := strings.TrimSpace(zigVersion.String()) 162 | 163 | outDated := "" 164 | if localVersion != remoteVersion { 165 | outDated = "[outdated]" 166 | } 167 | 168 | fmt.Println("--------------------------------------------") 169 | fmt.Printf("%-15s (%-15s) %-10s %-10s\n", "master (local)", localVersion, "[installed]", outDated) 170 | 171 | } 172 | } 173 | } 174 | 175 | return nil 176 | } 177 | -------------------------------------------------------------------------------- /install.ps1: -------------------------------------------------------------------------------- 1 | 2 | function Install-ZVM { 3 | param( 4 | [string]$urlSuffix, 5 | [switch]$NoEnv 6 | ); 7 | 8 | $ZVMRoot = "${Home}\.zvm" 9 | $ZVMSelf = mkdir -Force "${ZVMRoot}\self" 10 | $ZVMBin = mkdir -Force "${ZVMRoot}\bin" 11 | $Target = $urlSuffix 12 | $URL = "https://github.com/tristanisham/zvm/releases/latest/download/$urlSuffix" 13 | $ZipPath = "${ZVMSelf}\$Target" 14 | 15 | $null = mkdir -Force $ZVMSelf 16 | # curl.exe "-#SfLo" "$ZVMSelf/elevate.cmd" "https://raw.githubusercontent.com/tristanisham/zvm/master/bin/elevate.cmd" -s 17 | #curl.exe "-#SfLo" "$ZVMSelf/elevate.vbs" "https://raw.githubusercontent.com/tristanisham/zvm/master/bin/elevate.vbs" -s 18 | Remove-Item -Force $ZipPath -ErrorAction SilentlyContinue 19 | curl.exe "-#SfLo" "$ZipPath" "$URL" 20 | if ($LASTEXITCODE -ne 0) { 21 | Write-Output "Install Failed - could not download $URL" 22 | Write-Output "The command 'curl.exe $URL -o $ZipPath' exited with code ${LASTEXITCODE}`n" 23 | exit 1 24 | } 25 | if (!(Test-Path $ZipPath)) { 26 | Write-Output "Install Failed - could not download $URL" 27 | Write-Output "The file '$ZipPath' does not exist. Did an antivirus delete it?`n" 28 | exit 1 29 | } 30 | $UnzippedPath = $Target.Substring(0, $Target.Length - 4) 31 | try { 32 | $lastProgressPreference = $global:ProgressPreference 33 | $global:ProgressPreference = 'SilentlyContinue'; 34 | Expand-Archive "$ZipPath" "$ZVMSelf" -Force 35 | $global:ProgressPreference = $lastProgressPreference 36 | if (!(Test-Path "${ZVMSelf}\$UnzippedPath\zvm.exe")) { 37 | throw "The file '${ZVMSelf}\$UnzippedPath\zvm.exe' does not exist. Download is corrupt / Antivirus intercepted?`n" 38 | } 39 | } 40 | catch { 41 | Write-Output "Install Failed - could not unzip $ZipPath" 42 | Write-Error $_ 43 | exit 1 44 | } 45 | Remove-Item "${ZVMSelf}\zvm.exe" -ErrorAction SilentlyContinue 46 | Move-Item "${ZVMSelf}\$UnzippedPath\zvm.exe" "${ZVMSelf}\zvm.exe" -Force 47 | 48 | Remove-Item "${ZVMSelf}\$Target" -Recurse -Force 49 | Remove-Item ${ZVMSelf}\$UnzippedPath -Force 50 | 51 | $null = "$(& "${ZVMSelf}\zvm.exe")" 52 | if ($LASTEXITCODE -eq 1073741795) { 53 | # STATUS_ILLEGAL_INSTRUCTION 54 | Write-Output "Install Failed - zvm.exe is not compatible with your CPU.`n" 55 | exit 1 56 | } 57 | if ($LASTEXITCODE -ne 0) { 58 | Write-Output "Install Failed - could not verify zvm.exe" 59 | Write-Output "The command '${ZVMSelf}\zvm.exe' exited with code ${LASTEXITCODE}`n" 60 | exit 1 61 | } 62 | 63 | $C_RESET = [char]27 + "[0m" 64 | $C_GREEN = [char]27 + "[1;32m" 65 | 66 | Write-Output "${C_GREEN}ZVM${DisplayVersion} was installed successfully!${C_RESET}" 67 | Write-Output "The binary is located at ${ZVMSelf}\zvm.exe`n" 68 | 69 | if (-not $NoEnv) { 70 | $User = [System.EnvironmentVariableTarget]::User 71 | $Path = [System.Environment]::GetEnvironmentVariable('Path', $User) -split ';' 72 | $ZVMInstall = 'ZVM_INSTALL' 73 | 74 | $ZVMInstallValue = [System.Environment]::GetEnvironmentVariable($ZVMInstall, [System.EnvironmentVariableTarget]::User) 75 | 76 | if ($null -eq $ZVMInstallValue) { 77 | [System.Environment]::SetEnvironmentVariable($ZVMInstall, $ZVMSelf, [System.EnvironmentVariableTarget]::User) 78 | } 79 | 80 | if ($Path -notcontains $ZVMSelf) { 81 | $Path += $ZVMSelf 82 | [System.Environment]::SetEnvironmentVariable('Path', $Path -join ';', $User) 83 | } 84 | if ($env:PATH -notcontains ";${ZVMSelf}") { 85 | $env:PATH = "${env:Path};${ZVMSelf}" 86 | } 87 | 88 | if ($Path -notcontains $ZVMBin) { 89 | $Path += $ZVMBin 90 | [System.Environment]::SetEnvironmentVariable('Path', $Path -join ';', $User) 91 | } 92 | if ($env:PATH -notcontains ";${ZVMBin}") { 93 | $env:PATH = "${env:Path};${ZVMBin}" 94 | } 95 | } else { 96 | Write-Output "Skipping environment variable setup due to --no-env flag.`n" 97 | } 98 | 99 | Write-Output "To get started, restart your terminal/editor, then type `"zvm`"`n" 100 | } 101 | 102 | 103 | $PROCESSOR_ARCH = $env:PROCESSOR_ARCHITECTURE.ToLower() 104 | 105 | if ($PROCESSOR_ARCH -eq "x86") { 106 | Write-Output "Install Failed - ZVM requires a 64-bit environment." 107 | Write-Output "Please ensure that you are running the 64-bit version of PowerShell or that your system is 64-bit.`n" 108 | exit 1 109 | } 110 | 111 | # Parse --no-env flag if present 112 | $NoEnv = $false 113 | if ($args -contains '--no-env') { 114 | $NoEnv = $true 115 | } 116 | 117 | Install-ZVM "zvm-windows-$PROCESSOR_ARCH.zip" -NoEnv:$NoEnv 118 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 | github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= 4 | github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= 5 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 6 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 7 | github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 8 | github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 9 | github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs= 10 | github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg= 11 | github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= 12 | github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= 13 | github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= 14 | github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 15 | github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= 16 | github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= 17 | github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY= 18 | github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= 19 | github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= 20 | github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= 21 | github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= 22 | github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 23 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 24 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= 26 | github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= 27 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 28 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 29 | github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 h1:FWpSWRD8FbVkKQu8M1DM9jF5oXFLyE+XpisIYfdzbic= 30 | github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7/go.mod h1:BMxO138bOokdgt4UaxZiEfypcSHX0t6SIFimVP1oRfk= 31 | github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 32 | github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 33 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 34 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 35 | github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= 36 | github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 37 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 38 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 39 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 40 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 41 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 42 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 43 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 44 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 45 | github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA= 46 | github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= 47 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 48 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 49 | github.com/tristanisham/clr v0.0.0-20221004001624-00ee60046d85 h1:zD4b2hs7jZ2sJtgtNdpMZyo4D4/Ifct8SMxvPNNkHzs= 50 | github.com/tristanisham/clr v0.0.0-20221004001624-00ee60046d85/go.mod h1:cKn2HV8Beq81OHjb2gja2ZiU4HAEQ6LSuxyaIT5Mg7o= 51 | github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo= 52 | github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= 53 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 54 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 55 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 56 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 57 | golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= 58 | golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= 59 | golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= 60 | golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= 61 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 63 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 64 | golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= 65 | golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 66 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 67 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 68 | -------------------------------------------------------------------------------- /cli/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | package cli 6 | 7 | import ( 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "io/fs" 12 | "os" 13 | "os/exec" 14 | "path/filepath" 15 | "strings" 16 | 17 | "github.com/charmbracelet/log" 18 | ) 19 | 20 | var ErrNoSettings = errors.New("settings.json not found") 21 | 22 | // Initialize sets up the ZVM environment, including the base directory 23 | // and settings.json. It creates necessary directories if they don't exist 24 | // and loads the configuration from disk. 25 | func Initialize() *ZVM { 26 | home, err := os.UserHomeDir() 27 | if err != nil { 28 | home = "~" 29 | } 30 | zvmPath := os.Getenv("ZVM_PATH") 31 | if zvmPath == "" { 32 | zvmPath = filepath.Join(home, ".zvm") 33 | } 34 | 35 | if _, err := os.Stat(zvmPath); errors.Is(err, fs.ErrNotExist) { 36 | if err := os.MkdirAll(filepath.Join(zvmPath, "self"), 0775); err != nil { 37 | log.Fatal(err) 38 | } 39 | } 40 | 41 | zvm := &ZVM{ 42 | baseDir: zvmPath, 43 | } 44 | 45 | zvm.Settings.path = filepath.Join(zvmPath, "settings.json") 46 | 47 | if err := zvm.loadSettings(); err != nil { 48 | if errors.Is(err, ErrNoSettings) { 49 | zvm.Settings = DefaultSettings 50 | 51 | outSettings, err := json.MarshalIndent(&zvm.Settings, "", " ") 52 | if err != nil { 53 | log.Warn("Unable to generate settings.json file", err) 54 | } 55 | 56 | if err := os.WriteFile(filepath.Join(zvmPath, "settings.json"), outSettings, 0755); err != nil { 57 | log.Warn("Unable to create settings.json file", err) 58 | } 59 | } 60 | } 61 | 62 | return zvm 63 | } 64 | 65 | // ZVM represents the Zig Version Manager and holds its configuration 66 | // and state, including the base directory for installations and settings. 67 | type ZVM struct { 68 | baseDir string 69 | Settings Settings 70 | } 71 | 72 | // A representaiton of the offical json schema for Zig versions 73 | type zigVersionMap = map[string]zigVersion 74 | 75 | // LoadMasterVersion takes a zigVersionMap and returns the master disto's version if it's present. 76 | // If it's not, this function returns an empty string. 77 | func LoadMasterVersion(zigMap *zigVersionMap) string { 78 | if ver, ok := (*zigMap)["master"]["version"].(string); ok { 79 | return ver 80 | } 81 | return "" 82 | } 83 | 84 | // A representation of individual Zig versions 85 | type zigVersion = map[string]any 86 | 87 | // ZigOnlVersion represents the structure of the Zig version data used by some online tools. 88 | // It maps a version string to a list of platform-specific download info. 89 | type ZigOnlVersion = map[string][]map[string]string 90 | 91 | // func (z *ZVM) loadVersionCache() error { 92 | // ver, err := os.ReadFile(filepath.Join(z.zvmBaseDir, "versions.json")) 93 | // if err != nil { 94 | // return err 95 | // } 96 | // if err := json.Unmarshal(ver, &z.zigVersions); err != nil { 97 | // return err 98 | // } 99 | // return nil 100 | // } 101 | // 102 | 103 | // validVmuAlis checks if the provided version string is a valid VMU alias. 104 | // Valid aliases are "default" and "mach". 105 | // TODO: Fix typo in function name (Alis -> Alias). 106 | func validVmuAlis(version string) bool { 107 | return version == "default" || version == "mach" 108 | } 109 | 110 | // getVersion determines the actual version string for a given input (e.g., resolving "master"). 111 | // It checks if the version is installed and returns an error if it's not a valid release 112 | // or if the installed version doesn't match expectations. 113 | func (z ZVM) getVersion(version string) error { 114 | 115 | root, err := os.OpenRoot(z.baseDir) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | defer root.Close() 121 | 122 | if _, err := root.Stat(version); err != nil { 123 | return err 124 | } 125 | 126 | targetZig := strings.TrimSpace(filepath.Join(root.Name(), version, "zig")) 127 | cmd := exec.Command(targetZig, "version") 128 | var zigVersion strings.Builder 129 | cmd.Stdout = &zigVersion 130 | err = cmd.Run() 131 | if err != nil { 132 | log.Warn(err) 133 | } 134 | 135 | outputVersion := strings.TrimSpace(zigVersion.String()) 136 | 137 | log.Debug("getVersion:", "output", outputVersion, "version", version, "program", targetZig) 138 | 139 | if version == outputVersion { 140 | return nil 141 | } else { 142 | if _, statErr := root.Stat(targetZig); statErr == nil || version == "master" { 143 | return nil 144 | } 145 | return fmt.Errorf("version %s is not a released version", version) 146 | } 147 | } 148 | 149 | // loadSettings loads the ZVM configuration from settings.json. 150 | // It handles missing settings files and ensures empty fields are reset to defaults. 151 | func (z *ZVM) loadSettings() error { 152 | setPath := z.Settings.path 153 | if _, err := os.Stat(setPath); errors.Is(err, os.ErrNotExist) { 154 | return ErrNoSettings 155 | } 156 | 157 | data, err := os.ReadFile(setPath) 158 | if err != nil { 159 | return err 160 | } 161 | 162 | if err = json.Unmarshal(data, &z.Settings); err != nil { 163 | return err 164 | } 165 | 166 | return z.Settings.ResetEmpty() 167 | } 168 | 169 | // func (z *ZVM) AlertIfUpgradable() { 170 | // if !z.Settings.StartupCheckUpgrade { 171 | // return 172 | // } 173 | // log.Debug("Checking for upgrade on startup is enabled") 174 | // upgradable, tagName, err := CanIUpgrade() 175 | // if err != nil { 176 | // log.Info("failed new zvm version check") 177 | // } 178 | 179 | // if upgradable { 180 | // coloredText := "zvm upgrade" 181 | // if z.Settings.UseColor { 182 | // coloredText = clr.Blue("zvm upgrade") 183 | // } 184 | 185 | // fmt.Printf("There's a new version of ZVM (%s).\n Run '%s' to install it!\n", tagName, coloredText) 186 | // } 187 | // } 188 | 189 | // func (z *ZVM) ConflictCheck(file string) (string, error) { 190 | // zls, err := exec.LookPath("zls") 191 | // if err != nil { 192 | // return "", err 193 | // } 194 | 195 | // linuxPath := filepath.Join(z.baseDir,"bin/zls") 196 | // if _, err := os.Stat(linuxPath); err == nil { 197 | 198 | // } 199 | // return zls, nil 200 | // } 201 | -------------------------------------------------------------------------------- /cli/version.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | package cli 6 | 7 | import ( 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "net/http" 13 | "net/url" 14 | "os" 15 | "path/filepath" 16 | "strconv" 17 | "strings" 18 | 19 | "github.com/tristanisham/zvm/cli/meta" 20 | 21 | "github.com/charmbracelet/log" 22 | ) 23 | 24 | // fetchVersionMap downloads the Zig version map from the configured URL. 25 | // It parses the JSON response and writes it to a "versions.json" file in the base directory. 26 | func (z *ZVM) fetchVersionMap() (zigVersionMap, error) { 27 | 28 | log.Debug("setting's VMU", "url", z.Settings.VersionMapUrl) 29 | 30 | req, err := http.NewRequest("GET", z.Settings.VersionMapUrl, nil) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | req.Header.Set("User-Agent", "zvm "+meta.VERSION) 36 | client := http.DefaultClient 37 | resp, err := client.Do(req) 38 | if err != nil { 39 | return nil, err 40 | } 41 | defer resp.Body.Close() 42 | 43 | versions, err := io.ReadAll(resp.Body) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | rawVersionStructure := make(zigVersionMap) 49 | if err := json.Unmarshal(versions, &rawVersionStructure); err != nil { 50 | var syntaxErr *json.SyntaxError 51 | if errors.As(err, &syntaxErr) { 52 | return nil, fmt.Errorf("%w: %w", ErrInvalidVersionMap, err) 53 | } 54 | 55 | return nil, err 56 | } 57 | 58 | if err := os.WriteFile(filepath.Join(z.baseDir, "versions.json"), versions, 0755); err != nil { 59 | return nil, err 60 | } 61 | 62 | return rawVersionStructure, nil 63 | } 64 | 65 | // cleanURL removes consecutive slashes from a URL while preserving the protocol. 66 | func cleanURL(url string) string { 67 | // Split the URL into two parts: protocol (e.g., "https://") and the rest 68 | var prefix string 69 | if strings.HasPrefix(url, "https://") { 70 | prefix = "https://" 71 | url = strings.TrimPrefix(url, "https://") 72 | } else if strings.HasPrefix(url, "http://") { 73 | prefix = "http://" 74 | url = strings.TrimPrefix(url, "http://") 75 | } 76 | 77 | // Replace multiple slashes with a single slash in the remaining part of the URL 78 | cleanedPath := strings.ReplaceAll(url, "//", "/") 79 | 80 | // Reconstruct the URL with the protocol prefix 81 | return prefix + cleanedPath 82 | } 83 | 84 | // note: the zls release-worker uses the same index format as zig, but without the latest master entry. 85 | // fetchZlsTaggedVersionMap downloads the ZLS tagged version map from the configured URL. 86 | // It writes the result to "versions-zls.json" in the base directory. 87 | func (z *ZVM) fetchZlsTaggedVersionMap() (zigVersionMap, error) { 88 | log.Debug("setting's ZRW", "url", z.Settings.ZlsVMU) 89 | 90 | fullVersionMapAPI := cleanURL(z.Settings.ZlsVMU + "v1/zls/index.json") 91 | 92 | log.Debug("Version Map Url (95)", "func", "fetchZlsTaggedVersionMap", "url", fullVersionMapAPI) 93 | req, err := http.NewRequest("GET", fullVersionMapAPI, nil) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | req.Header.Set("User-Agent", "zvm "+meta.VERSION) 99 | client := http.DefaultClient 100 | resp, err := client.Do(req) 101 | if err != nil { 102 | return nil, err 103 | } 104 | defer resp.Body.Close() 105 | 106 | versions, err := io.ReadAll(resp.Body) 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | rawVersionStructure := make(zigVersionMap) 112 | if err := json.Unmarshal(versions, &rawVersionStructure); err != nil { 113 | var syntaxErr *json.SyntaxError 114 | if errors.As(err, &syntaxErr) { 115 | return nil, fmt.Errorf("%w: %w", ErrInvalidVersionMap, err) 116 | } 117 | 118 | return nil, err 119 | } 120 | 121 | if err := os.WriteFile(filepath.Join(z.baseDir, "versions-zls.json"), versions, 0755); err != nil { 122 | return nil, err 123 | } 124 | 125 | return rawVersionStructure, nil 126 | } 127 | 128 | // note: the zls release-worker uses the same index format as zig, but without the latest master entry. 129 | // this function does not write the result to a file. 130 | // fetchZlsVersionByZigVersion queries the ZLS release worker for a ZLS build compatible 131 | // with the specific Zig version and compatibility mode provided. 132 | func (z *ZVM) fetchZlsVersionByZigVersion(version string, compatMode string) (zigVersion, error) { 133 | log.Debug("setting's ZRW", "url", z.Settings.ZlsVMU) 134 | 135 | // https://github.com/zigtools/release-worker?tab=readme-ov-file#query-parameters 136 | // The compatibility query parameter must be either only-runtime or full: 137 | // full: Request a ZLS build that can be built and used with the given Zig version. 138 | // only-runtime: Request a ZLS build that can be used at runtime with the given Zig version but may not be able to build ZLS from source. 139 | selectVersionUrl := cleanURL(fmt.Sprintf("%s/v1/zls/select-version?zig_version=%s&compatibility=%s", z.Settings.ZlsVMU, url.QueryEscape(version), compatMode)) 140 | log.Debug("fetching zls version", "zigVersion", version, "url", selectVersionUrl) 141 | req, err := http.NewRequest("GET", selectVersionUrl, nil) 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | req.Header.Set("User-Agent", "zvm "+meta.VERSION) 147 | client := http.DefaultClient 148 | resp, err := client.Do(req) 149 | if err != nil { 150 | return nil, err 151 | } 152 | defer resp.Body.Close() 153 | 154 | versions, err := io.ReadAll(resp.Body) 155 | if err != nil { 156 | return nil, err 157 | } 158 | 159 | rawVersionStructure := make(zigVersion) 160 | if err := json.Unmarshal(versions, &rawVersionStructure); err != nil { 161 | var syntaxErr *json.SyntaxError 162 | if errors.As(err, &syntaxErr) { 163 | return nil, fmt.Errorf("%w: %w", ErrInvalidVersionMap, err) 164 | } 165 | 166 | return nil, err 167 | } 168 | 169 | if badRequest, ok := rawVersionStructure["error"].(string); ok { 170 | return nil, fmt.Errorf("%w: %s", ErrNoZlsVersion, badRequest) 171 | } 172 | 173 | if code, ok := rawVersionStructure["code"]; ok { 174 | codeStr := strconv.FormatFloat(code.(float64), 'f', 0, 64) 175 | msg := rawVersionStructure["message"] 176 | return nil, fmt.Errorf("%w: code %s: %s", ErrNoZlsVersion, codeStr, msg) 177 | } 178 | 179 | return rawVersionStructure, nil 180 | } 181 | 182 | // statelessFetchVersionMap is the same as fetchVersionMap but it doesn't write to disk. Will probably be depreciated and nuked from orbit when my 183 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # ZVM install script - v2.0.0 - ZVM: https://github.com/tristanisham/zvm 4 | 5 | ARCH=$(uname -m) 6 | OS=$(uname -s) 7 | 8 | if [ "$ARCH" = "aarch64" ]; then 9 | ARCH="arm64" 10 | fi 11 | if [ "$ARCH" = "x86_64" ]; then 12 | ARCH="amd64" 13 | fi 14 | 15 | # echo "Installing zvm-$OS-$ARCH" 16 | 17 | install_latest() { 18 | echo -e "Downloading $1 in $(pwd)" 19 | if [ "$(uname)" = "Darwin" ]; then 20 | # Do something under MacOS platform 21 | if command -v wget >/dev/null 2>&1; then 22 | echo "wget is installed. Using wget..." 23 | wget -q --show-progress --max-redirect 5 -O zvm.tar "https://github.com/tristanisham/zvm/releases/latest/download/$1" 24 | else 25 | echo "wget is not installed. Using curl..." 26 | curl -L --max-redirs 5 "https://github.com/tristanisham/zvm/releases/latest/download/$1" -o zvm.tar 27 | fi 28 | 29 | mkdir -p "$HOME/.zvm/self" 30 | tar -xf zvm.tar -C "$HOME/.zvm/self" 31 | rm "zvm.tar" 32 | 33 | elif [ "$OS" = "Linux" ]; then 34 | # Do something under GNU/Linux platform 35 | if command -v wget2 >/dev/null 2>&1; then 36 | echo "wget2 is installed. Using wget2..." 37 | wget2 -q --force-progress --max-redirect 5 -O zvm.tar "https://github.com/tristanisham/zvm/releases/latest/download/$1" 38 | elif command -v wget >/dev/null 2>&1; then 39 | echo "wget is installed. Using wget..." 40 | wget -q --show-progress --max-redirect 5 -O zvm.tar "https://github.com/tristanisham/zvm/releases/latest/download/$1" 41 | else 42 | echo "wget is not installed. Using curl..." 43 | curl -L --max-redirs 5 "https://github.com/tristanisham/zvm/releases/latest/download/$1" -o zvm.tar 44 | fi 45 | 46 | mkdir -p "$HOME/.zvm/self" 47 | tar -xf zvm.tar -C "$HOME/.zvm/self" 48 | rm "zvm.tar" 49 | elif [ "$OS" = "MINGW32_NT" ] || [ "$OS" = "MINGW64_NT" ]; then 50 | curl -L --max-redirs 5 "https://github.com/tristanisham/zvm/releases/latest/download/$1" -o zvm.zip 51 | # Additional extraction steps for Windows can be added here 52 | fi 53 | } 54 | 55 | if [ "$(uname)" = "Darwin" ]; then 56 | install_latest "zvm-darwin-$ARCH.tar" 57 | elif [ "$OS" = "Linux" ]; then 58 | install_latest "zvm-linux-$ARCH.tar" 59 | elif [ "$OS" = "MINGW32_NT" ] || [ "$OS" = "MINGW64_NT" ]; then 60 | install_latest "zvm-windows-$ARCH.zip" 61 | fi 62 | 63 | ############################### 64 | # Determine the target file to update based on the user's shell. 65 | # For Fish, we update ~/.config/fish/config.fish. 66 | # For Zsh, we prefer .zshenv, .zprofile or .zshrc. 67 | # Otherwise, we fallback to bash files (or any shell using .profile). 68 | 69 | TARGET_FILE="" 70 | 71 | if [[ "$SHELL" == */fish ]]; then 72 | TARGET_FILE="$HOME/.config/fish/config.fish" 73 | elif [[ "$SHELL" == */zsh ]]; then 74 | if [ -f "$HOME/.zshenv" ]; then 75 | TARGET_FILE="$HOME/.zshenv" 76 | elif [ -f "$HOME/.zprofile" ]; then 77 | TARGET_FILE="$HOME/.zprofile" 78 | else 79 | TARGET_FILE="$HOME/.zshrc" 80 | fi 81 | else 82 | if [ -f "$HOME/.bashrc" ]; then 83 | TARGET_FILE="$HOME/.bashrc" 84 | elif [ -f "$HOME/.profile" ]; then 85 | TARGET_FILE="$HOME/.profile" 86 | else 87 | TARGET_FILE="" 88 | fi 89 | fi 90 | 91 | ############################### 92 | # Check for --no-env flag 93 | NO_ENV=0 94 | for arg in "$@"; do 95 | if [ "$arg" = "--no-env" ]; then 96 | NO_ENV=1 97 | break 98 | fi 99 | done 100 | 101 | # Append the ZVM environment variables if they are not already present, unless --no-env is passed. 102 | if [ "$NO_ENV" -eq 0 ]; then 103 | if [ -n "$TARGET_FILE" ]; then 104 | if grep -q 'ZVM_INSTALL' "$TARGET_FILE"; then 105 | echo "ZVM environment variables are already present in $TARGET_FILE" 106 | exit 0 107 | fi 108 | echo "Adding ZVM environment variables to $TARGET_FILE" 109 | 110 | if [[ "$SHELL" == */fish ]]; then 111 | { 112 | echo 113 | echo "# ZVM" 114 | echo 'set -gx ZVM_INSTALL "$HOME/.zvm/self"' 115 | echo 'set -gx PATH $PATH "$HOME/.zvm/bin"' 116 | echo 'set -gx PATH $PATH "$ZVM_INSTALL/"' 117 | } >>"$TARGET_FILE" 118 | echo "Restart fish or run 'source $TARGET_FILE' to start using ZVM in this shell!" 119 | else 120 | { 121 | echo 122 | echo "# ZVM" 123 | echo 'export ZVM_INSTALL="$HOME/.zvm/self"' 124 | echo 'export PATH="$PATH:$HOME/.zvm/bin"' 125 | echo 'export PATH="$PATH:$ZVM_INSTALL/"' 126 | } >>"$TARGET_FILE" 127 | echo "Run 'source $TARGET_FILE' to start using ZVM in this shell!" 128 | fi 129 | echo "Run 'zvm i master' to install Zig" 130 | else 131 | echo 132 | echo "No suitable shell startup file found." 133 | echo "Please add the following lines to your shell's startup script (or execute them in your current session):" 134 | if [[ "$TERM" == "xterm"* || "$TERM" == "screen"* || "$TERM" == "tmux"* ]]; then 135 | # Colors for pretty-printing 136 | RED='\033[0;31m' 137 | GREEN='\033[0;32m' 138 | BLUE='\033[0;34m' 139 | NC='\033[0m' 140 | if [[ "$SHELL" == */fish ]]; then 141 | echo -e "${GREEN}set -gx${NC} ${BLUE}ZVM_INSTALL${NC}${GREEN} ${NC}${RED}\"\$HOME/.zvm/self\"${NC}" 142 | echo -e "${GREEN}set -gx${NC} ${BLUE}PATH${NC}${GREEN} ${NC}${RED}\"\$PATH:\$HOME/.zvm/bin\"${NC}" 143 | echo -e "${GREEN}set -gx${NC} ${BLUE}PATH${NC}${GREEN} ${NC}${RED}\"\$PATH:\$ZVM_INSTALL/\"${NC}" 144 | else 145 | echo -e "${GREEN}export${NC} ${BLUE}ZVM_INSTALL${NC}${GREEN}=${NC}${RED}\"\$HOME/.zvm/self\"${NC}" 146 | echo -e "${GREEN}export${NC} ${BLUE}PATH${NC}${GREEN}=${NC}${RED}\"\$PATH:\$HOME/.zvm/bin\"${NC}" 147 | echo -e "${GREEN}export${NC} ${BLUE}PATH${NC}${GREEN}=${NC}${RED}\"\$PATH:\$ZVM_INSTALL/\"${NC}" 148 | fi 149 | else 150 | if [[ "$SHELL" == */fish ]]; then 151 | echo 'set -gx ZVM_INSTALL "$HOME/.zvm/self"' 152 | echo 'set -gx PATH $PATH "$HOME/.zvm/bin"' 153 | echo 'set -gx PATH $PATH "$ZVM_INSTALL/"' 154 | else 155 | echo 'export ZVM_INSTALL="$HOME/.zvm/self"' 156 | echo 'export PATH="$PATH:$HOME/.zvm/bin"' 157 | echo 'export PATH="$PATH:$ZVM_INSTALL/"' 158 | fi 159 | fi 160 | echo "Run 'zvm i master' to install Zig" 161 | fi 162 | else 163 | echo "Skipping environment variable setup due to --no-env flag." 164 | fi 165 | -------------------------------------------------------------------------------- /cli/settings.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | package cli 6 | 7 | import ( 8 | "encoding/json" 9 | "fmt" 10 | "net/url" 11 | "os" 12 | "path/filepath" 13 | 14 | "github.com/charmbracelet/log" 15 | 16 | "github.com/tristanisham/clr" 17 | ) 18 | 19 | type Settings struct { 20 | path string 21 | MirrorListUrl string `json:"mirrorListUrl,omitempty"` // Zig's community mirror list URL 22 | MinisignPubKey string `json:"minisignPubKey,omitempty"` 23 | VersionMapUrl string `json:"versionMapUrl,omitempty"` // Zig's version map URL 24 | ZlsVMU string `json:"zlsVersionMapUrl,omitempty"` // ZLS's version map URL 25 | UseColor bool `json:"useColor"` 26 | AlwaysForceInstall bool `json:"alwaysForceInstall"` 27 | } 28 | 29 | // DefaultSettings defines the default configuration values for ZVM. 30 | var DefaultSettings = Settings{ 31 | MirrorListUrl: "https://ziglang.org/download/community-mirrors.txt", 32 | // From https://ziglang.org/download/ 33 | MinisignPubKey: "RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U", 34 | VersionMapUrl: "https://ziglang.org/download/index.json", 35 | ZlsVMU: "https://releases.zigtools.org/", 36 | UseColor: true, 37 | AlwaysForceInstall: false, 38 | } 39 | 40 | // UseMirrorList returns true if the mirror list URL is not set to "disabled". 41 | func (s *Settings) UseMirrorList() bool { 42 | return s.MirrorListUrl != "disabled" 43 | } 44 | 45 | // ToggleColor toggles the UseColor setting and saves the configuration. 46 | func (s *Settings) ToggleColor() { 47 | s.UseColor = !s.UseColor 48 | if err := s.save(); err != nil { 49 | log.Fatal(err) 50 | } 51 | 52 | if s.UseColor { 53 | fmt.Printf("Terminal color output: %s\n", clr.Green("ON")) 54 | return 55 | } 56 | 57 | fmt.Println("Terminal color output: OFF") 58 | } 59 | 60 | // ResetMirrorList resets the mirror list URL to the default value and saves the configuration. 61 | func (s *Settings) ResetMirrorList() error { 62 | s.MirrorListUrl = DefaultSettings.MirrorListUrl 63 | if err := s.save(); err != nil { 64 | return err 65 | } 66 | 67 | return nil 68 | } 69 | 70 | // ResetVersionMap resets the version map URL to the default value and saves the configuration. 71 | func (s *Settings) ResetVersionMap() error { 72 | s.VersionMapUrl = DefaultSettings.VersionMapUrl 73 | if err := s.save(); err != nil { 74 | return err 75 | } 76 | 77 | return nil 78 | } 79 | 80 | // ResetZlsVMU resets the ZLS version map URL to the default value and saves the configuration. 81 | func (s *Settings) ResetZlsVMU() error { 82 | s.ZlsVMU = DefaultSettings.ZlsVMU 83 | if err := s.save(); err != nil { 84 | return err 85 | } 86 | 87 | return nil 88 | } 89 | 90 | // NoColor disables terminal color output and saves the configuration. 91 | func (s *Settings) NoColor() { 92 | s.UseColor = false 93 | if err := s.save(); err != nil { 94 | log.Fatal(err) 95 | } 96 | fmt.Println("Terminal color output: OFF") 97 | } 98 | 99 | // YesColor enables terminal color output and saves the configuration. 100 | func (s *Settings) YesColor() { 101 | s.UseColor = true 102 | if err := s.save(); err != nil { 103 | log.Fatal(err) 104 | } 105 | fmt.Printf("Terminal color output: %s\n", clr.Green("ON")) 106 | } 107 | 108 | // SetColor sets the terminal color output preference and saves the configuration. 109 | func (s *Settings) SetColor(answer bool) { 110 | s.UseColor = answer 111 | if err := s.save(); err != nil { 112 | log.Fatal(err) 113 | } 114 | } 115 | 116 | // SetMirrorListUrl sets the mirror list URL and saves the configuration. 117 | // It validates the URL unless it is set to "disabled". 118 | func (s *Settings) SetMirrorListUrl(mirrorListUrl string) error { 119 | if mirrorListUrl != "disabled" { 120 | if err := isValidWebURL(mirrorListUrl); err != nil { 121 | return fmt.Errorf("%w: %w", ErrInvalidVersionMap, err) 122 | } 123 | } 124 | 125 | s.MirrorListUrl = mirrorListUrl 126 | if err := s.save(); err != nil { 127 | return err 128 | } 129 | 130 | log.Debug("set mirror list url", "url", s.MirrorListUrl) 131 | 132 | return nil 133 | } 134 | 135 | // SetVersionMapUrl sets the version map URL and saves the configuration. 136 | // It validates that the URL is a valid web URL. 137 | func (s *Settings) SetVersionMapUrl(versionMapUrl string) error { 138 | if err := isValidWebURL(versionMapUrl); err != nil { 139 | return fmt.Errorf("%w: %w", ErrInvalidVersionMap, err) 140 | } 141 | 142 | s.VersionMapUrl = versionMapUrl 143 | if err := s.save(); err != nil { 144 | return err 145 | } 146 | 147 | log.Debug("set version map url", "url", s.VersionMapUrl) 148 | 149 | return nil 150 | } 151 | 152 | // SetZlsVMU sets the ZLS version map URL and saves the configuration. 153 | // It validates that the URL is a valid web URL. 154 | func (s *Settings) SetZlsVMU(versionMapUrl string) error { 155 | if err := isValidWebURL(versionMapUrl); err != nil { 156 | return fmt.Errorf("%w: %w", ErrInvalidVersionMap, err) 157 | } 158 | 159 | s.ZlsVMU = versionMapUrl 160 | if err := s.save(); err != nil { 161 | return err 162 | } 163 | 164 | log.Debug("set zls version map url", "url", s.ZlsVMU) 165 | 166 | return nil 167 | } 168 | 169 | // ResetEmpty ensures that any empty settings fields are set to their default values 170 | // and saves the configuration. 171 | func (s *Settings) ResetEmpty() error { 172 | if s.MirrorListUrl == "" { 173 | s.MirrorListUrl = DefaultSettings.MirrorListUrl 174 | } 175 | 176 | if s.MinisignPubKey == "" { 177 | s.MinisignPubKey = DefaultSettings.MinisignPubKey 178 | } 179 | 180 | if s.VersionMapUrl == "" { 181 | s.VersionMapUrl = DefaultSettings.VersionMapUrl 182 | } 183 | 184 | if s.ZlsVMU == "" { 185 | s.ZlsVMU = DefaultSettings.ZlsVMU 186 | } 187 | 188 | return s.save() 189 | } 190 | 191 | // isValidWebURL checks if the given URL string is a valid web URL. 192 | func isValidWebURL(urlString string) error { 193 | parsedURL, err := url.Parse(urlString) 194 | if err != nil { 195 | return err // URL parsing error 196 | } 197 | 198 | // Check for valid HTTP/HTTPS scheme 199 | if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { 200 | return fmt.Errorf("invalid URL scheme: %s", parsedURL.Scheme) 201 | } 202 | 203 | // Check for non-empty host (domain) 204 | if parsedURL.Host == "" { 205 | return fmt.Errorf("URL host (domain) is empty") 206 | } 207 | 208 | // Optionally, you can add more checks (like path, query params, etc.) here if needed 209 | 210 | return nil // URL is valid 211 | } 212 | 213 | // save writes the current settings to the settings.json file. 214 | func (s Settings) save() error { 215 | outSettings, err := json.MarshalIndent(&s, "", " ") 216 | if err != nil { 217 | return fmt.Errorf("unable to generate settings.json file %v", err) 218 | } 219 | 220 | if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil { 221 | return fmt.Errorf("unable to create settings directory: %w", err) 222 | } 223 | 224 | if err := os.WriteFile(s.path, outSettings, 0755); err != nil { 225 | return fmt.Errorf("unable to create settings.json file %w", err) 226 | } 227 | 228 | return nil 229 | } 230 | -------------------------------------------------------------------------------- /deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "specifiers": { 4 | "jsr:@std/assert@~0.219.1": "0.219.1", 5 | "jsr:@std/path@~0.219.1": "0.219.1", 6 | "jsr:@zip-js/zip-js@*": "2.7.72" 7 | }, 8 | "jsr": { 9 | "@std/assert@0.219.1": { 10 | "integrity": "e76c2a1799a78f0f4db7de04bdc9b908a7a4b821bb65eda0285885297d4fb8af" 11 | }, 12 | "@std/path@0.219.1": { 13 | "integrity": "e5c0ffef3a8ef2b48e9e3d88a1489320e8fb2cc7be767b17c91a1424ffb4c8ed", 14 | "dependencies": [ 15 | "jsr:@std/assert" 16 | ] 17 | }, 18 | "@zip-js/zip-js@2.7.72": { 19 | "integrity": "b72877f90aaefa1f1bd265d51f354bb58b6dd0d0e2799c865584acf49eae9115" 20 | } 21 | }, 22 | "remote": { 23 | "https://deno.land/std@0.133.0/_deno_unstable.ts": "23a1a36928f1b6d3b0170aaa67de09af12aa998525f608ff7331b9fb364cbde6", 24 | "https://deno.land/std@0.133.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", 25 | "https://deno.land/std@0.133.0/_util/os.ts": "49b92edea1e82ba295ec946de8ffd956ed123e2948d9bd1d3e901b04e4307617", 26 | "https://deno.land/std@0.133.0/fs/_util.ts": "0fb24eb4bfebc2c194fb1afdb42b9c3dda12e368f43e8f2321f84fc77d42cb0f", 27 | "https://deno.land/std@0.133.0/fs/copy.ts": "9248d1492599957af8c693ceb10a432b09f0b0b61c60a4d6aff29b0c7d3a17b3", 28 | "https://deno.land/std@0.133.0/fs/empty_dir.ts": "7274d87160de34cbed0531e284df383045cf43543bbeadeb97feac598bd8f3c5", 29 | "https://deno.land/std@0.133.0/fs/ensure_dir.ts": "9dc109c27df4098b9fc12d949612ae5c9c7169507660dcf9ad90631833209d9d", 30 | "https://deno.land/std@0.133.0/fs/ensure_file.ts": "7d353e64fee3d4d1e7c6b6726a2a5e987ba402c15fb49566309042887349c545", 31 | "https://deno.land/std@0.133.0/fs/ensure_link.ts": "489e23df9fe3e6636048b5830ddf0f111eb29621eb85719255ad9bd645f3471b", 32 | "https://deno.land/std@0.133.0/fs/ensure_symlink.ts": "88dc83de1bc90ed883dd458c2d2eae3d5834a4617d12925734836e1f0803b274", 33 | "https://deno.land/std@0.133.0/fs/eol.ts": "b92f0b88036de507e7e6fbedbe8f666835ea9dcbf5ac85917fa1fadc919f83a5", 34 | "https://deno.land/std@0.133.0/fs/exists.ts": "cb734d872f8554ea40b8bff77ad33d4143c1187eac621a55bf37781a43c56f6d", 35 | "https://deno.land/std@0.133.0/fs/expand_glob.ts": "0c10130d67c9b02164b03df8e43c6d6defbf8e395cb69d09e84a8586e6d72ac3", 36 | "https://deno.land/std@0.133.0/fs/mod.ts": "4dc052c461c171abb5c25f6e0f218ab838a716230930b534ba351745864b7d6d", 37 | "https://deno.land/std@0.133.0/fs/move.ts": "0573cedcf583f09a9494f2dfccbf67de68a93629942d6b5e6e74a9e45d4e8a2e", 38 | "https://deno.land/std@0.133.0/fs/walk.ts": "117403ccd21fd322febe56ba06053b1ad5064c802170f19b1ea43214088fe95f", 39 | "https://deno.land/std@0.133.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", 40 | "https://deno.land/std@0.133.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", 41 | "https://deno.land/std@0.133.0/path/_util.ts": "c1e9686d0164e29f7d880b2158971d805b6e0efc3110d0b3e24e4b8af2190d2b", 42 | "https://deno.land/std@0.133.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633", 43 | "https://deno.land/std@0.133.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee", 44 | "https://deno.land/std@0.133.0/path/mod.ts": "4275129bb766f0e475ecc5246aa35689eeade419d72a48355203f31802640be7", 45 | "https://deno.land/std@0.133.0/path/posix.ts": "663e4a6fe30a145f56aa41a22d95114c4c5582d8b57d2d7c9ed27ad2c47636bb", 46 | "https://deno.land/std@0.133.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", 47 | "https://deno.land/std@0.133.0/path/win32.ts": "e7bdf63e8d9982b4d8a01ef5689425c93310ece950e517476e22af10f41a136e", 48 | "https://deno.land/std@0.167.0/_util/asserts.ts": "d0844e9b62510f89ce1f9878b046f6a57bf88f208a10304aab50efcb48365272", 49 | "https://deno.land/std@0.167.0/bytes/concat.ts": "97a1274e117510ffffc9499c4debb9541e408732bab2e0ca624869ae13103c10", 50 | "https://deno.land/std@0.167.0/uuid/_common.ts": "b608b98503c701d96ddf6983a40fa424952e1ea6d4b13bf13ebe29530742cee4", 51 | "https://deno.land/std@0.167.0/uuid/mod.ts": "e57ba10200d75f2b17570f13eba19faa6734b1be2da5091e2c01039df41274a5", 52 | "https://deno.land/std@0.167.0/uuid/v1.ts": "7123410ef9ce980a4f2e54a586ccde5ed7063f6f119a70d86eebd92f8e100295", 53 | "https://deno.land/std@0.167.0/uuid/v4.ts": "60c829ec64291b920f1ff5f391fce8d05a42f74f58d5949e12bc0b3af88c7f5d", 54 | "https://deno.land/std@0.167.0/uuid/v5.ts": "fa79d1495ec5bbbf6c3f3f6073d9ab3eecb0683371d936299ec9c3d1df2caac7", 55 | "https://deno.land/std@0.184.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", 56 | "https://deno.land/std@0.184.0/archive/_common.ts": "3fc6974d41f1d2e55edf839a4574db0b5e6f4f62abb6cc3d2c3ca13eb6d08b07", 57 | "https://deno.land/std@0.184.0/archive/mod.ts": "8076e214b461fe214de63960271279bbb8d669f434d512fcc75ab1c787335367", 58 | "https://deno.land/std@0.184.0/archive/tar.ts": "838baf680e545ed780eff41a4a90afff7557f2aec291d0f195d4ab2648b68b5c", 59 | "https://deno.land/std@0.184.0/archive/untar.ts": "b7bd29929ebc819cb5b7265d1e491e4061e31828b6235e8ff5ad17ddfb77d09e", 60 | "https://deno.land/std@0.184.0/bytes/copy.ts": "939d89e302a9761dcf1d9c937c7711174ed74c59eef40a1e4569a05c9de88219", 61 | "https://deno.land/std@0.184.0/io/buf_reader.ts": "abeb92b18426f11d72b112518293a96aef2e6e55f80b84235e8971ac910affb5", 62 | "https://deno.land/std@0.184.0/io/buffer.ts": "17f4410eaaa60a8a85733e8891349a619eadfbbe42e2f319283ce2b8f29723ab", 63 | "https://deno.land/std@0.184.0/io/multi_reader.ts": "9c2a0a31686c44b277e16da1d97b4686a986edcee48409b84be25eedbc39b271", 64 | "https://deno.land/std@0.184.0/streams/_common.ts": "f45cba84f0d813de3326466095539602364a9ba521f804cc758f7a475cda692d", 65 | "https://deno.land/std@0.184.0/streams/copy.ts": "75cbc795ff89291df22ddca5252de88b2e16d40c85d02840593386a8a1454f71", 66 | "https://deno.land/std@0.184.0/streams/read_all.ts": "ee319772fb0fd28302f97343cc48dfcf948f154fd0d755d8efe65814b70533be", 67 | "https://deno.land/std@0.184.0/types.d.ts": "dbaeb2c4d7c526db9828fc8df89d8aecf53b9ced72e0c4568f97ddd8cda616a4", 68 | "https://deno.land/x/exec@0.0.5/mod.ts": "2a71f7e23e25be883275b22d872bbd2c3dfa3058934f1f156c8663fb81f5894f", 69 | "https://deno.land/x/zip@v1.2.5/compress.ts": "43d9f4440960d15a85aec58f5d365acc25530d3d4186b2f5f896c090ecac20e8", 70 | "https://deno.land/x/zip@v1.2.5/decompress.ts": "0bce3d453726f686274fab3f6c19b72b5e74223a00d89c176b1de49a5dd5528d", 71 | "https://deno.land/x/zip@v1.2.5/deps.ts": "79548387594b3ae1efaaa870b5a507c4d6bedede13dbd5d4ad42f6cda0aeef86", 72 | "https://deno.land/x/zip@v1.2.5/mod.ts": "28eecbc3e1e5adf564f4aa465e64268713a05653104bacdcb04561533f8caf57", 73 | "https://deno.land/x/zip@v1.2.5/utils.ts": "43c323f2b79f9db1976c5739bbb1f9cced20e8077ca7e7e703f9d01d4330bd9d", 74 | "https://deno.land/x/zipjs@v2.7.6/index.d.ts": "45776289e6365324dc2737442ad3a2339a75477ef2a2e5152eeca2775a028813", 75 | "https://deno.land/x/zipjs@v2.7.6/index.js": "7c71926e0c9618e48a22d9dce701131704fd3148a1d2eefd5dba1d786c846a5f", 76 | "https://deno.land/x/zipjs@v2.7.6/lib/core/codec-pool.js": "e5ab8ee3ec800ed751ef1c63a1bd8e50f162aa256a5f625d173d7a32e76e828c", 77 | "https://deno.land/x/zipjs@v2.7.6/lib/core/codec-worker.js": "0d9f5ac94390aafaa6a51b8615887628b341a858e3dfa179b4b1620d0b71c9b1", 78 | "https://deno.land/x/zipjs@v2.7.6/lib/core/configuration.js": "baa316a63df2f8239f9d52cd4863eaedaddd34ad887b7513588da75d19e84932", 79 | "https://deno.land/x/zipjs@v2.7.6/lib/core/constants.js": "c1aff78c6b9378b26ab4330e85032160ebf1f2d09a99e5e6907626f816e88908", 80 | "https://deno.land/x/zipjs@v2.7.6/lib/core/io.js": "8ae9dd6a987cda416117876eaf81b457528a6827808dcd13c8c4924905c1575f", 81 | "https://deno.land/x/zipjs@v2.7.6/lib/core/streams/aes-crypto-stream.js": "63988c9f3ce1e043c80e6eb140ebb07bf2ab543ee9a85349651ab74b96aab2cf", 82 | "https://deno.land/x/zipjs@v2.7.6/lib/core/streams/codec-stream.js": "685f1120b94b6295dcd61b195d6202cd24a5344e4588dc52f42e8ac0f9dfe294", 83 | "https://deno.land/x/zipjs@v2.7.6/lib/core/streams/codecs/crc32.js": "dfdde666f72b4a5ffc8cf5b1451e0db578ce4bd90de20df2cff5bfd47758cb23", 84 | "https://deno.land/x/zipjs@v2.7.6/lib/core/streams/codecs/deflate.js": "5c0058b730ca09044c31c67ae0b56993baae7801807c5ab18b5931ee37d9fe24", 85 | "https://deno.land/x/zipjs@v2.7.6/lib/core/streams/codecs/inflate.js": "769cc7dfd9da81ae3ae2ddae07a7102951c30123ead5e9b8983a134f2d45b417", 86 | "https://deno.land/x/zipjs@v2.7.6/lib/core/streams/codecs/sjcl.js": "462289c5312f01bba8a757a7a0f3d8f349f471183cb4c49fb73d58bba18a5428", 87 | "https://deno.land/x/zipjs@v2.7.6/lib/core/streams/common-crypto.js": "4d462619848d94427fcd486fd94e5c0741af60e476df6720da8224b086eba47e", 88 | "https://deno.land/x/zipjs@v2.7.6/lib/core/streams/crc32-stream.js": "23b8ae832c77866435dab93319ec39b8af6b38acf737f582f2344716775e9952", 89 | "https://deno.land/x/zipjs@v2.7.6/lib/core/streams/stream-adapter.js": "9e7f3fe1601cc447943cd37b5adb6d74c6e9c404d002e707e8eace7bc048929c", 90 | "https://deno.land/x/zipjs@v2.7.6/lib/core/streams/zip-crypto-stream.js": "19305af1e8296e7fa6763f3391d0b8149a1e09c659e1d1ff32a484448b18243c", 91 | "https://deno.land/x/zipjs@v2.7.6/lib/core/streams/zip-entry-stream.js": "7c2921291481f8ee8c0f3cf023d705fefcb954ceb729a76975e0e733278b7192", 92 | "https://deno.land/x/zipjs@v2.7.6/lib/core/util/cp437-decode.js": "d665ded184037ffe5d255be8f379f90416053e3d0d84fac95b28f4aeaab3d336", 93 | "https://deno.land/x/zipjs@v2.7.6/lib/core/util/decode-text.js": "c04a098fa7c16470c48b6abd4eb4ac48af53547de65e7c8f39b78ae62330ad57", 94 | "https://deno.land/x/zipjs@v2.7.6/lib/core/util/default-mime-type.js": "177ae00e1956d3d00cdefc40eb158cb591d3d24ede452c056d30f98d73d9cd73", 95 | "https://deno.land/x/zipjs@v2.7.6/lib/core/util/encode-text.js": "c51a8947c15b7fe31b0036b69fd68817f54b30ce29502b5c9609d8b15e3b20d9", 96 | "https://deno.land/x/zipjs@v2.7.6/lib/core/util/mime-type.js": "7e4f0d516923f7dcf5cd14a0d06e2b7af9dacd160c707afac82f3ce6c69ae287", 97 | "https://deno.land/x/zipjs@v2.7.6/lib/core/util/stream-codec-shim.js": "845c0659c6ab9ed9b03cae452255e4e0a699001e8b45ccae23cb0ece5f91064c", 98 | "https://deno.land/x/zipjs@v2.7.6/lib/core/zip-entry.js": "d30a535cd1e75ef98094cd04120f178c103cdc4055d23ff747ffc6a154da8d2d", 99 | "https://deno.land/x/zipjs@v2.7.6/lib/core/zip-fs-core.js": "e34a71334a5855b16c9f86b554caf349c337644f54fb7bf1d3831bb4be3ba52d", 100 | "https://deno.land/x/zipjs@v2.7.6/lib/core/zip-reader.js": "76bf449ec1baf8f39cf35354773a6f56e10c811dd6782e446874c063280e41d0", 101 | "https://deno.land/x/zipjs@v2.7.6/lib/core/zip-writer.js": "34809b421f5deb497ce8cabec730af858c12dde54a6205c78b5591460785dc1e", 102 | "https://deno.land/x/zipjs@v2.7.6/lib/z-worker-inline.js": "31f2a0971f87accd020df37fb54526a7ae0de3f2415cb541d79878af33f66c78", 103 | "https://deno.land/x/zipjs@v2.7.6/lib/zip-fs.js": "a733360302f5fbec9cc01543cb9fcfe7bae3f35a50d0006626ce42fe8183b63f" 104 | }, 105 | "workspace": { 106 | "dependencies": [ 107 | "jsr:@std/path@~0.219.1" 108 | ] 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "errors" 10 | "fmt" 11 | "os" 12 | "strings" 13 | "time" 14 | 15 | "github.com/tristanisham/zvm/cli" 16 | "github.com/tristanisham/zvm/cli/meta" 17 | opts "github.com/urfave/cli/v3" 18 | 19 | "github.com/charmbracelet/log" 20 | ) 21 | 22 | var ( 23 | zvm cli.ZVM 24 | printUpgradeNotice bool = true 25 | ) 26 | 27 | var zvmApp = &opts.Command{ 28 | Name: "zvm", 29 | Usage: "Zig Version Manager", 30 | Description: "zvm lets you easily install, upgrade, and switch between different versions of Zig.", 31 | Version: meta.VerCopy, 32 | Copyright: fmt.Sprintf("Copyright © %d Tristan Isham", time.Now().Year()), 33 | Suggest: true, 34 | Before: func(ctx context.Context, cmd *opts.Command) (context.Context, error) { 35 | zvm = *cli.Initialize() 36 | return nil, nil 37 | }, 38 | // app-global flags 39 | Flags: []opts.Flag{ 40 | &opts.StringFlag{ 41 | Name: "color", 42 | Usage: "enable (on, yes/y, enabled, true) or disable (off, no/n, disabled, false) colored ZVM output", 43 | Value: "toggle", 44 | Action: func(ctx context.Context, cmd *opts.Command, val string) error { 45 | switch val { 46 | case "on", "yes", "enabled", "y", "true": 47 | zvm.Settings.YesColor() 48 | 49 | case "off", "no", "disabled", "n", "false": 50 | zvm.Settings.NoColor() 51 | 52 | default: 53 | zvm.Settings.ToggleColor() 54 | } 55 | 56 | return nil 57 | }, 58 | }, 59 | }, 60 | Commands: []*opts.Command{ 61 | { 62 | Name: "install", 63 | Usage: "download and install a version of Zig", 64 | Aliases: []string{"i"}, 65 | Flags: []opts.Flag{ 66 | &opts.BoolFlag{ 67 | Name: "zls", 68 | // Aliases: []string{"z"}, 69 | Usage: "install ZLS", 70 | }, 71 | &opts.BoolFlag{ 72 | Name: "force", 73 | Aliases: []string{"f"}, 74 | Usage: "force installation even if the version is already installed", 75 | }, 76 | &opts.BoolFlag{ 77 | Name: "full", 78 | Usage: "use the 'full' zls compatibility mode", 79 | }, 80 | &opts.BoolFlag{ 81 | Name: "nomirror", 82 | Usage: "download Zig from ziglang.org instead of a community mirror", 83 | }, 84 | }, 85 | Description: "To install the latest version, use `master`", 86 | // Args: true, 87 | ArgsUsage: " ", 88 | Action: func(ctx context.Context, cmd *opts.Command) error { 89 | versionArg := strings.TrimPrefix(cmd.Args().First(), "v") 90 | 91 | if versionArg == "" { 92 | return errors.New("no version provided") 93 | } 94 | 95 | req := cli.ExtractInstall(versionArg) 96 | req.Version = strings.TrimPrefix(req.Version, "v") 97 | 98 | force := zvm.Settings.AlwaysForceInstall 99 | 100 | if cmd.Bool("force") { 101 | force = cmd.Bool("force") 102 | } 103 | 104 | zlsCompat := "only-runtime" 105 | if cmd.Bool("full") { 106 | zlsCompat = "full" 107 | } 108 | 109 | // Install Zig 110 | err := zvm.Install(req.Package, force, !cmd.Bool("nomirror")) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | // Install ZLS (if requested) 116 | if cmd.Bool("zls") { 117 | if err := zvm.InstallZls(req.Package, zlsCompat, force); err != nil { 118 | return err 119 | } 120 | } 121 | 122 | return nil 123 | }, 124 | }, 125 | { 126 | Name: "use", 127 | Usage: "switch between versions of Zig", 128 | // Args: true, 129 | Flags: []opts.Flag{ 130 | &opts.BoolFlag{ 131 | Name: "sync", 132 | Usage: "sync your current version of Zig with the repository", 133 | }, 134 | }, 135 | Action: func(ctx context.Context, cmd *opts.Command) error { 136 | if cmd.Bool("sync") { 137 | return zvm.Sync() 138 | } else { 139 | versionArg := strings.TrimPrefix(cmd.Args().First(), "v") 140 | 141 | if versionArg == "" { 142 | return fmt.Errorf("command 'use' requires 1 valid Zig version as an argument") 143 | } 144 | 145 | if err := zvm.Use(versionArg); err != nil { 146 | return err 147 | } 148 | 149 | fmt.Printf("Now using Zig %s\n", versionArg) 150 | return nil 151 | } 152 | }, 153 | }, 154 | { 155 | Name: "run", 156 | Usage: "run a command with the given Zig version", 157 | // Args: true, 158 | SkipFlagParsing: true, 159 | Action: func(ctx context.Context, cmd *opts.Command) error { 160 | versionArg := strings.TrimPrefix(cmd.Args().First(), "v") 161 | cmds := cmd.Args().Tail() 162 | return zvm.Run(versionArg, cmds) 163 | 164 | }, 165 | }, 166 | { 167 | Name: "list", 168 | Usage: "list installed Zig versions. Flag `--all` to see remote options", 169 | Aliases: []string{"ls"}, 170 | // Args: true, 171 | Flags: []opts.Flag{ 172 | &opts.BoolFlag{ 173 | Name: "all", 174 | Aliases: []string{"a"}, 175 | Usage: "list remote Zig versions available for download, based on your version map", 176 | }, 177 | &opts.BoolFlag{ 178 | Name: "vmu", 179 | Usage: "list set version maps", 180 | }, 181 | }, 182 | Action: func(ctx context.Context, cmd *opts.Command) error { 183 | log.Debug("Version Map", "url", zvm.Settings.VersionMapUrl, "cmd", "list/ls") 184 | if cmd.Bool("all") { 185 | return zvm.ListRemoteAvailable() 186 | } else if cmd.Bool("vmu") { 187 | if len(zvm.Settings.VersionMapUrl) == 0 { 188 | if err := zvm.Settings.ResetVersionMap(); err != nil { 189 | return err 190 | } 191 | } 192 | 193 | if len(zvm.Settings.ZlsVMU) == 0 { 194 | if err := zvm.Settings.ResetZlsVMU(); err != nil { 195 | return err 196 | } 197 | } 198 | 199 | vmu := zvm.Settings.VersionMapUrl 200 | zrw := zvm.Settings.ZlsVMU 201 | 202 | fmt.Printf("Zig VMU: %s\nZLS VMU: %s\n", vmu, zrw) 203 | return nil 204 | } else { 205 | return zvm.ListVersions() 206 | } 207 | }, 208 | }, 209 | { 210 | Name: "uninstall", 211 | Usage: "remove an installed version of Zig", 212 | Aliases: []string{"rm"}, 213 | // Args: true, 214 | Action: func(ctx context.Context, cmd *opts.Command) error { 215 | versionArg := strings.TrimPrefix(cmd.Args().First(), "v") 216 | return zvm.Uninstall(versionArg) 217 | }, 218 | }, 219 | { 220 | Name: "clean", 221 | Usage: "remove build artifacts (good if you're a scrub)", 222 | Action: func(ctx context.Context, cmd *opts.Command) error { 223 | return zvm.Clean() 224 | }, 225 | }, 226 | { 227 | Name: "upgrade", 228 | Usage: "self-upgrade ZVM", 229 | Action: func(ctx context.Context, cmd *opts.Command) error { 230 | printUpgradeNotice = false 231 | return zvm.Upgrade() 232 | }, 233 | }, 234 | { 235 | Name: "mirrorlist", 236 | Usage: "set ZVM's mirror list URL for custom Zig distribution servers, or set to \"disabled\" to download directly from ziglang.org", 237 | Action: func(ctx context.Context, cmd *opts.Command) error { 238 | url := cmd.Args().First() 239 | log.Debug("user passed mirrorlist", "url", url) 240 | 241 | switch url { 242 | case "default": 243 | return zvm.Settings.ResetMirrorList() 244 | 245 | default: 246 | if err := zvm.Settings.SetMirrorListUrl(url); err != nil { 247 | if url == "" { 248 | err = fmt.Errorf("%wURL cannot be an empty string", err) 249 | } 250 | log.Info("Run `zvm mirrorlist default` to reset your mirror list.") 251 | return err 252 | } 253 | } 254 | 255 | return nil 256 | }, 257 | }, 258 | { 259 | Name: "vmu", 260 | Usage: "set ZVM's version map URL for custom Zig distribution servers", 261 | // Args: true, 262 | Commands: []*opts.Command{ 263 | { 264 | Name: "zig", 265 | Usage: "set ZVM's version map URL for custom Zig distribution servers", 266 | // Args: true, 267 | ArgsUsage: "", 268 | 269 | Action: func(ctx context.Context, cmd *opts.Command) error { 270 | url := cmd.Args().First() 271 | log.Debug("user passed VMU", "url", url) 272 | 273 | switch url { 274 | case "default": 275 | return zvm.Settings.ResetVersionMap() 276 | 277 | case "mach": 278 | if err := zvm.Settings.SetVersionMapUrl("https://machengine.org/zig/index.json"); err != nil { 279 | log.Info("Run `zvm vmu zig default` to reset your version map.") 280 | return err 281 | } 282 | 283 | default: 284 | if err := zvm.Settings.SetVersionMapUrl(url); err != nil { 285 | log.Info("Run `zvm vmu zig default` to reset your verison map.") 286 | return err 287 | } 288 | } 289 | 290 | return nil 291 | }, 292 | }, 293 | { 294 | Name: "zls", 295 | Usage: "set ZVM's version map URL for custom ZLS Release Workers", 296 | // Args: true, 297 | Action: func(ctx context.Context, cmd *opts.Command) error { 298 | url := cmd.Args().First() 299 | log.Debug("user passed zrw", "url", url) 300 | 301 | switch url { 302 | case "default": 303 | return zvm.Settings.ResetZlsVMU() 304 | 305 | default: 306 | if err := zvm.Settings.SetZlsVMU(url); err != nil { 307 | log.Info("Run `zvm vmu zls default` to reset your release worker.") 308 | return err 309 | } 310 | } 311 | 312 | return nil 313 | }, 314 | }, 315 | }, 316 | }, 317 | }, 318 | } 319 | 320 | func main() { 321 | if _, ok := os.LookupEnv("ZVM_DEBUG"); ok { 322 | log.SetLevel(log.DebugLevel) 323 | } 324 | 325 | _, checkUpgradeDisabled := os.LookupEnv("ZVM_SET_CU") 326 | log.Debug("Automatic Upgrade Checker", "disabled", checkUpgradeDisabled) 327 | 328 | // Upgrade 329 | upSig := make(chan string, 1) 330 | 331 | if !checkUpgradeDisabled { 332 | go func(out chan<- string) { 333 | if tag, ok, _ := cli.CanIUpgrade(); ok { 334 | out <- tag 335 | } else { 336 | out <- "" 337 | } 338 | }(upSig) 339 | } else { 340 | upSig <- "" 341 | } 342 | 343 | // run and report errors 344 | if err := zvmApp.Run(context.Background(), os.Args); err != nil { 345 | // if meta.VERSION == "v0.7.9" && errors.Is(err, cli.ErrInvalidVersionMap) { 346 | // meta.CtaGeneric("Help", `Encountered an issue while trying to install ZLS for Zig 'master'. 347 | 348 | // Problem: ZVM v0.7.7 and v0.7.8 may have saved an invalid 'zlsVersionMapUrl' to your settings, 349 | // which causes this error. The latest version, v0.7.9, can fix this issue by using the correct URL. 350 | 351 | // To resolve this: 352 | // 1. Open your ZVM settings file: '~/.zvm/settings.json' 353 | // 2. Remove the 'zlsVersionMapUrl' key & value from the file (if present). 354 | // What happens next: ZVM will automatically use the correct version map the next time you run it 355 | // If the issue persists, please double-check your settings and try again, or create a GitHub Issue.`) 356 | // } 357 | meta.CtaFatal(err) 358 | } 359 | 360 | if tag := <-upSig; tag != "" { 361 | if printUpgradeNotice { 362 | meta.CtaUpgradeAvailable(tag) 363 | } else { 364 | log.Infof("You are now using ZVM %s\n", tag) 365 | } 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | Zig Version Manager (zvm) is a tool for managing your 6 | [Zig](https://ziglang.org/) installs. With std under heavy development and a 7 | large feature roadmap, Zig is bound to continue changing. Breaking existing 8 | builds, updating valid syntax, and introducing new features like a package 9 | manager. While this is great for developers, it also can lead to headaches when 10 | you need multiple versions of a language installed to compile your projects, or 11 | a language gets updated frequently. 12 | 13 | ## Donate 14 | 15 | - [Paypal](https://www.paypal.com/donate/?hosted_button_id=HFTFEFXP2A388) 16 | 17 | ## Join our Community 18 | 19 | - [Twitch](https://twitch.tv/atalocke) 20 | - [Twitter|X](https://twitter.com/atalocke) 21 | 22 | Subscribe on Polar 23 | 24 | 25 | # Installing ZVM 26 | 27 | ZVM lives entirely in `$HOME/.zvm` on all platforms it supports. Inside of the 28 | directory, ZVM will download new ZIG versions and symlink whichever version you 29 | specify with `zvm use` to `$HOME/.zvm/bin`. You should add this folder to your 30 | path. After ZVM 0.2.3, ZVM's installer will now add ZVM to `$HOME/.zvm/self`. 31 | You should also add this directory as the environment variable `ZVM_INSTALL`. 32 | The installer scripts should handle this for you automatically on *nix and 33 | Windows systems. 34 | 35 | If you don't want to use `ZVM_INSTALL` (like you already have ZVM in a place you 36 | like), then ZVM will update the exact executable you've called `upgrade` from. 37 | 38 | # Linux, BSD, MacOS, *nix 39 | 40 | ```sh 41 | curl https://raw.githubusercontent.com/tristanisham/zvm/master/install.sh | bash 42 | ``` 43 | 44 | 45 | 46 | 53 | 54 | # Windows 55 | 56 | ## PowerShell 57 | 58 | ```ps1 59 | irm https://raw.githubusercontent.com/tristanisham/zvm/master/install.ps1 | iex 60 | ``` 61 | 62 | ## Command Prompt 63 | 64 | ```cmd 65 | powershell -c "irm https://raw.githubusercontent.com/tristanisham/zvm/master/install.ps1 | iex" 66 | ``` 67 | 68 | # If You Have a Valid Version of Go Installed 69 | 70 | ```sh 71 | go install -ldflags "-s -w" github.com/tristanisham/zvm@latest 72 | ``` 73 | 74 | ## Manually 75 | 76 | Please grab the 77 | [latest release](https://github.com/tristanisham/zvm/releases/latest). 78 | 79 | ## Putting ZVM on your Path 80 | 81 | ZVM requires a few directories to be on your `$PATH`. If you don't know how to 82 | update your environment variables permanently on Windows, you can follow 83 | [this guide](https://www.computerhope.com/issues/ch000549.htm). Once you're in 84 | the appropriate menu, add or append to the following environment variables: 85 | 86 | Add 87 | 88 | - ZVM_INSTALL: `%USERPROFILE%\.zvm\self` 89 | 90 | Append 91 | 92 | - PATH: `%USERPROFILE%\.zvm\bin` 93 | - PATH: `%ZVM_INSTALL%` 94 | 95 | ## Configure ZVM path 96 | 97 | It is possible to overwrite the default behavior of ZVM to adhere to XDG 98 | specification on Linux. There's an environment variable `ZVM_PATH`. Setting it 99 | to `$XDG_DATA_HOME/zvm` will do the trick. 100 | 101 | ## Community Package 102 | 103 | ### AUR 104 | 105 | `zvm` on the [Arch AUR](https://aur.archlinux.org/packages/zvm) is a 106 | community-maintained package, and may be out of date. 107 | 108 | # Why should I use ZVM? 109 | 110 | While Zig is still pre-1.0 if you're going to stay up-to-date with the master 111 | branch, you're going to be downloading Zig quite often. You could do it 112 | manually, having to scoll around to find your appropriate version, decompress 113 | it, and install it on your `$PATH`. Or, you could install ZVM and run 114 | `zvm i master` every time you want to update. `zvm` is a static binary under a 115 | permissive license. It supports more platforms than any other Zig version 116 | manager. Its only dependency is `tar` on Unix-based systems. Whether you're on 117 | Windows, MacOS, Linux, a flavor of BSD, or Plan 9 `zvm` will let you install, 118 | switch between, and run multiple versions of Zig. 119 | 120 | # Contributing and Notice 121 | 122 | `zvm` is stable software. Pre-v1.0.0 any breaking changes will be clearly 123 | labeled, and any commands potentially on the chopping block will print notice. 124 | The program is under constant development, and the author is very willing to 125 | work with contributors. **If you have any issues, ideas, or contributions you'd 126 | like to suggest 127 | [create a GitHub issue](https://github.com/tristanisham/zvm/issues/new/choose)**. 128 | 129 | # How to use ZVM 130 | 131 | ## Install 132 | 133 | ```sh 134 | zvm install 135 | # Or 136 | zvm i 137 | ``` 138 | 139 | Use `install` or `i` to download a specific version of Zig. To install the 140 | latest version, use "master". 141 | 142 | ```sh 143 | # Example 144 | zvm i master 145 | ``` 146 | 147 | ### Force Install 148 | 149 | As of `v0.7.6` ZVM will now skip downloading a version if it is already 150 | installed. You can always force an install with the `--force` or `-f` flag. 151 | 152 | ```sh 153 | zvm i --force master 154 | ``` 155 | 156 | You can also enable the old behavior by setting the new `alwaysForceInstall` 157 | field to `true` in `~/.zvm/settings.json`. 158 | 159 | ### Install ZLS with ZVM 160 | 161 | You can now install ZLS with your Zig download! To install ZLS with ZVM, simply 162 | pass the `--zls` flag with `zvm i`. For example: 163 | 164 | ```sh 165 | zvm i --zls master 166 | ``` 167 | 168 | #### Select ZLS compatibility mode 169 | 170 | By default, ZVM will install a ZLS build, which can be used with the given Zig 171 | version, but may not be able to build ZLS from source. If you want to use a ZLS 172 | build, which can be built using the selected Zig version, pass the `--full` flag 173 | with `zvm i --zls`. For example: 174 | 175 | ```sh 176 | zvm i --zls --full master 177 | ``` 178 | 179 | > [!IMPORTANT] 180 | > This does not apply to tagged releases, e.g.: `0.13.0` 181 | 182 | ## Switch between installed Zig versions 183 | 184 | ```sh 185 | zvm use 186 | ``` 187 | 188 | Use `use` to switch between versions of Zig. 189 | 190 | ```sh 191 | # Example 192 | zvm use master 193 | ``` 194 | 195 | ## List installed Zig versions 196 | 197 | ```sh 198 | # Example 199 | zvm ls 200 | ``` 201 | 202 | Use `ls` to list all installed version of Zig. 203 | 204 | ### List all versions of Zig available 205 | 206 | ```sh 207 | zvm ls --all 208 | ``` 209 | 210 | The `--all` flag will list the available verisons of Zig for download. Not the 211 | versions locally installed. 212 | 213 | ### List set version maps 214 | 215 | ```sh 216 | zvm ls --vmu 217 | ``` 218 | 219 | The `--vmu` flag will list set version maps for Zig and ZLS downloads. 220 | 221 | ## Uninstall a Zig version 222 | 223 | ```sh 224 | # Example 225 | zvm rm 0.10.0 226 | ``` 227 | 228 | Use `uninstall` or `rm` to remove an uninstalled version from your system. 229 | 230 | ## Upgrade your ZVM installation 231 | 232 | As of `zvm v0.2.3` you can now upgrade your ZVM installation from, well, zvm. 233 | Just run: 234 | 235 | ```sh 236 | zvm upgrade 237 | ``` 238 | 239 | The latest version of ZVM should install on your machine, regardless of where 240 | your binary lives (though if you have your binary in a privaledged folder, you 241 | may have to run this command with `sudo`). 242 | 243 | ## Clean up build artifacts 244 | 245 | ```sh 246 | # Example 247 | zvm clean 248 | ``` 249 | 250 | Use `clean` to remove build artifacts (Good if you're on Windows). 251 | 252 | ## Run installed version of Zig without switching your default 253 | 254 | If you want to run a version of Zig without setting it as your default, the new 255 | `run` command is your friend. 256 | 257 | ```sh 258 | zig version 259 | # 0.13.0 260 | 261 | zvm run 0.11.0 version 262 | # 0.11.0 263 | 264 | zig version 265 | # 0.13.0 266 | ``` 267 | 268 | This can be helpful if you want to test your project on a newer version of Zig 269 | without having to switch between bins, or on alternative flavor of Zig. 270 | 271 | ## How to use with alternative VMUs 272 | 273 | Make sure you switch your VMU before using `run`. 274 | 275 | ```sh 276 | zvm vmu zig mach 277 | zvm run mach-latest version 278 | # 0.14.0-dev.1911+3bf89f55c 279 | ``` 280 | 281 | If you would like to run the currently set Zig, please keep using the standard 282 | `zig` command. 283 | 284 | ## Set Version Map Source 285 | 286 | ZVM lets choose your vendor for Zig and ZLS. This is great if your company hosts 287 | it's own internal fork of Zig, you prefer a different flavor of the language, 288 | like Mach. 289 | 290 | ```sh 291 | zvm vmu zig "https://machengine.org/zig/index.json" # Change the source ZVM pulls Zig release information from. 292 | 293 | zvm vmu zls https://validurl.local/vmu.json 294 | # ZVM only supports schemas that match the offical version map schema. 295 | # Run `vmu default` to reset your version map. 296 | 297 | zvm vmu zig default # Resets back to default Zig releases. 298 | zvm vmu zig mach # Sets ZVM to pull from Mach nominated Zig. 299 | 300 | zvm vmu zls default # Resets back to default ZLS releases. 301 | ``` 302 | 303 | ## Use a Custom Mirror Distribution Server 304 | ZVM now lets you set your own Mirror Distribution Server. If you cannot or choose not to use the official Zig mirror list, you can host your own, or use another grouping of mirrors. 305 | 306 | ```sh 307 | zvm mirrorlist 308 | # Reset to the official mirror 309 | zvm mirrorlist default 310 | ``` 311 | 312 | ## Print program help 313 | 314 | Print global help information by running: 315 | 316 | ```sh 317 | zvm --help 318 | ``` 319 | 320 | Print help information about a specific command or subcommand. 321 | 322 | ```sh 323 | zvm list --help 324 | ``` 325 | 326 | ``` 327 | NAME: 328 | zvm list - list installed Zig versions. Flag `--all` to see remote options 329 | 330 | USAGE: 331 | zvm list [command options] [arguments...] 332 | 333 | OPTIONS: 334 | --all, -a list remote Zig versions available for download, based on your version map (default: false) 335 | --vmu list set version maps (default: false) 336 | --help, -h show help 337 | ``` 338 | 339 | ## Print program version 340 | 341 | ```sh 342 | zvm --version 343 | ``` 344 | 345 | Prints the version of ZVM you have installed. 346 | 347 |
348 | 349 | ## Option flags 350 | 351 | ### Color Toggle 352 | 353 | Enable or disable colored ZVM output. No value toggles colors. 354 | 355 | #### Enable 356 | 357 | - on 358 | - yes/y 359 | - enabled 360 | - true 361 | 362 | #### Disabled 363 | 364 | - off 365 | - no/n 366 | - disabled 367 | - false 368 | 369 | ```sh 370 | --color # Toggle ANSI color printing on or off for ZVM's output, i.e. --color=true 371 | ``` 372 | 373 | ## Environment Variables 374 | 375 | - `ZVM_DEBUG` enables DEBUG logging for your executable. This is meant for 376 | contributors and developers. 377 | - `ZVM_SET_CU` Toggle the automatic upgrade checker. If you want to reenable the 378 | checker, just `uset ZVM_SET_CU`. 379 | - `ZVM_PATH` replaces the default install location for ZVM Set the environment 380 | variable to the parent directory of where you've placed the `.zvm` directory. 381 | - `ZVM_SKIP_TLS_VERIFY` Do you have problems using TLS in your evironment? 382 | Toggle off verifying TLS by setting this environment variable. 383 | - By default when this is enabled ZVM will print a warning. Set this variable 384 | to `no-warn` to silence this warning. 385 | 386 | ## Settings 387 | 388 | ZVM has additional setting stored in `~/.zvm/settings.json`. You can manually 389 | update version maps, toggle color support, and disable the automatic upgrade 390 | checker here. All settings are also exposed as flags or environment variables. 391 | This file is stateful, and ZVM will create it if it does not exist and utilizes 392 | it for its operation. 393 | 394 | ## Please Consider Giving the Repo a Star ⭐ 395 | 396 | 397 | 398 | 399 | 400 | 401 | Star History Chart 402 | 403 | 404 | -------------------------------------------------------------------------------- /cli/upgrade.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | package cli 6 | 7 | import ( 8 | "archive/tar" 9 | "encoding/json" 10 | "errors" 11 | "fmt" 12 | "io" 13 | "net/http" 14 | "os" 15 | "path/filepath" 16 | "runtime" 17 | "strings" 18 | "time" 19 | 20 | "github.com/schollz/progressbar/v3" 21 | 22 | "github.com/tristanisham/zvm/cli/meta" 23 | 24 | "github.com/charmbracelet/log" 25 | "github.com/tristanisham/clr" 26 | "golang.org/x/mod/semver" 27 | ) 28 | 29 | // Upgrade will upgrade the system installation of ZVM. 30 | // I wrote most of it before I remembered that GitHub has an API so expect major refactoring. 31 | func (z *ZVM) Upgrade() error { 32 | defer func() { 33 | if err := z.Clean(); err != nil { 34 | log.Warn("ZVM failed to clean up after itself.") 35 | } 36 | }() 37 | 38 | tagName, upgradable, err := CanIUpgrade() 39 | if err != nil { 40 | return errors.Join(ErrFailedUpgrade, err) 41 | } 42 | 43 | if !upgradable { 44 | fmt.Printf("You are already on the latest release (%s) of ZVM :) \n", clr.Blue(meta.VERSION)) 45 | os.Exit(0) 46 | } else { 47 | fmt.Printf("You are on ZVM %s... upgrading to (%s)", meta.VERSION, tagName) 48 | } 49 | 50 | zvmInstallDirENV, err := z.getInstallDir() 51 | if err != nil { 52 | return err 53 | } 54 | 55 | log.Debug("exe dir", "path", zvmInstallDirENV) 56 | zvmBinaryName := "zvm" 57 | archive := "tar" 58 | if runtime.GOOS == "windows" { 59 | archive = "zip" 60 | zvmBinaryName = "zvm.exe" 61 | } 62 | 63 | download := fmt.Sprintf("zvm-%s-%s.%s", runtime.GOOS, runtime.GOARCH, archive) 64 | 65 | downloadUrl := fmt.Sprintf("https://github.com/tristanisham/zvm/releases/latest/download/%s", download) 66 | 67 | resp, err := http.Get(downloadUrl) 68 | if err != nil { 69 | errors.Join(ErrFailedUpgrade, err) 70 | } 71 | defer resp.Body.Close() 72 | 73 | tempDownload, err := os.CreateTemp(z.baseDir, fmt.Sprintf("*.%s", archive)) 74 | if err != nil { 75 | return err 76 | } 77 | defer tempDownload.Close() 78 | defer os.Remove(tempDownload.Name()) 79 | 80 | log.Debug("tempDir", "name", tempDownload.Name()) 81 | pbar := progressbar.DefaultBytes( 82 | int64(resp.ContentLength), 83 | "Upgrading ZVM...", 84 | ) 85 | 86 | _, err = io.Copy(io.MultiWriter(tempDownload, pbar), resp.Body) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | zvmPath := filepath.Join(zvmInstallDirENV, zvmBinaryName) 92 | if err := os.Remove(filepath.Join(zvmInstallDirENV, zvmBinaryName)); err != nil { 93 | if err, ok := err.(*os.PathError); ok { 94 | if os.IsNotExist(err) { 95 | log.Debug("Failed to remove file", "path", zvmPath) 96 | } 97 | } 98 | } 99 | 100 | log.Debug("zvmPath", "path", zvmPath) 101 | 102 | newTemp, err := os.MkdirTemp(z.baseDir, "zvm-upgrade-*") 103 | if err != nil { 104 | log.Debugf("Failed to create temp direcory: %s", newTemp) 105 | return errors.Join(ErrFailedUpgrade, err) 106 | } 107 | 108 | defer os.RemoveAll(newTemp) 109 | 110 | if runtime.GOOS == "windows" { 111 | log.Debug("unzip", "from", tempDownload.Name(), "to", newTemp) 112 | if err := unzipSource(tempDownload.Name(), newTemp); err != nil { 113 | log.Error(err) 114 | return err 115 | } 116 | 117 | secondaryZVM := fmt.Sprintf("%s.old", zvmPath) 118 | log.Debug("SecondaryZVM", "path", secondaryZVM) 119 | 120 | newDownload := filepath.Join(newTemp, fmt.Sprintf("zvm-%s-%s", runtime.GOOS, runtime.GOARCH), zvmBinaryName) 121 | 122 | if err := replaceExe(newDownload, zvmPath); err != nil { 123 | log.Warn("This command might break if ZVM is installed outside of ~/.zvm/self/") 124 | return fmt.Errorf("upgrade error: %q", err) 125 | } 126 | // fmt.Println("Run the following to complete your upgrade on Windows.") 127 | // fmt.Printf("- Command Prompt:\n\tmove /Y '%s' '%s'\n", secondaryZVM, zvmPath) 128 | // fmt.Printf("- Powershell:\n\tMove-Item -Path '%s' -Destination '%s' -Force\n", secondaryZVM, zvmPath) 129 | 130 | } else { 131 | if err := untar(tempDownload.Name(), newTemp); err != nil { 132 | log.Error(err) 133 | return err 134 | } 135 | 136 | if err := os.Rename(filepath.Join(newTemp, zvmBinaryName), zvmPath); err != nil { 137 | log.Debugf("Failed to rename %s to %s", filepath.Join(newTemp, zvmBinaryName), zvmPath) 138 | return errors.Join(ErrFailedUpgrade, err) 139 | } 140 | } 141 | 142 | if err := os.Chmod(zvmPath, 0775); err != nil { 143 | log.Debugf("Failed to update permissions for %s", zvmPath) 144 | return errors.Join(ErrFailedUpgrade, err) 145 | } 146 | 147 | return nil 148 | } 149 | 150 | // Replaces one file with another on Windows. 151 | func replaceExe(from, to string) error { 152 | if runtime.GOOS == "windows" { 153 | if err := os.Rename(to, fmt.Sprintf("%s.old", to)); err != nil { 154 | return err 155 | } 156 | } else { 157 | if err := os.Remove(to); err != nil { 158 | return err 159 | } 160 | } 161 | 162 | if err := os.Rename(from, to); err != nil { 163 | from_io, err := os.Open(from) 164 | if err != nil { 165 | return err 166 | } 167 | defer from_io.Close() 168 | 169 | to_io, err := os.Create(to) 170 | if err != nil { 171 | return err 172 | } 173 | defer to_io.Close() 174 | 175 | if _, err := io.Copy(to_io, from_io); err != nil { 176 | return nil 177 | } 178 | } 179 | 180 | return nil 181 | } 182 | 183 | // getInstallDir finds the directory this executabile is in. 184 | func (z ZVM) getInstallDir() (string, error) { 185 | zvmInstallDirENV, ok := os.LookupEnv("ZVM_INSTALL") 186 | if !ok { 187 | this, err := os.Executable() 188 | if err != nil { 189 | return filepath.Join(z.baseDir, "self"), nil 190 | } 191 | 192 | itIsASymlink, err := isSymlink(this) 193 | if err != nil { 194 | return filepath.Join(z.baseDir, "self"), nil 195 | } 196 | 197 | var finalPath string 198 | if !itIsASymlink { 199 | finalPath, err = resolveSymlink(this) 200 | if err != nil { 201 | return filepath.Join(z.baseDir, "self"), nil 202 | } 203 | } else { 204 | finalPath = this 205 | } 206 | 207 | modifyable, err := canModifyFile(finalPath) 208 | if err != nil { 209 | return "", fmt.Errorf("%q, couldn't determine permissions to modify zvm install", ErrFailedUpgrade) 210 | } 211 | 212 | if modifyable { 213 | return filepath.Dir(this), nil 214 | } 215 | 216 | return "", fmt.Errorf("%q, didn't have permissions to modify zvm install", ErrFailedUpgrade) 217 | } 218 | 219 | return zvmInstallDirENV, nil 220 | } 221 | 222 | // resolveSymlink follows a symbolic link and returns the absolute path to the target. 223 | func resolveSymlink(symlink string) (string, error) { 224 | target, err := os.Readlink(symlink) 225 | if err != nil { 226 | return "", err 227 | } 228 | // Ensure the path is absolute 229 | absolutePath, err := filepath.Abs(target) 230 | if err != nil { 231 | return "", err 232 | } 233 | return absolutePath, nil 234 | } 235 | 236 | // untar extracts a tarball to the specified target directory. 237 | func untar(tarball, target string) error { 238 | log.Debug("untar", "tarball", tarball, "target", target) 239 | reader, err := os.Open(tarball) 240 | if err != nil { 241 | return err 242 | } 243 | defer reader.Close() 244 | 245 | tarReader := tar.NewReader(reader) 246 | 247 | absTarget, err := filepath.Abs(target) 248 | if err != nil { 249 | return err 250 | } 251 | 252 | for { 253 | header, err := tarReader.Next() 254 | 255 | switch { 256 | case err == io.EOF: 257 | return nil 258 | case err != nil: 259 | return err 260 | case header == nil: 261 | continue 262 | } 263 | 264 | fpath := filepath.Join(absTarget, header.Name) 265 | 266 | if !strings.HasPrefix(fpath, absTarget+string(os.PathSeparator)) { 267 | return fmt.Errorf("illegal file path: %s", fpath) 268 | } 269 | 270 | switch header.Typeflag { 271 | case tar.TypeDir: 272 | if _, err := os.Stat(fpath); err != nil { 273 | if err := os.MkdirAll(fpath, 0755); err != nil { 274 | return err 275 | } 276 | } 277 | case tar.TypeReg: 278 | if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil { 279 | return err 280 | } 281 | 282 | writer, err := os.Create(fpath) 283 | if err != nil { 284 | return err 285 | } 286 | if _, err := io.Copy(writer, tarReader); err != nil { 287 | writer.Close() 288 | return err 289 | } 290 | writer.Close() 291 | } 292 | } 293 | } 294 | 295 | // isSymlink checks if the given path is a symbolic link. 296 | func isSymlink(path string) (bool, error) { 297 | fileInfo, err := os.Lstat(path) 298 | if err != nil { 299 | return false, err 300 | } 301 | return fileInfo.Mode()&os.ModeSymlink != 0, nil 302 | } 303 | 304 | // CanIUpgrade checks if a newer version of ZVM is available on GitHub. 305 | // It returns the latest tag name, a boolean indicating if an upgrade is available, and any error. 306 | func CanIUpgrade() (string, bool, error) { 307 | release, err := getLatestGitHubRelease("tristanisham", "zvm") 308 | if err != nil { 309 | return "", false, err 310 | } 311 | 312 | if semver.Compare(meta.VERSION, release.TagName) == -1 { 313 | return release.TagName, true, nil 314 | } 315 | 316 | return release.TagName, false, nil 317 | } 318 | 319 | // func getGitHubReleases(owner, repo string) ([]GithubRelease, error) { 320 | // url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo) 321 | // resp, err := http.Get(url) 322 | // if err != nil { 323 | // return nil, err 324 | // } 325 | // defer resp.Body.Close() 326 | 327 | // var releases []GithubRelease 328 | // err = json.NewDecoder(resp.Body).Decode(&releases) 329 | // if err != nil { 330 | // return nil, err 331 | // } 332 | 333 | // return releases, nil 334 | // } 335 | 336 | // getLatestGitHubRelease fetches the latest release information for the specified repository from GitHub API. 337 | func getLatestGitHubRelease(owner, repo string) (*GithubRelease, error) { 338 | url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) 339 | resp, err := http.Get(url) 340 | if err != nil { 341 | return nil, err 342 | } 343 | defer resp.Body.Close() 344 | 345 | var release GithubRelease 346 | err = json.NewDecoder(resp.Body).Decode(&release) 347 | if err != nil { 348 | return nil, err 349 | } 350 | 351 | return &release, nil 352 | } 353 | 354 | // GithubRelease represents the JSON structure of a GitHub release object. 355 | type GithubRelease struct { 356 | CreatedAt time.Time `json:"created_at"` 357 | PublishedAt time.Time `json:"published_at"` 358 | TargetCommitish string `json:"target_commitish"` 359 | Name string `json:"name"` 360 | Body string `json:"body"` 361 | ZipballURL string `json:"zipball_url"` 362 | NodeID string `json:"node_id"` 363 | TagName string `json:"tag_name"` 364 | URL string `json:"url"` 365 | HTMLURL string `json:"html_url"` 366 | TarballURL string `json:"tarball_url"` 367 | AssetsURL string `json:"assets_url"` 368 | UploadURL string `json:"upload_url"` 369 | Assets []struct { 370 | UpdatedAt time.Time `json:"updated_at"` 371 | CreatedAt time.Time `json:"created_at"` 372 | Label interface{} `json:"label"` 373 | ContentType string `json:"content_type"` 374 | Name string `json:"name"` 375 | URL string `json:"url"` 376 | State string `json:"state"` 377 | NodeID string `json:"node_id"` 378 | BrowserDownloadURL string `json:"browser_download_url"` 379 | Uploader struct { 380 | FollowingURL string `json:"following_url"` 381 | NodeID string `json:"node_id"` 382 | GistsURL string `json:"gists_url"` 383 | StarredURL string `json:"starred_url"` 384 | GravatarID string `json:"gravatar_id"` 385 | URL string `json:"url"` 386 | HTMLURL string `json:"html_url"` 387 | FollowersURL string `json:"followers_url"` 388 | Login string `json:"login"` 389 | Type string `json:"type"` 390 | AvatarURL string `json:"avatar_url"` 391 | SubscriptionsURL string `json:"subscriptions_url"` 392 | OrganizationsURL string `json:"organizations_url"` 393 | ReposURL string `json:"repos_url"` 394 | EventsURL string `json:"events_url"` 395 | ReceivedEventsURL string `json:"received_events_url"` 396 | ID int `json:"id"` 397 | SiteAdmin bool `json:"site_admin"` 398 | } `json:"uploader"` 399 | Size int `json:"size"` 400 | DownloadCount int `json:"download_count"` 401 | ID int `json:"id"` 402 | } `json:"assets"` 403 | Author struct { 404 | FollowingURL string `json:"following_url"` 405 | NodeID string `json:"node_id"` 406 | GistsURL string `json:"gists_url"` 407 | StarredURL string `json:"starred_url"` 408 | GravatarID string `json:"gravatar_id"` 409 | URL string `json:"url"` 410 | HTMLURL string `json:"html_url"` 411 | FollowersURL string `json:"followers_url"` 412 | Login string `json:"login"` 413 | Type string `json:"type"` 414 | AvatarURL string `json:"avatar_url"` 415 | SubscriptionsURL string `json:"subscriptions_url"` 416 | OrganizationsURL string `json:"organizations_url"` 417 | ReposURL string `json:"repos_url"` 418 | EventsURL string `json:"events_url"` 419 | ReceivedEventsURL string `json:"received_events_url"` 420 | ID int `json:"id"` 421 | SiteAdmin bool `json:"site_admin"` 422 | } `json:"author"` 423 | ID int `json:"id"` 424 | Prerelease bool `json:"prerelease"` 425 | Draft bool `json:"draft"` 426 | } 427 | -------------------------------------------------------------------------------- /cli/install.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tristan Isham. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | package cli 6 | 7 | import ( 8 | "archive/zip" 9 | "crypto/sha256" 10 | "crypto/tls" 11 | "encoding/hex" 12 | "errors" 13 | "fmt" 14 | "io" 15 | "io/fs" 16 | "math/rand" 17 | "net/http" 18 | "net/url" 19 | "os" 20 | "os/exec" 21 | "path" 22 | "path/filepath" 23 | "runtime" 24 | "slices" 25 | "strings" 26 | 27 | "github.com/jedisct1/go-minisign" 28 | "github.com/schollz/progressbar/v3" 29 | "github.com/tristanisham/zvm/cli/meta" 30 | 31 | "github.com/charmbracelet/log" 32 | 33 | "github.com/tristanisham/clr" 34 | ) 35 | 36 | // Install downloads and installs the specified Zig version. 37 | // It handles checking for existing installations, verifying checksums, 38 | // and extracting the downloaded bundle. 39 | func (z *ZVM) Install(version string, force bool, mirror bool) error { 40 | err := os.MkdirAll(z.baseDir, 0755) 41 | if err != nil { 42 | return err 43 | } 44 | rawVersionStructure, err := z.fetchVersionMap() 45 | if err != nil { 46 | return err 47 | } 48 | 49 | if !force { 50 | installedVersions, err := z.GetInstalledVersions() 51 | if err != nil { 52 | return err 53 | } 54 | if slices.Contains(installedVersions, version) { 55 | alreadyInstalled := true 56 | installedVersion := version 57 | if version == "master" { 58 | targetZig := strings.TrimSpace(filepath.Join(z.baseDir, "master", "zig")) 59 | cmd := exec.Command(targetZig, "version") 60 | var zigVersion strings.Builder 61 | cmd.Stdout = &zigVersion 62 | err := cmd.Run() 63 | if err != nil { 64 | log.Warn(err) 65 | } 66 | 67 | installedVersion = strings.TrimSpace(zigVersion.String()) 68 | if master, ok := rawVersionStructure["master"]; ok { 69 | if remoteVersion, ok := master["version"].(string); ok { 70 | if installedVersion != remoteVersion { 71 | alreadyInstalled = false 72 | } 73 | } 74 | } 75 | } 76 | if alreadyInstalled { 77 | fmt.Printf("Zig version %s is already installed\nRerun with the `--force` flag to install anyway\n", installedVersion) 78 | return nil 79 | } 80 | } 81 | } 82 | 83 | tarPath, err := getTarPath(version, &rawVersionStructure) 84 | if err != nil { 85 | if errors.Is(err, ErrUnsupportedVersion) { 86 | return fmt.Errorf("%s: %q", err, version) 87 | } else { 88 | return err 89 | } 90 | } 91 | 92 | log.Debug("tarPath", "url", tarPath) 93 | 94 | var tarResp *http.Response 95 | var minisig minisign.Signature 96 | mirror = mirror && z.Settings.UseMirrorList() && z.Settings.VersionMapUrl == DefaultSettings.VersionMapUrl 97 | if mirror { 98 | tarResp, minisig, err = attemptMirrorDownload(z.Settings.MirrorListUrl, tarPath) 99 | } else { 100 | tarResp, err = attemptDownload(tarPath) 101 | } 102 | 103 | if err != nil { 104 | return err 105 | } 106 | defer tarResp.Body.Close() 107 | 108 | var pathEnding string 109 | if runtime.GOOS == "windows" { 110 | pathEnding = "*.zip" 111 | } else { 112 | pathEnding = "*.tar.xz" 113 | } 114 | 115 | tempFile, err := os.CreateTemp(z.baseDir, pathEnding) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | defer tempFile.Close() 121 | defer os.RemoveAll(tempFile.Name()) 122 | 123 | var clrOptVerStr string 124 | if z.Settings.UseColor { 125 | clrOptVerStr = clr.Green(version) 126 | } else { 127 | clrOptVerStr = version 128 | } 129 | 130 | pbar := progressbar.DefaultBytes( 131 | int64(tarResp.ContentLength), 132 | fmt.Sprintf("Downloading %s:", clrOptVerStr), 133 | ) 134 | 135 | hash := sha256.New() 136 | _, err = io.Copy(io.MultiWriter(tempFile, pbar, hash), tarResp.Body) 137 | if err != nil { 138 | return err 139 | } 140 | 141 | var shasum string 142 | 143 | shasum, err = getVersionShasum(version, &rawVersionStructure) 144 | if err != nil { 145 | return err 146 | } 147 | 148 | fmt.Println("Checking shasum...") 149 | if len(shasum) > 0 { 150 | ourHexHash := hex.EncodeToString(hash.Sum(nil)) 151 | log.Debug("shasum check:", "theirs", shasum, "ours", ourHexHash) 152 | if ourHexHash != shasum { 153 | // TODO (tristan) 154 | // Why is my sha256 identical on the server and sha256sum, 155 | // but not when I download it in ZVM? Oh shit. 156 | // It's because it's a compressed download. 157 | return fmt.Errorf("shasum for %v does not match expected value", version) 158 | } 159 | fmt.Println("Shasums match! 🎉") 160 | } else { 161 | log.Warnf("No shasum provided by host") 162 | } 163 | 164 | if mirror { 165 | fmt.Println("Checking minisign signature...") 166 | pubkey, err := minisign.NewPublicKey(z.Settings.MinisignPubKey) 167 | if err != nil { 168 | return fmt.Errorf("minisign public key decoding failed: %v", err) 169 | } 170 | verified, err := pubkey.VerifyFromFile(tempFile.Name(), minisig) 171 | if err != nil { 172 | return fmt.Errorf("minisign verification failed: %v", err) 173 | } 174 | 175 | if !verified { 176 | return fmt.Errorf("minisign signature for %v could not be verified", version) 177 | } 178 | 179 | fmt.Println("Minisign signature verified! 🎉") 180 | } 181 | 182 | // The base directory where all Zig files for the appropriate version are installed 183 | // installedVersionPath := filepath.Join(z.zvmBaseDir, version) 184 | fmt.Println("Extracting bundle...") 185 | 186 | if err := ExtractBundle(tempFile.Name(), z.baseDir); err != nil { 187 | log.Fatal(err) 188 | } 189 | var tarName string 190 | 191 | resultUrl, err := url.Parse(tarPath) 192 | if err != nil { 193 | log.Error(err) 194 | tarName = version 195 | } 196 | 197 | // Maybe think of a better algorithm 198 | urlPath := strings.Split(resultUrl.Path, "/") 199 | tarName = urlPath[len(urlPath)-1] 200 | tarName = strings.TrimSuffix(tarName, ".tar.xz") 201 | tarName = strings.TrimSuffix(tarName, ".zip") 202 | 203 | if err := os.Rename(filepath.Join(z.baseDir, tarName), filepath.Join(z.baseDir, version)); err != nil { 204 | if _, err := os.Stat(filepath.Join(z.baseDir, version)); err == nil { 205 | // Room here to make the backup file. 206 | log.Debug("removing", "path", filepath.Join(z.baseDir, version)) 207 | if err := os.RemoveAll(filepath.Join(z.baseDir, version)); err != nil { 208 | log.Fatal(err) 209 | } else { 210 | oldName := filepath.Join(z.baseDir, tarName) 211 | newName := filepath.Join(z.baseDir, version) 212 | log.Debug("renaming", "old", oldName, "new", newName, "identical", oldName == newName) 213 | if oldName != newName { 214 | if err := os.Rename(oldName, newName); err != nil { 215 | log.Fatal(clr.Yellow(err)) 216 | } 217 | } 218 | 219 | } 220 | 221 | } 222 | } 223 | 224 | // This removes the extra download 225 | if err := os.RemoveAll(filepath.Join(z.baseDir, tarName)); err != nil { 226 | log.Warn(err) 227 | } 228 | 229 | z.createSymlink(version) 230 | 231 | fmt.Println("Successfully installed Zig!") 232 | 233 | return nil 234 | } 235 | 236 | // attemptMirrorDownload HTTP requests Zig downloads from the community mirrorlist. 237 | // Returns a tuple of (response, minisig, error). 238 | func attemptMirrorDownload(mirrorListURL string, tarURL string) (*http.Response, minisign.Signature, error) { 239 | log.Debug("attemptMirrorDownload", "mirrorListURL", mirrorListURL, "tarURL", tarURL) 240 | tarURLParsed, err := url.Parse(tarURL) 241 | if err != nil { 242 | return nil, minisign.Signature{}, fmt.Errorf("%w: %w", ErrDownloadFail, err) 243 | } 244 | tarName := path.Base(tarURLParsed.Path) 245 | 246 | resp, err := attemptDownload(mirrorListURL) 247 | if err != nil { 248 | return nil, minisign.Signature{}, fmt.Errorf("%w: %w", ErrDownloadFail, err) 249 | } 250 | defer resp.Body.Close() 251 | 252 | mirrorBytes, err := io.ReadAll(resp.Body) 253 | if err != nil { 254 | return nil, minisign.Signature{}, err 255 | } 256 | 257 | mirrors := strings.Split(string(mirrorBytes), "\n") 258 | // Pop empty field after terminating newline 259 | mirrors = mirrors[:len(mirrors)-1] 260 | rand.Shuffle(len(mirrors), func(i, j int) { mirrors[i], mirrors[j] = mirrors[j], mirrors[i] }) 261 | // Default as fallback 262 | mirrors = append(mirrors, "https://ziglang.org/builds/") 263 | 264 | for i, mirror := range mirrors { 265 | mirrorTarURL, err := url.JoinPath(mirror, tarName) 266 | if err != nil { 267 | log.Debug("mirror path error", "mirror", mirror, "error", err) 268 | continue 269 | } 270 | 271 | log.Debug("attemptMirrorDownload", "mirror", i, "mirrorURL", mirrorTarURL) 272 | tarResp, err := attemptDownload(mirrorTarURL) 273 | if err != nil { 274 | log.Debug("mirror tar error", "mirror", mirror, "error", err) 275 | continue 276 | } 277 | 278 | minisig, err := attemptMinisigDownload(mirrorTarURL) 279 | if err != nil { 280 | log.Debug("mirror minisig error", "mirror", mirror, "error", err) 281 | tarResp.Body.Close() 282 | continue 283 | } 284 | 285 | return tarResp, minisig, nil 286 | } 287 | 288 | return nil, minisign.Signature{}, fmt.Errorf("%w: %w: %w", ErrDownloadFail, errors.New("all download attempts failed"), err) 289 | } 290 | 291 | // attemptMinisigDownload downloads the minisign signature for a given tarball URL. 292 | func attemptMinisigDownload(tarURL string) (minisign.Signature, error) { 293 | minisigResp, err := attemptDownload(tarURL + ".minisig") 294 | if err != nil { 295 | return minisign.Signature{}, err 296 | } 297 | defer minisigResp.Body.Close() 298 | 299 | minisigBytes, err := io.ReadAll(minisigResp.Body) 300 | if err != nil { 301 | return minisign.Signature{}, err 302 | } 303 | 304 | return minisign.DecodeSignature(string(minisigBytes)) 305 | } 306 | 307 | // attemptDownload creates a generic http request for ZVM. 308 | func attemptDownload(url string) (*http.Response, error) { 309 | req, err := createDownloadReq(url) 310 | if err != nil { 311 | return nil, fmt.Errorf("%w: %w", ErrDownloadFail, err) 312 | } 313 | 314 | client := http.DefaultClient 315 | 316 | // Checks the ZVM_SKIP_TLS_VERIFY environment variable and 317 | // toggles verifying a secure connection. 318 | if kind, is := os.LookupEnv("ZVM_SKIP_TLS_VERIFY"); is { 319 | 320 | if kind != "no-warn" { 321 | log.Warnf("ZVM_SKIP_TLS_VERIFY enabled") 322 | } 323 | 324 | log.Debug("ZVM_SKIP_TLS_VERIFY", "enabled", true) 325 | client = &http.Client{ 326 | Transport: &http.Transport{ 327 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 328 | }, 329 | } 330 | } else { 331 | // Yeah, yeah. Just an easy way to do the call. 332 | log.Debug("ZVM_SKIP_TLS_VERIFY", "enabled", false) 333 | } 334 | 335 | resp, err := client.Do(req) 336 | if err != nil { 337 | return nil, fmt.Errorf("%w: %w", ErrDownloadFail, err) 338 | } 339 | 340 | if resp.StatusCode != 200 { 341 | return nil, fmt.Errorf("%w: %s", ErrDownloadFail, resp.Status) 342 | } 343 | 344 | return resp, nil 345 | } 346 | 347 | // createDownloadReq creates a new HTTP GET request for downloading Zig or ZLS, 348 | // setting appropriate User-Agent and client headers. 349 | func createDownloadReq(tarURL string) (*http.Request, error) { 350 | zigArch, zigOS := zigStyleSysInfo() 351 | 352 | zigDownloadReq, err := http.NewRequest("GET", tarURL+"?source=zvm", nil) 353 | if err != nil { 354 | return nil, err 355 | } 356 | 357 | zigDownloadReq.Header.Set("User-Agent", "zvm "+meta.VERSION) 358 | zigDownloadReq.Header.Set("X-Client-Os", zigOS) 359 | zigDownloadReq.Header.Set("X-Client-Arch", zigArch) 360 | 361 | return zigDownloadReq, nil 362 | } 363 | 364 | // SelectZlsVersion determines the appropriate ZLS version for a given Zig version. 365 | // It checks tagged releases first, then falls back to compatibility mode logic. 366 | func (z *ZVM) SelectZlsVersion(version string, compatMode string) (string, string, string, error) { 367 | rawVersionStructure, err := z.fetchZlsTaggedVersionMap() 368 | if err != nil { 369 | return "", "", "", err 370 | } 371 | 372 | // tagged releases. 373 | tarPath, err := getTarPath(version, &rawVersionStructure) 374 | if err == nil { 375 | shasum, err := getVersionShasum(version, &rawVersionStructure) 376 | if err == nil { 377 | return version, tarPath, shasum, nil 378 | } 379 | } 380 | 381 | // master/nightly releases. 382 | if err == ErrUnsupportedVersion { 383 | info, err := z.fetchZlsVersionByZigVersion(version, compatMode) 384 | if err != nil { 385 | return "", "", "", err 386 | } 387 | 388 | zlsVersion, ok := info["version"].(string) 389 | if !ok { 390 | return "", "", "", ErrMissingVersionInfo 391 | } 392 | 393 | arch, ops := zigStyleSysInfo() 394 | systemInfo, ok := info[fmt.Sprintf("%s-%s", arch, ops)].(map[string]any) 395 | if !ok { 396 | return "", "", "", ErrUnsupportedSystem 397 | } 398 | 399 | tar, ok := systemInfo["tarball"].(string) 400 | if !ok { 401 | return "", "", "", ErrMissingBundlePath 402 | } 403 | 404 | shasum, ok := systemInfo["shasum"].(string) 405 | if !ok { 406 | return "", "", "", ErrMissingShasum 407 | } 408 | 409 | return zlsVersion, tar, shasum, nil 410 | } 411 | 412 | return "", "", "", err 413 | } 414 | 415 | // InstallZls downloads and installs the Zig Language Server (ZLS) for the specified Zig version. 416 | func (z *ZVM) InstallZls(requestedVersion string, compatMode string, force bool) error { 417 | fmt.Println("Determining installed Zig version...") 418 | 419 | // make sure dir exists 420 | installDir := filepath.Join(z.baseDir, requestedVersion) 421 | err := os.MkdirAll(installDir, 0755) 422 | if err != nil { 423 | return err 424 | } 425 | 426 | targetZig := strings.TrimSpace(filepath.Join(z.baseDir, requestedVersion, "zig")) 427 | cmd := exec.Command(targetZig, "version") 428 | var builder strings.Builder 429 | cmd.Stdout = &builder 430 | err = cmd.Run() 431 | if err != nil { 432 | log.Warn(err) 433 | } 434 | zigVersion := strings.TrimSpace(builder.String()) 435 | log.Debug("installed zig version", "version", zigVersion) 436 | 437 | fmt.Println("Selecting ZLS version...") 438 | 439 | zlsVersion, tarPath, shasum, err := z.SelectZlsVersion(zigVersion, compatMode) 440 | if err != nil { 441 | if errors.Is(err, ErrUnsupportedVersion) { 442 | return fmt.Errorf("%s: %q", err, zigVersion) 443 | } else { 444 | return err 445 | } 446 | } 447 | log.Debug("selected zls version", "zigVersion", zigVersion, "zlsVersion", zlsVersion) 448 | 449 | _, osType := zigStyleSysInfo() 450 | filename := "zls" 451 | if osType == "windows" { 452 | filename += ".exe" 453 | } 454 | 455 | if !force { 456 | installedVersion := "" 457 | targetZls := strings.TrimSpace(filepath.Join(installDir, filename)) 458 | if _, err := os.Stat(targetZls); err == nil { 459 | cmd := exec.Command(targetZls, "--version") 460 | var builder strings.Builder 461 | cmd.Stdout = &builder 462 | err := cmd.Run() 463 | if err != nil { 464 | log.Warn(err) 465 | } 466 | 467 | installedVersion = strings.TrimSpace(builder.String()) 468 | } 469 | if installedVersion == zlsVersion { 470 | fmt.Printf("ZLS version %s is already installed\n", installedVersion) 471 | return nil 472 | } 473 | } 474 | 475 | log.Debug("tarPath", "url", tarPath) 476 | 477 | tarResp, err := attemptDownload(tarPath) 478 | if err != nil { 479 | return err 480 | } 481 | defer tarResp.Body.Close() 482 | 483 | var pathEnding string 484 | if runtime.GOOS == "windows" { 485 | pathEnding = "*.zip" 486 | } else { 487 | pathEnding = "*.tar.xz" 488 | } 489 | 490 | tempDir, err := os.CreateTemp(z.baseDir, pathEnding) 491 | if err != nil { 492 | return err 493 | } 494 | 495 | defer tempDir.Close() 496 | defer os.RemoveAll(tempDir.Name()) 497 | 498 | var clr_opt_ver_str string 499 | if z.Settings.UseColor { 500 | clr_opt_ver_str = clr.Green(zlsVersion) 501 | } else { 502 | clr_opt_ver_str = zlsVersion 503 | } 504 | 505 | pbar := progressbar.DefaultBytes( 506 | int64(tarResp.ContentLength), 507 | fmt.Sprintf("Downloading ZLS %s:", clr_opt_ver_str), 508 | ) 509 | 510 | hash := sha256.New() 511 | _, err = io.Copy(io.MultiWriter(tempDir, pbar, hash), tarResp.Body) 512 | if err != nil { 513 | return err 514 | } 515 | 516 | fmt.Println("Checking ZLS shasum...") 517 | if len(shasum) > 0 { 518 | ourHexHash := hex.EncodeToString(hash.Sum(nil)) 519 | log.Debug("shasum check:", "theirs", shasum, "ours", ourHexHash) 520 | if ourHexHash != shasum { 521 | // TODO (tristan) 522 | // Why is my sha256 identical on the server and sha256sum, 523 | // but not when I download it in ZVM? Oh shit. 524 | // It's because it's a compressed download. 525 | return fmt.Errorf("shasum for zls-%v does not match expected value", zlsVersion) 526 | } 527 | fmt.Println("Shasums for ZLS match! 🎉") 528 | } else { 529 | log.Warnf("No ZLS shasum provided by host") 530 | } 531 | 532 | fmt.Println("Extracting ZLS bundle...") 533 | 534 | zlsTempDir, err := os.MkdirTemp(z.baseDir, "zls-*") 535 | if err != nil { 536 | return err 537 | } 538 | defer os.RemoveAll(zlsTempDir) 539 | 540 | if err := ExtractBundle(tempDir.Name(), zlsTempDir); err != nil { 541 | log.Fatal(err) 542 | } 543 | 544 | zlsPath, err := findZlsExecutable(zlsTempDir) 545 | if err != nil { 546 | return err 547 | } 548 | 549 | if err := os.Rename(zlsPath, filepath.Join(installDir, filename)); err != nil { 550 | return err 551 | } 552 | 553 | if zlsPath == "" { 554 | return fmt.Errorf("could not find ZLS in %q", zlsTempDir) 555 | } 556 | 557 | if err := os.Chmod(filepath.Join(installDir, filename), 0755); err != nil { 558 | return err 559 | } 560 | 561 | z.createSymlink(requestedVersion) 562 | fmt.Println("Done! 🎉") 563 | return nil 564 | } 565 | 566 | // findZlsExecutable searches the given directory for the ZLS executable. 567 | func findZlsExecutable(dir string) (string, error) { 568 | var result string 569 | 570 | filename := "zls" 571 | if runtime.GOOS == "windows" { 572 | filename += ".exe" 573 | } 574 | 575 | err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { 576 | if err != nil { 577 | return err 578 | } 579 | 580 | if d.IsDir() || d.Type().Type() == os.ModeSymlink { 581 | return nil 582 | } 583 | 584 | if filepath.Base(path) != filename { 585 | return nil 586 | } 587 | 588 | result = path 589 | 590 | return fs.SkipAll 591 | }) 592 | if err != nil { 593 | return "", err 594 | } 595 | 596 | return result, nil 597 | } 598 | 599 | // createSymlink creates a symbolic link for the installed version 600 | // pointing to the 'bin' directory in the ZVM base path. 601 | func (z *ZVM) createSymlink(version string) { 602 | // .zvm/master 603 | versionPath := filepath.Join(z.baseDir, version) 604 | binDir := filepath.Join(z.baseDir, "bin") 605 | 606 | stat, err := os.Lstat(binDir) 607 | 608 | // See zvm.Use() for an explanation. 609 | if stat != nil { 610 | if err == nil { 611 | fmt.Println("Removing old inode link") 612 | if err := os.RemoveAll(binDir); err != nil { 613 | log.Fatal("could not remove bin", "err", err, "dir", binDir) 614 | } 615 | 616 | } 617 | } 618 | 619 | if err := meta.Link(versionPath, binDir); err != nil { 620 | log.Fatal("meta.Link error", err) 621 | } 622 | 623 | } 624 | 625 | // getTarPath determines the download URL for the Zig binary based on the version and system architecture. 626 | func getTarPath(version string, data *map[string]map[string]any) (string, error) { 627 | arch, ops := zigStyleSysInfo() 628 | 629 | if info, ok := (*data)[version]; ok { 630 | if systemInfo, ok := info[fmt.Sprintf("%s-%s", arch, ops)]; ok { 631 | if base, ok := systemInfo.(map[string]any); ok { 632 | if tar, ok := base["tarball"].(string); ok { 633 | return tar, nil 634 | } 635 | } else { 636 | return "", ErrMissingBundlePath 637 | } 638 | } else { 639 | return "", ErrUnsupportedSystem 640 | } 641 | } 642 | 643 | 644 | return "", ErrUnsupportedVersion 645 | } 646 | 647 | // getVersionShasum retrieves the expected SHA256 checksum for the Zig binary of the given version. 648 | func getVersionShasum(version string, data *map[string]map[string]any) (string, error) { 649 | if info, ok := (*data)[version]; ok { 650 | arch, ops := zigStyleSysInfo() 651 | if systemInfo, ok := info[fmt.Sprintf("%s-%s", arch, ops)]; ok { 652 | if base, ok := systemInfo.(map[string]any); ok { 653 | if shasum, ok := base["shasum"].(string); ok { 654 | return shasum, nil 655 | } 656 | } else { 657 | return "", fmt.Errorf("unable to find necessary download path") 658 | } 659 | } else { 660 | return "", fmt.Errorf("invalid/unsupported system: ARCH: %s OS: %s", arch, ops) 661 | } 662 | } 663 | verMap := []string{" "} 664 | for key := range *data { 665 | verMap = append(verMap, key) 666 | } 667 | 668 | return "", fmt.Errorf("invalid Zig version: %s\nAllowed versions:%s", version, strings.Join(verMap, "\n ")) 669 | } 670 | 671 | // zigStyleSysInfo returns the architecture and operating system strings 672 | // formatted as expected by Zig's download servers (e.g., "x86_64", "macos"). 673 | func zigStyleSysInfo() (arch string, os string) { 674 | arch = runtime.GOARCH 675 | os = runtime.GOOS 676 | 677 | switch arch { 678 | case "amd64": 679 | arch = "x86_64" 680 | case "arm64": 681 | arch = "aarch64" 682 | case "loong64": 683 | arch = "loongarch64" 684 | case "ppc64le": 685 | arch = "powerpc64le" 686 | } 687 | 688 | switch os { 689 | case "darwin": 690 | os = "macos" 691 | } 692 | 693 | return arch, os 694 | } 695 | 696 | // ExtractBundle extracts a compressed bundle (zip, tar.xz) to the specified output directory. 697 | func ExtractBundle(bundle, out string) error { 698 | // This is how I extracted an extension from a path in a cross-platform manner before 699 | // I realized filepath existed. 700 | // ----------------------------------------------------------------------------------- 701 | // get extension 702 | // replacedBundle := strings.ReplaceAll(bundle, "\\", "/") 703 | // splitPath := strings.Split(replacedBundle, "/") 704 | // _, extension, _ := strings.Cut(splitPath[len(splitPath)-1], ".") 705 | extension := filepath.Ext(bundle) 706 | 707 | // For some reason, this broke inexplicably in v0.6.6. Added check for ".xz" extension 708 | // to fix, but would love to know how this became an issue. 709 | if strings.Contains(extension, "tar") || extension == ".xz" { 710 | return untarXZ(bundle, out) 711 | } else if extension == ".zip" { 712 | return unzipSource(bundle, out) 713 | } 714 | 715 | return fmt.Errorf("unknown format %v", extension) 716 | } 717 | 718 | // untarXZ extracts a .tar.xz file to the specified output directory using the 'tar' command. 719 | func untarXZ(in, out string) error { 720 | tar := exec.Command("tar", "-xf", in, "-C", out) 721 | tar.Stdout = os.Stdout 722 | tar.Stderr = os.Stderr 723 | if err := tar.Run(); err != nil { 724 | log.Debug("Error untarring bundle") 725 | return err 726 | } 727 | return nil 728 | } 729 | 730 | // unzipSource extracts a .zip file to the specified destination directory. 731 | func unzipSource(source, destination string) error { 732 | // 1. Open the zip file 733 | reader, err := zip.OpenReader(source) 734 | if err != nil { 735 | return err 736 | } 737 | 738 | defer reader.Close() 739 | 740 | // 2. Get the absolute destination path 741 | destination, err = filepath.Abs(destination) 742 | if err != nil { 743 | return err 744 | } 745 | 746 | os.MkdirAll(destination, 0755) 747 | 748 | extractAndWriteFile := func(f *zip.File) error { 749 | rc, err := f.Open() 750 | if err != nil { 751 | return err 752 | } 753 | 754 | defer func() { 755 | if err := rc.Close(); err != nil { 756 | panic(err) 757 | } 758 | }() 759 | 760 | path := filepath.Join(destination, f.Name) 761 | if !strings.HasPrefix(path, filepath.Clean(destination)+string(os.PathSeparator)) { 762 | return fmt.Errorf("illegal file path: %s", path) 763 | } 764 | 765 | if f.FileInfo().IsDir() { 766 | os.MkdirAll(path, f.Mode()) 767 | } else { 768 | os.MkdirAll(filepath.Dir(path), f.Mode()) 769 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) 770 | if err != nil { 771 | return err 772 | } 773 | 774 | defer func() { 775 | if err := f.Close(); err != nil { 776 | panic(err) 777 | } 778 | }() 779 | 780 | _, err = io.Copy(f, rc) 781 | if err != nil { 782 | return err 783 | } 784 | } 785 | 786 | return nil 787 | } 788 | 789 | // 3. Iterate over zip files inside the archive and unzip each of them 790 | for _, f := range reader.File { 791 | err := extractAndWriteFile(f) 792 | if err != nil { 793 | return err 794 | } 795 | 796 | } 797 | 798 | return nil 799 | } 800 | 801 | type installRequest struct { 802 | Site, Package, Version string 803 | } 804 | 805 | // ExtractInstall parses a simplified installation string (e.g., "site:package@version") 806 | // into an installRequest structure. 807 | func ExtractInstall(input string) installRequest { 808 | log.Debug("ExtractInstall", "input", input) 809 | var req installRequest 810 | colonIdx := strings.Index(input, ":") 811 | atIdx := strings.Index(input, "@") 812 | 813 | if colonIdx != -1 { 814 | req.Site = input[:colonIdx] 815 | if atIdx != -1 { 816 | req.Package = input[colonIdx+1 : atIdx] 817 | req.Version = input[atIdx+1:] 818 | } else { 819 | req.Package = input[colonIdx+1:] 820 | } 821 | } else { 822 | if atIdx != -1 { 823 | req.Package = input[:atIdx] 824 | req.Version = input[atIdx+1:] 825 | } else { 826 | req.Package = input 827 | } 828 | } 829 | 830 | return req 831 | } 832 | --------------------------------------------------------------------------------