diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100755 index 0000000..da0407c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ + +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100755 index 0000000..7270150 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ + +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + go: [ '1.26' ] + steps: + - uses: actions/checkout@v3 + + - name: Setup Go + uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.go }} + + - name: Run CI + env: + COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }} + run: make ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4590b3c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,82 @@ +name: release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + - goos: windows + goarch: arm64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22.x" + - name: Build release archive + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + VERSION: ${{ github.ref_name }} + shell: bash + run: | + set -euo pipefail + version="${VERSION#v}" + stage="$(mktemp -d)" + trap 'rm -rf "${stage}"' EXIT + suffix="" + extension="tar.gz" + if [ "${GOOS}" = "windows" ]; then + suffix=".exe" + extension="zip" + fi + go build -trimpath -ldflags "-s -w -X github.com/osspkg/gvm/internal/version.Value=${VERSION}" -o "${stage}/gvm${suffix}" ./cmd/gvm + go build -trimpath -ldflags "-s -w -X github.com/osspkg/gvm/internal/version.Value=${VERSION}" -o "${stage}/go${suffix}" ./cmd/go + archive="gvm_${version}_${GOOS}_${GOARCH}.${extension}" + if [ "${GOOS}" = "windows" ]; then + (cd "${stage}" && zip -q "${GITHUB_WORKSPACE}/${archive}" "gvm.exe" "go.exe") + else + tar -czf "${archive}" -C "${stage}" gvm go + fi + sha256sum "${archive}" > "${archive}.sha256" + - uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.goos }}-${{ matrix.goarch }} + path: | + gvm_* + + publish: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + pattern: release-* + merge-multiple: true + - name: Create checksums + working-directory: dist + run: cat *.sha256 | sort -k2 > checksums.txt + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "${GITHUB_REF_NAME}" dist/* --generate-notes --verify-tag diff --git a/.gitignore b/.gitignore index ae8444c..d380e10 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,24 @@ -.idea -.vscode -.cache -.tmp -.gvmrc \ No newline at end of file +.idea/ +.vscode/ +.cache/ +.tmp/ +.venv/ +bin/ +dist/ + +.tools/ +vendor/ +build/ +coverage.txt +coverage.out +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.db +*.db-journal +*.mmdb +*.test +*.out +.env \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100755 index 0000000..f2b16a7 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,68 @@ +version: "2" + +run: + go: "1.26" + timeout: 5m + tests: false + issues-exit-code: 1 + modules-download-mode: readonly + allow-parallel-runners: true + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + new: false + fix: false + +output: + formats: + text: + print-linter-name: true + print-issued-lines: true + +formatters: + exclusions: + paths: + - vendors/ + enable: + - gofmt + - goimports + +linters: + settings: + staticcheck: + checks: + - all + - -S1023 + - -ST1000 + - -ST1003 + - -ST1020 + gosec: + excludes: + - G104 + - G115 + - G301 + - G304 + - G306 + - G501 + - G505 + exclusions: + paths: + - vendors/ + default: none + enable: + - govet + - errcheck + - misspell + - gocyclo + - ineffassign + - unparam + - unused + - prealloc + - durationcheck + - staticcheck + - makezero + - nilerr + - errorlint + - bodyclose + - gosec diff --git a/.lic.yaml b/.lic.yaml new file mode 100755 index 0000000..a30bbe8 --- /dev/null +++ b/.lic.yaml @@ -0,0 +1,3 @@ +author: "Mikhail Knyazhev " +lic_short: "BSD 3-Clause" +lic_file: LICENSE diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..75a9c63 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,97 @@ +# Agent instructions + +## Repository scope + +- Work from the repository root. +- This is the Go module `github.com/osspkg/gvm`. The minimum declared Go version is 1.22; CI and lint configuration currently use Go 1.26. +- Keep command entrypoints in `cmd/gvm` and `cmd/go`. +- Keep implementation packages private under `internal/`: + - `internal/app` orchestrates CLI commands. + - `internal/config` parses and writes safe dotenv-style `.gvmrc` files. + - `internal/env` computes the managed runtime environment and PATH. + - `internal/sdk` installs, lists, validates, and removes Go SDKs. + - `internal/venv` manages project virtual environments and tools. + - `internal/release` handles GitHub release archives and updates. + - `internal/progress`, `internal/runner`, and `internal/version` provide shared runtime behavior. +- Treat `README.md`, `install.sh`, `install.ps1`, and `.github/workflows/` as public operational contracts. + +## Configuration and safety contracts + +- Parse `.gvmrc` as data. Never add shell sourcing, `eval`, command substitution, or executable configuration behavior. +- Preserve unrelated environment entries when changing only `GVM_GO_VERSION`, `GVM_VENV`, or `GVM_TOOLS`. +- When `gvm local` creates a project-local `.gvmrc` without an explicit version, inspect `go.work` first and `go.mod` second, taking the valid `go` directive as `GVM_GO_VERSION`. If both files exist, `go.work` wins; if neither has a valid directive, return an actionable error instead of selecting the system Go implicitly. +- Preserve the resolution order: process environment, nearest local `.gvmrc` from the current directory or its parents, global `$GVM_HOME/.gvmrc`, then defaults. Managed `GOROOT`, `GOPATH`, `GOMODCACHE`, `GOBIN`, and `PATH` must not be overridden by project config. +- Keep SDKs under `$GVM_HOME/.cache/src/go`, global tools under `$GVM_HOME/.cache/bin`, module cache under `$GVM_HOME/.cache/pkg`, and manager binaries under `$GVM_HOME/bin`. +- SDK installation, release updates, and tool installation must stage work safely and clean up temporary files on failure. +- Validate SDK versions before constructing paths. Do not turn user-supplied versions into arbitrary filesystem paths. +- Keep `gvm rm ` limited to the requested SDK. Never replace it with a broad recursive deletion or a path-based delete. +- Do not use the real user `GVM_HOME`, shell profiles, or live GitHub releases for tests. Use `t.TempDir()`, injected environments, and fake HTTP servers. + +## Code and test conventions + +- Use standard Go packages and platform-aware APIs such as `filepath` and `os.PathListSeparator`; do not hard-code Unix path separators in cross-platform code. +- Put tests next to the package they cover in `*_test.go` files. Prefer table-driven tests and temporary directories for filesystem behavior. +- Cover malformed configuration, path traversal, checksum failures, incomplete SDKs, atomic cleanup, PATH ordering, and argument forwarding when changing those areas. +- Keep network tests deterministic with fake HTTP servers. Do not make unit or integration tests depend on the public Go download API or GitHub. +- Update the public README when changing CLI commands, configuration precedence, cache layout, supported platforms, release assets, or installer behavior. +- Avoid committing generated or local output from `bin/`, `dist/`, `.cache/`, `build/`, `coverage.*`, or binaries. + +## Development commands + +Run commands from the repository root. + +For a focused package test: + +```sh +go test ./internal/config +go test ./internal/sdk +``` + +For normal validation: + +```sh +go fmt ./... +go test ./... +go vet ./... +git diff --check +``` + +Before handoff, also run the race suite when the change affects shared state, filesystem lifecycle, process execution, or concurrency: + +```sh +go test -race ./... +``` + +The repository's CI-equivalent target is: + +```sh +make ci +``` + +`make ci` installs `goppy`, runs license setup, linting, tests, and the build pipeline. It may modify generated/license or build outputs; inspect `git status` afterward. The underlying targets are available individually as `make license`, `make lint`, `make tests`, and `make build`. + +Build both command binaries locally with: + +```sh +go build -o bin/gvm ./cmd/gvm +go build -o bin/go ./cmd/go +``` + +The release workflow cross-builds these binaries for Linux, macOS, and Windows on amd64 and arm64. When changing release packaging, keep archive names, `checksums.txt`, executable suffixes, and the matrix in `.github/workflows/release.yml` synchronized with the installers and update client. + +## Installer and release boundaries + +- Keep `install.sh` and `install.ps1` thin and repeatable. They create `GVM_HOME` layout, download a published release, install only `gvm` and `go`, and configure user profiles/environment variables idempotently. +- Test installer syntax with `bash -n install.sh`. Do not execute installers against the real home directory during validation; use an isolated home or inspect the script. +- Changes to GitHub release behavior must preserve SHA-256 verification and atomic replacement of manager binaries without altering installed SDKs. +- Do not run publication, deployment, profile mutation, or release commands merely as validation. These actions require an explicit request. + +## Handoff checklist + +- Re-read the changed code and documentation. +- Run formatting, focused tests, and the broader checks appropriate to the change. +- Run `go test -race ./...` for lifecycle, process, filesystem, or concurrency changes. +- Run `bash -n install.sh` when the Unix installer changes; use a PowerShell syntax check when the Windows installer changes and the tool is available. +- Run `git diff --check` and inspect `git status` for accidental artifacts. +- Report checks that actually ran and distinguish unavailable platform-specific checks from successful checks. + diff --git a/LICENSE b/LICENSE index 30d8962..d6c3bf3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,28 @@ -MIT License +BSD 3-Clause License -Copyright (c) 2024 OSSPkg Team +Copyright (c) 2024-2026, Mikhail Knyazhev -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: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. -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. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..81734d5 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +SHELL=/bin/bash + +.PHONY: install +install: + PATH="$$(go env GOROOT)/bin:$$PATH" go install go.osspkg.com/goppy/v3/cmd/goppy@latest + PATH="$$(go env GOROOT)/bin:$$PATH" goppy setup-lib + +.PHONY: lint +lint: + PATH="$$(go env GOROOT)/bin:$$PATH" goppy lint + +.PHONY: license +license: + PATH="$$(go env GOROOT)/bin:$$PATH" goppy license + +.PHONY: build +build: + PATH="$$(go env GOROOT)/bin:$$PATH" goppy build --arch=amd64 + +.PHONY: tests +tests: + PATH="$$(go env GOROOT)/bin:$$PATH" goppy test + +.PHONY: pre-commit +pre-commit: install license lint tests build + +.PHONY: ci +ci: pre-commit diff --git a/README.md b/README.md index f65e9c5..3afff9a 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,268 @@ # gvm -Golang Virtual Manager -### Install & Update Script +[![CI](https://github.com/osspkg/gvm/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/osspkg/gvm/actions/workflows/ci.yml) +[![Latest Release](https://img.shields.io/github/v/release/osspkg/gvm?sort=semver)](https://github.com/osspkg/gvm/releases) +[![Go Version](https://img.shields.io/github/go-mod/go-version/osspkg/gvm)](https://go.dev/) +[![License](https://img.shields.io/github/license/osspkg/gvm)](LICENSE) -To **install** or **update** gvm, you should run the installation script. To do that, you may either download and run the script manually, or use the following cURL or Wget command: -```shell -curl -o- https://raw.githubusercontent.com/osspkg/gvm/master/install.sh | bash +`gvm` is a per-user Go version manager implemented in Go. It installs and selects Go SDKs, prepares project-specific environments, manages Go tools, and runs binaries with the selected environment. + +Supported platforms: + +- Linux: amd64, arm64 +- macOS: amd64, arm64 +- Windows: amd64, arm64 + +## Features + +- Install Go SDKs from the official Go download metadata. +- Verify SDK archives with SHA-256 checksums before installation. +- Select global and project-local Go versions. +- Discover `.gvmrc` files from the current directory up to the filesystem root. +- Use safe dotenv configuration without shell evaluation or command execution. +- Create project virtual environments in `.venv/bin`. +- Install tools from repeated `GVM_TOOLS` entries. +- Run binaries with deterministic, deduplicated `PATH` ordering. +- List and remove installed SDK versions. +- Update the `gvm` and `go` manager binaries from GitHub Releases. + +## Installation + +### Linux and macOS + +The installer uses the latest GitHub Release and configures `.profile`, `.bashrc`, and `.zshrc`. + +```sh +curl --fail --location https://raw.githubusercontent.com/osspkg/gvm/master/install.sh | bash +``` + +To use a custom installation directory: + +```sh +export GVM_HOME="$HOME/tools/gvm" +curl --fail --location https://raw.githubusercontent.com/osspkg/gvm/master/install.sh | bash +``` + +Open a new shell, or reload the profile after installation: + +```sh +source "$HOME/.profile" +``` + +### Windows PowerShell + +The installer downloads the latest Windows release, sets the user-level `GVM_HOME` and `PATH`, and updates the PowerShell profile. + +```powershell +irm https://raw.githubusercontent.com/osspkg/gvm/master/install.ps1 | iex +``` + +For a custom installation directory: + +```powershell +$env:GVM_HOME = Join-Path $HOME "tools\gvm" +irm https://raw.githubusercontent.com/osspkg/gvm/master/install.ps1 | iex +``` + +Start a new PowerShell session after installation. + +## Quick start + +Select a global Go version and use it in a project: + +```sh +gvm default 1.22.0 + +mkdir -p ~/src/example +cd ~/src/example + +gvm local 1.22.0 +go version +``` + +Create a project environment with tools: + +```sh +cat > .gvmrc <<'EOF' +GVM_GO_VERSION=1.22.0 +GVM_VENV=true +GVM_TOOLS=golang.org/x/tools/gopls@latest +GVM_TOOLS=honnef.co/go/tools/cmd/staticcheck@latest +GOPROXY=https://proxy.golang.org +EOF + +gvm venv +go version +gvm run gopls version +``` + +`gvm venv` creates `.venv/bin`, adds `.venv/` to `.gitignore`, and installs the configured tools into the project environment. + +## Commands + +| Command | Description | +| --- | --- | +| `gvm install [version]` | Install a Go SDK. Without a version, use `GVM_GO_VERSION` from the active `.gvmrc`. | +| `gvm list` | List complete SDKs installed in `$GVM_HOME/.cache/src`. | +| `gvm rm ` | Remove one installed SDK, for example `gvm rm 1.21.0`. | +| `gvm default ` | Install an SDK and configure the global `$GVM_HOME/.gvmrc`. | +| `gvm local [version]` | Install an SDK and configure `.gvmrc` in the current directory. Without a version, infer it from `go.work` first, then `go.mod`. | +| `gvm venv` | Create `.venv/bin`, enable `GVM_VENV=true`, and install configured tools. | +| `gvm run [args...]` | Run a binary using the active SDK environment. | +| `gvm update` | Update the `gvm` and `go` manager binaries from the latest GitHub Release. | +| `gvm version` | Print the gvm version. | +| `go [args...]` | Run the selected Go SDK with the active environment. | + +`gvm rm` accepts a Go version, not a filesystem path. It does not edit `.gvmrc`; if the removed version remains selected, the next `go` invocation may install it again. + +## Configuration + +### `GVM_HOME` + +`GVM_HOME` selects the per-user installation directory. If it is not set, gvm uses: + +```text +$HOME/.gvm +``` + +The installers export `GVM_HOME` and prepend `$GVM_HOME/bin` to `PATH` in the supported shell or PowerShell profiles. + +### `.gvmrc` + +`.gvmrc` is a restricted dotenv file. It is parsed as data and is never sourced by a shell. Shell commands, command substitution, and executable expressions are not evaluated. + +Example: + +```dotenv +GVM_GO_VERSION=1.22.0 +GVM_VENV=true +GVM_TOOLS=golang.org/x/tools/gopls@latest +GVM_TOOLS=honnef.co/go/tools/cmd/staticcheck@latest +GOPROXY=https://proxy.golang.org +``` + +Configuration resolution works as follows: + +1. Process environment values have the highest priority. +2. The nearest `.gvmrc` in the current directory or a parent directory is selected. +3. If no local `.gvmrc` exists, `$GVM_HOME/.gvmrc` is used. +4. `GVM_VENV` defaults to `false` and `GVM_TOOLS` defaults to empty. +5. `GVM_GO_VERSION` is required; there is no implicit Go SDK version for normal `go` invocations. + +Repeated `GVM_TOOLS` entries are preserved in order. The legacy plain-text format containing only a version number is invalid and is not migrated automatically. + +When `gvm local` is called without a version, it searches the current directory and its parents for `go.work` first, then `go.mod`. The first valid `go` directive becomes `GVM_GO_VERSION`; if both files are available, `go.work` wins. Passing a version explicitly remains an override. + +### Managed environment + +For the active SDK, gvm computes these variables: + +```text +GOROOT=$GVM_HOME/.cache/src/go +GOPATH=$GVM_HOME/.cache +GOMODCACHE=$GVM_HOME/.cache/pkg +GOBIN=$GVM_HOME/.cache/bin +``` + +When `GVM_VENV=true`, `GOBIN` becomes `/.venv/bin`. + +The managed `PATH` order is: + +```text +/.venv/bin # when GVM_VENV=true +$GVM_HOME/.cache/bin +$GVM_HOME/bin +inherited PATH without empty entries or duplicates ``` -### Use: +The path separator is selected for the host platform automatically. -```shell -gvm install # only install version -gvm default # set global version and install -gvm local # set local version, generate `.gvmrc` and install -gvm update # install last GVM version +## Directory layout + +```text +$GVM_HOME/ +├── bin/ +│ ├── gvm +│ └── go +└── .cache/ + ├── bin/ # global tools installed with go install + ├── pkg/ # Go module cache + └── src/ + └── go/ # installed Go SDK ``` -### Use file `.gvmrc` -To configure the Go version for the current project, specify the desired version in the file. Example: +Project-local state: ```text -1.22.0 -``` \ No newline at end of file +/ +├── .gvmrc +├── .gitignore +└── .venv/ + └── bin/ +``` + +`gvm venv` adds `.venv/` to `.gitignore` idempotently. + +## Running binaries + +`gvm run` and the `go` wrapper use the same environment construction. Binary lookup order is: + +1. `/.venv/bin` +2. `$GVM_HOME/.cache/bin` +3. `$GVM_HOME/bin` +4. The inherited `PATH` + +Each path is included once, and empty path components are removed. + +## Updates and releases + +`gvm update` retrieves the latest release from `github.com/osspkg/gvm`, selects the archive for the current operating system and architecture, verifies `checksums.txt`, and updates only the manager binaries. Installed Go SDKs are not modified. + +Release archives use these names: + +```text +gvm___.tar.gz # Linux and macOS +gvm___.zip # Windows +``` + +The release workflow publishes builds for all six supported targets and includes SHA-256 checksums. + +## Development + +Requirements: + +- Go 1.22 or newer +- Bash for the Unix installer and local CI commands + +Run the standard checks: + +```sh +go fmt ./... +go test ./... +go test -race ./... +go vet ./... +git diff --check +``` + +Build the command binaries: + +```sh +go build -o bin/gvm ./cmd/gvm +go build -o bin/go ./cmd/go +``` + +Cross-compile an individual target: + +```sh +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./cmd/gvm +``` + +CI configuration is in [.github/workflows/ci.yml](.github/workflows/ci.yml), and release packaging is in [.github/workflows/release.yml](.github/workflows/release.yml). + +## Contributing + +Bug reports, feature requests, and pull requests are welcome. Please include the platform, architecture, Go version, command used, and relevant error output when reporting a problem. + +## License + +gvm is distributed under the [BSD 3-Clause License](LICENSE). diff --git a/bin/go b/bin/go deleted file mode 100755 index f0fe07f..0000000 --- a/bin/go +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash - -DIR="$(command cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -\. "$DIR/../env.sh" - -if [ -z "${GVM_GO}" ]; then - echo "File '.gvmrc' not found." - echo "> Call 'gvm default ' for global setup golang." - echo "> Call 'gvm local ' for local setup golang." - exit 1 -fi - -if [ ! -f "${GOROOT}/bin/go" ]; then - command gvm install -fi - -echo "go: v${GVM_GO}" -exec "${GOROOT}/bin/go" "$@" \ No newline at end of file diff --git a/bin/gvm b/bin/gvm deleted file mode 100755 index f4f1a8e..0000000 --- a/bin/gvm +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash - -DIR="$(command cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -\. "$DIR/../env.sh" - -type_arch_name(){ - case "$(uname -m)" in - aarch64) echo "arm64";; - x86) echo "386";; - x86_64) echo "amd64";; - *) - echo "Unsupported Arch" - exit 1 - ;; - esac -} - -type_os_name(){ - case "$(uname -sr)" in - Linux*) echo "linux";; - *) - echo "Unsupported OS" - exit 1 - ;; - esac -} - -app_download(){ - if ! curl --fail -L -s -q "$1" -o "$2"; then - echo "Failed download: $1" - exit 1 - fi -} - -app_setup(){ - local ver - ver=$1 - local arch - arch=$(type_arch_name) - local os - os=$(type_os_name) - - command mkdir -p "${GVM_CACHE}/go${ver}" - command mkdir -p "${GVM_CACHE}/modules" - - if [ ! -f "${GVM_CACHE}/go${ver}/go/bin/go" ]; then - app_download "https://go.dev/dl/go${ver}.${os}-${arch}.tar.gz" "${GVM_CACHE}/go${ver}.tar.gz" - command tar -C "${GVM_CACHE}/go${ver}" -xzf "${GVM_CACHE}/go${ver}.tar.gz" - fi -} - -__call_use(){ - local ver - ver=$1 - local rcfile - rcfile=$2 - - if [ -z "${ver}" ]; then - ver=$(go_version) - fi - - if [ -z "${ver}" ]; then - echo "File '.gvmrc' not found." - echo "> Call 'gvm default ' for global setup golang." - echo "> Call 'gvm local ' for local setup golang." - exit 1 - fi - - app_setup "${ver}" - go_env "${ver}" - - if [ -n "${rcfile}" ]; then - command printf "${ver}" > "${rcfile}" - fi -} - -__call_update(){ - curl -o- https://raw.githubusercontent.com/osspkg/gvm/master/install.sh | bash -} - -case "$1" in - install) - __call_use "$2" "" - ;; - default) - __call_use "$2" "${GVM_DIR}/.gvmrc" - ;; - local) - __call_use "$2" "$(pwd)/.gvmrc" - ;; - update) - __call_update - ;; - *) - echo "Commands: install, default, local" - ;; -esac \ No newline at end of file diff --git a/cmd/go/main.go b/cmd/go/main.go new file mode 100644 index 0000000..d72a4e6 --- /dev/null +++ b/cmd/go/main.go @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package main + +import ( + "context" + "fmt" + "os" + + "github.com/osspkg/gvm/internal/app" +) + +func main() { + application, err := app.New(os.Stdout, os.Stderr) + if err == nil { + err = application.RunGo(context.Background(), os.Args[1:]) + } + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/gvm/main.go b/cmd/gvm/main.go new file mode 100644 index 0000000..ad2e26f --- /dev/null +++ b/cmd/gvm/main.go @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package main + +import ( + "context" + "fmt" + "os" + + "github.com/osspkg/gvm/internal/app" +) + +func main() { + application, err := app.New(os.Stdout, os.Stderr) + if err == nil { + err = application.RunGVM(context.Background(), os.Args[1:]) + } + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/env.sh b/env.sh deleted file mode 100755 index f1b2b1c..0000000 --- a/env.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash - -export GVM_DIR="${HOME}/.gvm" -export GVM_CACHE="${GVM_DIR}/.cache" -export GVM_BIN="${GVM_DIR}/bin" - -go_version(){ - local RC_PATH - if [ -f "$(pwd)/.gvmrc" ]; then - RC_PATH="$(pwd)/.gvmrc" - else - RC_PATH="${GVM_DIR}/.gvmrc" - fi - if [ -f "${RC_PATH}" ]; then - cat < "${RC_PATH}" | sed -e 's/^[[:space:]]*//' - fi - - echo "" -} - -clean_env_path(){ - local ORIG_PATH - ORIG_PATH="$PATH" - local NEW_PATH - NEW_PATH="" - - while IFS=':' read -ra SRC; do - for i in "${SRC[@]}"; do - if [[ "${i}" != *"${GVM_DIR}"* ]]; then - NEW_PATH="${NEW_PATH}:${i}" - fi - done - done <<< "${ORIG_PATH}" - - export PATH="${NEW_PATH}" -} - -go_env(){ - local ver - ver=$1 - - if [ -n "${ver}" ]; then - export GOROOT="${GVM_CACHE}/go${ver}/go" - export GOPATH="${GVM_CACHE}/modules" - export GODEBUG="netdns=go+2" - export GOVERSION="go${ver}" - - export GVM_GO="${ver}" - export PATH="${PATH}:${GOPATH}/bin:${GOROOT}/bin" - fi - - export PATH="${GVM_BIN}:${PATH}" -} - -clean_env_path -go_env "$(go_version)" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e309642 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/osspkg/gvm + +go 1.22 diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..16453ac --- /dev/null +++ b/install.ps1 @@ -0,0 +1,54 @@ +$ErrorActionPreference = "Stop" + +$repository = "osspkg/gvm" +$gvmHome = if ($env:GVM_HOME) { $env:GVM_HOME } else { Join-Path $HOME ".gvm" } +$binDir = Join-Path $gvmHome "bin" +$cacheDir = Join-Path $gvmHome ".cache" +$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("gvm-" + [System.Guid]::NewGuid().ToString("N")) + +try { + New-Item -ItemType Directory -Force -Path $tempDir, $binDir, (Join-Path $cacheDir "bin"), (Join-Path $cacheDir "pkg"), (Join-Path $cacheDir "src") | Out-Null + $release = Invoke-RestMethod -Headers @{ "Accept" = "application/vnd.github+json"; "User-Agent" = "gvm-installer" } -Uri "https://api.github.com/repos/$repository/releases/latest" + $version = $release.tag_name.TrimStart("v") + $architecture = switch ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture) { + "X64" { "amd64" } + "Arm64" { "arm64" } + default { throw "unsupported architecture" } + } + $archiveName = "gvm_${version}_windows_${architecture}.zip" + $archivePath = Join-Path $tempDir $archiveName + Invoke-WebRequest -Headers @{ "User-Agent" = "gvm-installer" } -Uri "https://github.com/$repository/releases/download/$($release.tag_name)/$archiveName" -OutFile $archivePath + Expand-Archive -Path $archivePath -DestinationPath $tempDir -Force + Copy-Item (Join-Path $tempDir "gvm.exe") (Join-Path $binDir "gvm.exe") -Force + Copy-Item (Join-Path $tempDir "go.exe") (Join-Path $binDir "go.exe") -Force + + [Environment]::SetEnvironmentVariable("GVM_HOME", $gvmHome, "User") + $pathEntry = $binDir.TrimEnd("\") + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $userParts = @($userPath -split ";" | Where-Object { $_ -and $_ -ne $pathEntry }) + [Environment]::SetEnvironmentVariable("Path", ((@($pathEntry) + $userParts) -join ";"), "User") + + $env:GVM_HOME = $gvmHome + $processParts = @($env:Path -split ";" | Where-Object { $_ -and $_ -ne $pathEntry }) + $env:Path = ((@($pathEntry) + $processParts) -join ";") + + if (-not (Test-Path $PROFILE)) { + New-Item -ItemType File -Force -Path $PROFILE | Out-Null + } + if (-not (Select-String -Path $PROFILE -SimpleMatch -Pattern "# >>> gvm >>>" -Quiet)) { + $profileBlock = @( + "", + '# >>> gvm >>>', + '$env:GVM_HOME = if ($env:GVM_HOME) { $env:GVM_HOME } else { Join-Path $HOME ''.gvm'' }', + '$gvmBin = Join-Path $env:GVM_HOME ''bin''', + '$gvmPathParts = @($env:Path -split '';'' | Where-Object { $_ -and $_ -ne $gvmBin })', + '$env:Path = ((@($gvmBin) + $gvmPathParts) -join '';'' )', + '# <<< gvm <<<' + ) -join [Environment]::NewLine + Add-Content -Path $PROFILE -Value $profileBlock + } + Write-Output "gvm $($release.tag_name) installed in $gvmHome" +} +finally { + if (Test-Path $tempDir) { Remove-Item $tempDir -Recurse -Force } +} diff --git a/install.sh b/install.sh index 6e51a94..204a91b 100755 --- a/install.sh +++ b/install.sh @@ -1,67 +1,85 @@ #!/usr/bin/env bash -export GVM_DIR="$HOME/.gvm" +set -euo pipefail -app_has() { - type "$1" > /dev/null 2>&1 -} +readonly repository="osspkg/gvm" +readonly home_dir="${GVM_HOME:-${HOME}/.gvm}" +readonly bin_dir="${home_dir}/bin" +readonly cache_dir="${home_dir}/.cache" +readonly temp_dir="$(mktemp -d)" +trap 'rm -rf "${temp_dir}"' EXIT -app_check() { - if ! app_has "$1"; then - echo "Error: $1 not found" +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "required command not found: $1" >&2 exit 1 fi } -git_clone(){ - git clone --quiet --branch "master" --single-branch https://github.com/osspkg/gvm.git "${GVM_DIR}/.tmp" +platform_os() { + case "$(uname -s)" in + Linux) echo "linux" ;; + Darwin) echo "darwin" ;; + *) echo "unsupported operating system: $(uname -s)" >&2; exit 1 ;; + esac } -get_profile_path(){ - local PROFILE_PATH - PROFILE_PATH='' - for PROFILE in ".bashrc" ".bash_profile" ".profile" - do - if [ -f "${HOME}/${PROFILE}" ]; then - PROFILE_PATH="${HOME}/${PROFILE}" - break - fi - done - if [ -z "${PROFILE_PATH}" ]; then - echo "Failed profile detect" - exit 1 - fi - echo "${PROFILE_PATH}" +platform_arch() { + case "$(uname -m)" in + x86_64|amd64) echo "amd64" ;; + arm64|aarch64) echo "arm64" ;; + *) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac } -setup_profile(){ - local PROFILE_PATH - PROFILE_PATH=$(get_profile_path) - - ENV_STRING="\\n[ -s \"\$HOME/.gvm/env.sh\" ] && \\. \"\$HOME/.gvm/env.sh\" # This loads gvm\\n\\n" - - if ! command grep -qc '/.gvm/env.sh' "${PROFILE_PATH}"; then - command printf "${ENV_STRING}" >> "${PROFILE_PATH}" +append_profile_block() { + local profile="$1" + touch "${profile}" + if grep -Fq '# >>> gvm >>>' "${profile}"; then + return fi + cat >>"${profile}" <<'PROFILE' + +# >>> gvm >>> +export GVM_HOME="${GVM_HOME:-$HOME/.gvm}" +case ":${PATH:-}:" in + *":${GVM_HOME}/bin:"*) ;; + *) export PATH="${GVM_HOME}/bin${PATH:+:${PATH}}" ;; +esac +# <<< gvm <<< +PROFILE } -app_check "curl" -app_check "git" +require_command curl +require_command tar -mkdir -p "$GVM_DIR" -mkdir -p "${GVM_DIR}/.cache" -rm -rf "${GVM_DIR}/.tmp" -mkdir -p "${GVM_DIR}/.tmp" -if ! git_clone; then - echo "Failed clone GVM" +os_name="$(platform_os)" +arch_name="$(platform_arch)" +api_response="${temp_dir}/release.json" +curl --fail --silent --show-error --location \ + "https://api.github.com/repos/${repository}/releases/latest" \ + -H 'Accept: application/vnd.github+json' -H 'User-Agent: gvm-installer' \ + -o "${api_response}" +release_tag="$(sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${api_response}" | head -n 1)" +if [ -z "${release_tag}" ]; then + echo "latest gvm release has no tag" >&2 exit 1 fi -rm -rf "${GVM_DIR}"/.tmp/.git* -cp -rlf "${GVM_DIR}"/.tmp/* "${GVM_DIR}"/ -rm -rf "${GVM_DIR}/.tmp" +release_version="${release_tag#v}" +archive_name="gvm_${release_version}_${os_name}_${arch_name}.tar.gz" +archive_path="${temp_dir}/${archive_name}" +curl --fail --silent --show-error --location \ + "https://github.com/${repository}/releases/download/${release_tag}/${archive_name}" \ + -o "${archive_path}" -setup_profile +mkdir -p "${bin_dir}" "${cache_dir}/bin" "${cache_dir}/pkg" "${cache_dir}/src" +tar -xzf "${archive_path}" -C "${temp_dir}" +install -m 0755 "${temp_dir}/gvm" "${bin_dir}/gvm" +install -m 0755 "${temp_dir}/go" "${bin_dir}/go" -command gvm default "1.22.0" +append_profile_block "${HOME}/.profile" +append_profile_block "${HOME}/.bashrc" +append_profile_block "${HOME}/.zshrc" -echo "Done! Please reboot system!" \ No newline at end of file +echo "gvm ${release_tag} installed in ${home_dir}" +echo "Open a new shell or source your profile to use gvm and go." diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..7c9c510 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,591 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package app + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/osspkg/gvm/internal/config" + "github.com/osspkg/gvm/internal/env" + "github.com/osspkg/gvm/internal/progress" + "github.com/osspkg/gvm/internal/release" + "github.com/osspkg/gvm/internal/runner" + "github.com/osspkg/gvm/internal/sdk" + "github.com/osspkg/gvm/internal/venv" + "github.com/osspkg/gvm/internal/version" +) + +type App struct { + Out io.Writer + Err io.Writer + Environ []string + CWD string + HTTP *http.Client + Progress *progress.Reporter + Executable string + Release *release.Client +} + +func New(out, errOut io.Writer) (*App, error) { + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("get current directory: %w", err) + } + if out == nil { + out = os.Stdout + } + if errOut == nil { + errOut = os.Stderr + } + client := &http.Client{Timeout: 15 * time.Minute} + reporter := progress.New(out, isTerminal(out)) + return &App{ + Out: out, + Err: errOut, + Environ: os.Environ(), + CWD: cwd, + HTTP: client, + Progress: reporter, + Executable: "", + Release: release.NewClient(client, reporter), + }, nil +} + +func (a *App) RunGVM(ctx context.Context, args []string) error { + if len(args) == 0 { + a.printHelp() + return nil + } + if args[0] == "__apply-update" { + if len(args) != 3 { + return errors.New("usage: gvm __apply-update ") + } + return a.applyStagedUpdate(args[1], args[2]) + } + switch args[0] { + case "help", "-h", "--help": + a.printHelp() + return nil + case "version", "--version": + _, err := fmt.Fprintln(a.Out, version.Value) + return err + case "install": + return a.install(ctx, args[1:]) + case "list": + return a.listSDKs(args[1:]) + case "rm": + return a.removeSDK(args[1:]) + case "default": + home, err := a.home() + if err != nil { + return err + } + return a.setVersion(ctx, args[1:], filepath.Join(home, ".gvmrc")) + case "local": + return a.setLocalVersion(ctx, args[1:]) + case "venv": + return a.createVenv(ctx, args[1:]) + case "run": + return a.runBinary(ctx, args[1:]) + case "update": + return a.update(ctx) + default: + return fmt.Errorf("unknown command %q; use `gvm help`", args[0]) + } +} + +func (a *App) RunGo(ctx context.Context, args []string) error { + cfg, err := a.resolveConfig() + if err != nil { + return err + } + sdkRoot, err := a.ensureSDK(ctx, cfg.GoVersion) + if err != nil { + return err + } + prepared, err := a.prepareRuntime(ctx, cfg, sdkRoot) + if err != nil { + return err + } + goBinary, err := runner.Executable(filepath.Join(sdkRoot, "bin"), "go") + if err != nil { + return err + } + return runner.Run(ctx, goBinary, a.CWD, args, prepared.Values, a.Out, a.Err) +} + +func (a *App) install(ctx context.Context, args []string) error { + if len(args) > 1 { + return errors.New("usage: gvm install [version]") + } + versionName := "" + if len(args) == 1 { + versionName = args[0] + } else { + cfg, err := a.resolveConfig() + if err != nil { + return err + } + versionName = cfg.GoVersion + } + path, err := a.ensureSDK(ctx, versionName) + if err != nil { + return err + } + _, err = fmt.Fprintf(a.Out, "Go %s installed at %s\n", versionName, path) + return err +} + +func (a *App) listSDKs(args []string) error { + if len(args) != 0 { + return errors.New("usage: gvm list") + } + home, err := a.home() + if err != nil { + return err + } + versions, err := sdk.NewStore(home, a.HTTP, a.Progress).InstalledVersions() + if err != nil { + return err + } + if _, err := fmt.Fprintln(a.Out, "Installed Go SDKs:"); err != nil { + return err + } + if len(versions) == 0 { + _, err = fmt.Fprintln(a.Out, " none") + return err + } + for _, versionName := range versions { + if _, err := fmt.Fprintf(a.Out, " %s\n", versionName); err != nil { + return err + } + } + return nil +} + +func (a *App) removeSDK(args []string) error { + if len(args) != 1 { + return errors.New("usage: gvm rm ") + } + home, err := a.home() + if err != nil { + return err + } + if err := sdk.NewStore(home, a.HTTP, a.Progress).Remove(args[0]); err != nil { + return err + } + _, err = fmt.Fprintf(a.Out, "removed Go SDK %s\n", args[0]) + return err +} + +func (a *App) setVersion(ctx context.Context, args []string, path string) error { + if len(args) != 1 { + return errors.New("usage: gvm default ") + } + return a.configureVersion(ctx, args[0], path) +} + +func (a *App) setLocalVersion(ctx context.Context, args []string) error { + if len(args) > 1 { + return errors.New("usage: gvm local [version]") + } + versionName := "" + if len(args) == 1 { + versionName = args[0] + } else { + var err error + versionName, err = config.InferGoVersion(a.CWD) + if err != nil { + return err + } + } + return a.configureVersion(ctx, versionName, filepath.Join(a.CWD, ".gvmrc")) +} + +func (a *App) configureVersion(ctx context.Context, versionName, path string) error { + if _, err := a.ensureSDK(ctx, versionName); err != nil { + return err + } + if err := config.Write(path, versionName, nil, nil); err != nil { + return err + } + _, err := fmt.Fprintf(a.Out, "configured Go %s in %s\n", versionName, path) + return err +} + +func (a *App) createVenv(ctx context.Context, args []string) error { + if len(args) != 0 { + return errors.New("usage: gvm venv") + } + cfg, err := a.resolveConfig() + if err != nil { + return err + } + sdkRoot, err := a.ensureSDK(ctx, cfg.GoVersion) + if err != nil { + return err + } + path := filepath.Join(a.CWD, ".gvmrc") + if cfg.LocalPath != "" { + path = cfg.LocalPath + } + venvEnabled := true + tools := []string(nil) + if cfg.LocalPath == "" { + tools = cfg.Tools + } + if err := config.Write(path, cfg.GoVersion, &venvEnabled, tools); err != nil { + return err + } + cfg.Venv = true + if _, err := venv.Ensure(a.CWD); err != nil { + return err + } + prepared, err := env.Build(cfg, a.CWD, a.Environ) + if err != nil { + return err + } + if err := venv.EnsureTools(ctx, a.CWD, sdkRoot, cfg.Tools, prepared.Values, a.Out, a.Err); err != nil { + return err + } + _, err = fmt.Fprintf(a.Out, "virtual environment ready at %s\n", filepath.Join(a.CWD, ".venv")) + return err +} + +func (a *App) runBinary(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("usage: gvm run [args...]") + } + cfg, err := a.resolveConfig() + if err != nil { + return err + } + sdkRoot, err := a.ensureSDK(ctx, cfg.GoVersion) + if err != nil { + return err + } + prepared, err := a.prepareRuntime(ctx, cfg, sdkRoot) + if err != nil { + return err + } + directories := filepath.SplitList(prepared.PATH) + binary, err := runner.Find(args[0], directories) + if err != nil { + return err + } + return runner.Run(ctx, binary, a.CWD, args[1:], prepared.Values, a.Out, a.Err) +} + +func (a *App) resolveConfig() (config.Config, error) { + home, err := a.home() + if err != nil { + return config.Config{}, err + } + return config.Resolve(home, a.CWD, a.Environ) +} + +func (a *App) ensureSDK(ctx context.Context, versionName string) (string, error) { + home, err := a.home() + if err != nil { + return "", err + } + store := sdk.NewStore(home, a.HTTP, a.Progress) + return store.Ensure(ctx, versionName) +} + +func (a *App) prepareRuntime(ctx context.Context, cfg config.Config, sdkRoot string) (env.Result, error) { + if cfg.Venv { + if _, err := venv.Ensure(a.CWD); err != nil { + return env.Result{}, err + } + } + prepared, err := env.Build(cfg, a.CWD, a.Environ) + if err != nil { + return env.Result{}, err + } + if err := venv.EnsureTools(ctx, a.CWD, sdkRoot, cfg.Tools, prepared.Values, a.Out, a.Err); err != nil { + return env.Result{}, err + } + return prepared, nil +} + +func (a *App) home() (string, error) { + values := make(map[string]string, len(a.Environ)) + for _, item := range a.Environ { + key, value, ok := strings.Cut(item, "=") + if ok { + values[key] = value + } + } + if home := values["GVM_HOME"]; home != "" { + return filepath.Abs(home) + } + if home := values["HOME"]; home != "" { + return filepath.Abs(filepath.Join(home, ".gvm")) + } + if home := values["USERPROFILE"]; home != "" { + return filepath.Abs(filepath.Join(home, ".gvm")) + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("find user home: %w", err) + } + return filepath.Abs(filepath.Join(home, ".gvm")) +} + +func (a *App) update(ctx context.Context) error { + latest, err := a.Release.Latest(ctx) + if err != nil { + return err + } + latestVersion := strings.TrimPrefix(latest.TagName, "v") + if version.Value != "dev" && strings.TrimPrefix(version.Value, "v") == latestVersion { + _, err := fmt.Fprintf(a.Out, "gvm %s is already current\n", latest.TagName) + return err + } + archive, err := release.CurrentAsset(latest) + if err != nil { + return err + } + checksums, err := release.ChecksumAsset(latest) + if err != nil { + return err + } + home, err := a.home() + if err != nil { + return err + } + updateDir, err := release.DownloadPath(home) + if err != nil { + return err + } + archivePath := filepath.Join(updateDir, archive.Name) + checksumsPath := filepath.Join(updateDir, checksums.Name) + defer func() { + _ = os.Remove(archivePath) + _ = os.Remove(checksumsPath) + }() + if err := a.Release.Download(ctx, checksums, checksumsPath); err != nil { + return err + } + checksumData, err := os.ReadFile(checksumsPath) + if err != nil { + return fmt.Errorf("read release checksums: %w", err) + } + expected := release.ParseChecksums(checksumData)[archive.Name] + if expected == "" { + return fmt.Errorf("release checksums do not contain %s", archive.Name) + } + if err := a.Release.Download(ctx, archive, archivePath); err != nil { + return err + } + if err := release.VerifyChecksum(archivePath, expected); err != nil { + return err + } + staging, err := os.MkdirTemp(updateDir, "stage-") + if err != nil { + return fmt.Errorf("create update staging directory: %w", err) + } + defer func() { _ = os.RemoveAll(staging) }() + if err := release.ExtractManagerArchive(archivePath, staging); err != nil { + return err + } + binDir := filepath.Join(home, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + return fmt.Errorf("create GVM bin directory: %w", err) + } + if runtime.GOOS == "windows" { + return a.startWindowsUpdater(staging, binDir) + } + if err := replaceManagerBinaries(staging, binDir); err != nil { + return err + } + _, err = fmt.Fprintf(a.Out, "updated gvm to %s\n", latest.TagName) + return err +} + +func (a *App) startWindowsUpdater(staging, binDir string) error { + current, err := os.Executable() + if err != nil { + return fmt.Errorf("find current gvm executable: %w", err) + } + // The parent removes its temporary staging directory when this method + // returns. Move the payload to a pending path that the helper owns. + pending := staging + ".pending" + if err := os.Rename(staging, pending); err != nil { + return fmt.Errorf("preserve Windows update staging: %w", err) + } + restored := false + defer func() { + if !restored { + _ = os.Rename(pending, staging) + } + }() + helper := filepath.Join(filepath.Dir(pending), "gvm-updater.exe") + if err := copyFile(current, helper); err != nil { + return fmt.Errorf("prepare Windows updater: %w", err) + } + //nolint:gosec // helper is a copy of the running gvm executable in a private temp directory. + command := exec.Command(helper, "__apply-update", pending, binDir) + command.Stdout = a.Out + command.Stderr = a.Err + if err := command.Start(); err != nil { + return fmt.Errorf("start Windows updater: %w", err) + } + if err := command.Process.Release(); err != nil { + return fmt.Errorf("detach Windows updater: %w", err) + } + restored = true + _, err = fmt.Fprintln(a.Out, "update staged; restart gvm to finish installation") + return err +} + +func (a *App) applyStagedUpdate(staging, binDir string) error { + if runtime.GOOS == "windows" { + for attempt := 0; attempt < 20; attempt++ { + if err := replaceManagerBinaries(staging, binDir); err == nil { + _ = os.RemoveAll(staging) + return nil + } + time.Sleep(100 * time.Millisecond) + } + return errors.New("replace Windows manager binaries after retries") + } + return replaceManagerBinaries(staging, binDir) +} + +func managerBinaryNames() []string { + if runtime.GOOS == "windows" { + return []string{"gvm.exe", "go.exe"} + } + return []string{"gvm", "go"} +} + +func replaceManagerBinaries(staging, binDir string) error { + names := managerBinaryNames() + for _, name := range names { + info, err := os.Stat(filepath.Join(staging, name)) + if err != nil || info.IsDir() { + return fmt.Errorf("staged update is missing %s", name) + } + } + + backups := make(map[string]string, len(names)) + installed := make([]string, 0, len(names)) + rollback := func() { + for _, path := range installed { + _ = os.Remove(path) + } + for destination, backup := range backups { + _ = os.Rename(backup, destination) + } + } + for _, name := range names { + destination := filepath.Join(binDir, name) + info, err := os.Stat(destination) + if err == nil { + if info.IsDir() { + rollback() + return fmt.Errorf("manager destination is a directory: %s", destination) + } + backupFile, createErr := os.CreateTemp(binDir, ".gvm-backup-*") + if createErr != nil { + rollback() + return fmt.Errorf("create manager backup: %w", createErr) + } + backup := backupFile.Name() + if closeErr := backupFile.Close(); closeErr != nil { + _ = os.Remove(backup) + rollback() + return fmt.Errorf("close manager backup: %w", closeErr) + } + _ = os.Remove(backup) + if renameErr := os.Rename(destination, backup); renameErr != nil { + rollback() + return fmt.Errorf("backup %s: %w", name, renameErr) + } + backups[destination] = backup + } else if !os.IsNotExist(err) { + rollback() + return fmt.Errorf("inspect manager destination: %w", err) + } + } + for _, name := range names { + source := filepath.Join(staging, name) + destination := filepath.Join(binDir, name) + if err := os.Rename(source, destination); err != nil { + rollback() + return fmt.Errorf("replace %s: %w", name, err) + } + installed = append(installed, destination) + } + for _, backup := range backups { + if err := os.Remove(backup); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove manager backup: %w", err) + } + } + return nil +} + +func copyFile(source, destination string) (err error) { + input, err := os.Open(source) + if err != nil { + return err + } + defer func() { + if closeErr := input.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close source file: %w", closeErr) + } + }() + //nolint:gosec // the updater binary must remain owner-readable and executable. + output, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o700) + if err != nil { + return err + } + if _, copyErr := io.Copy(output, input); copyErr != nil { + closeErr := output.Close() + removeErr := os.Remove(destination) + return fmt.Errorf("copy file: %w", errors.Join(copyErr, closeErr, removeErr)) + } + if err := output.Close(); err != nil { + return fmt.Errorf("close destination file: %w", err) + } + return nil +} + +func (a *App) printHelp() { + _, _ = fmt.Fprintln(a.Out, `gvm - Go version manager + +Usage: + gvm install [version] + gvm list + gvm rm + gvm default + gvm local [version] + gvm venv + gvm run [args...] + gvm update + gvm version`) +} + +func isTerminal(writer io.Writer) bool { + file, ok := writer.(*os.File) + if !ok { + return false + } + info, err := file.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0 +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..7df6f55 --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,105 @@ +package app + +import ( + "bytes" + "context" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/osspkg/gvm/internal/progress" +) + +func TestRunGoAndRunUseVenvEnvironment(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX test helper") + } + root := t.TempDir() + home := filepath.Join(root, "home") + project := filepath.Join(root, "project") + sdkBin := filepath.Join(home, ".cache", "src", "go1.22.0", "bin") + venvBin := filepath.Join(project, ".venv", "bin") + if err := os.MkdirAll(sdkBin, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(venvBin, 0o755); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(sdkBin, "go"), "printf 'go|%s|%s|%s|%s\\n' \"$*\" \"$GOROOT\" \"$GOBIN\" \"$PATH\"\n") + writeExecutable(t, filepath.Join(venvBin, "hello"), "printf 'hello|%s|%s\\n' \"$*\" \"$GOBIN\"\n") + if err := os.WriteFile(filepath.Join(project, ".gvmrc"), []byte("GVM_GO_VERSION=1.22.0\nGVM_VENV=true\n"), 0o600); err != nil { + t.Fatal(err) + } + + var output bytes.Buffer + application := &App{ + Out: &output, + Err: &output, + Environ: []string{"GVM_HOME=" + home, "PATH=/usr/bin"}, + CWD: project, + HTTP: &http.Client{}, + Progress: progress.New(&output, false), + } + if err := application.RunGo(context.Background(), []string{"version", "-json"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "go|version -json|") || !strings.Contains(output.String(), "|"+venvBin+"|") { + t.Fatalf("go output = %q", output.String()) + } + output.Reset() + if err := application.RunGVM(context.Background(), []string{"run", "hello", "one", "two"}); err != nil { + t.Fatal(err) + } + if got := output.String(); !strings.Contains(got, "hello|one two|"+venvBin) { + t.Fatalf("run output = %q", got) + } +} + +func TestDefaultPreservesGlobalEnvironment(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX test helper") + } + root := t.TempDir() + home := filepath.Join(root, "home") + project := filepath.Join(root, "project") + sdkBin := filepath.Join(home, ".cache", "src", "go1.22.0", "bin") + if err := os.MkdirAll(sdkBin, 0o755); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(sdkBin, "go"), "exit 0\n") + if err := os.WriteFile(filepath.Join(home, ".gvmrc"), []byte("GVM_GO_VERSION=1.21.0\nGOPROXY=private\nGVM_TOOLS=example.com/tool@latest\n"), 0o600); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + application := &App{ + Out: &output, + Err: &output, + Environ: []string{"GVM_HOME=" + home}, + CWD: project, + HTTP: &http.Client{}, + Progress: progress.New(&output, false), + } + if err := application.RunGVM(context.Background(), []string{"default", "1.22.0"}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(home, ".gvmrc")) + if err != nil { + t.Fatal(err) + } + text := string(data) + for _, expected := range []string{"GVM_GO_VERSION=1.22.0", "GOPROXY=private", "GVM_TOOLS=example.com/tool@latest"} { + if !strings.Contains(text, expected) { + t.Fatalf("global config %q does not contain %q", text, expected) + } + } +} + +func writeExecutable(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o755); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/local_test.go b/internal/app/local_test.go new file mode 100644 index 0000000..e737e78 --- /dev/null +++ b/internal/app/local_test.go @@ -0,0 +1,123 @@ +package app + +import ( + "bytes" + "context" + "errors" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/osspkg/gvm/internal/config" + "github.com/osspkg/gvm/internal/progress" +) + +func TestLocalInfersGoVersionFromWorkspace(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home") + project := filepath.Join(root, "project", "nested") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + writeInstalledSDK(t, home, "1.23.0") + if err := os.WriteFile(filepath.Join(root, "go.work"), []byte("go 1.23.0\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/project\ngo 1.22.0\n"), 0o600); err != nil { + t.Fatal(err) + } + + var output bytes.Buffer + application := &App{ + Out: &output, + Err: &output, + Environ: []string{"GVM_HOME=" + home}, + CWD: project, + HTTP: &http.Client{}, + Progress: progress.New(&output, false), + } + if err := application.RunGVM(context.Background(), []string{"local"}); err != nil { + t.Fatal(err) + } + + values, err := config.ParseFile(filepath.Join(project, ".gvmrc")) + if err != nil { + t.Fatal(err) + } + if values.Fields["GVM_GO_VERSION"] != "1.23.0" { + t.Fatalf("GVM_GO_VERSION = %q, want 1.23.0", values.Fields["GVM_GO_VERSION"]) + } + if !strings.Contains(output.String(), "configured Go 1.23.0") { + t.Fatalf("output = %q", output.String()) + } +} + +func TestLocalInfersGoVersionFromModule(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home") + project := filepath.Join(root, "project") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + writeInstalledSDK(t, home, "1.22.0") + if err := os.WriteFile(filepath.Join(project, "go.mod"), []byte("module example.com/project\ngo 1.22.0\n"), 0o600); err != nil { + t.Fatal(err) + } + + application := &App{ + Out: &bytes.Buffer{}, + Err: &bytes.Buffer{}, + Environ: []string{"GVM_HOME=" + home}, + CWD: project, + HTTP: &http.Client{}, + Progress: progress.New(&bytes.Buffer{}, false), + } + if err := application.RunGVM(context.Background(), []string{"local"}); err != nil { + t.Fatal(err) + } + values, err := config.ParseFile(filepath.Join(project, ".gvmrc")) + if err != nil { + t.Fatal(err) + } + if values.Fields["GVM_GO_VERSION"] != "1.22.0" { + t.Fatalf("GVM_GO_VERSION = %q, want 1.22.0", values.Fields["GVM_GO_VERSION"]) + } +} + +func TestLocalWithoutModuleVersionReturnsError(t *testing.T) { + root := t.TempDir() + application := &App{ + Out: &bytes.Buffer{}, + Err: &bytes.Buffer{}, + Environ: []string{"GVM_HOME=" + filepath.Join(root, "home")}, + CWD: filepath.Join(root, "project"), + HTTP: &http.Client{}, + Progress: progress.New(&bytes.Buffer{}, false), + } + if err := os.MkdirAll(application.CWD, 0o755); err != nil { + t.Fatal(err) + } + + err := application.RunGVM(context.Background(), []string{"local"}) + if !errors.Is(err, config.ErrNoVersion) { + t.Fatalf("gvm local error = %v, want ErrNoVersion", err) + } +} + +func writeInstalledSDK(t *testing.T, home, version string) { + t.Helper() + name := "go" + if runtime.GOOS == "windows" { + name = "go.exe" + } + path := filepath.Join(home, ".cache", "src", "go"+version, "bin", name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("go"), 0o755); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/sdk_commands_test.go b/internal/app/sdk_commands_test.go new file mode 100644 index 0000000..7c324b4 --- /dev/null +++ b/internal/app/sdk_commands_test.go @@ -0,0 +1,69 @@ +package app + +import ( + "bytes" + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestListAndRemoveSDKCommands(t *testing.T) { + home := t.TempDir() + for _, version := range []string{"1.21.0", "1.22.0"} { + path := filepath.Join(home, ".cache", "src", "go"+version, "bin", sdkBinaryNameForTest()) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("go"), 0o755); err != nil { + t.Fatal(err) + } + } + + var output bytes.Buffer + application := &App{Out: &output, Err: &output, Environ: []string{"GVM_HOME=" + home}, CWD: t.TempDir()} + if err := application.RunGVM(context.Background(), []string{"list"}); err != nil { + t.Fatal(err) + } + for _, version := range []string{"1.21.0", "1.22.0"} { + if !strings.Contains(output.String(), " "+version+"\n") { + t.Fatalf("list output = %q", output.String()) + } + } + + output.Reset() + if err := application.RunGVM(context.Background(), []string{"rm", "1.21.0"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "removed Go SDK 1.21.0") { + t.Fatalf("remove output = %q", output.String()) + } + if _, err := os.Stat(filepath.Join(home, ".cache", "src", "go1.21.0")); !os.IsNotExist(err) { + t.Fatalf("removed SDK still exists: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".cache", "src", "go1.22.0")); err != nil { + t.Fatalf("other SDK was changed: %v", err) + } +} + +func TestListAndRemoveValidateArguments(t *testing.T) { + application := &App{Out: &bytes.Buffer{}, Err: &bytes.Buffer{}, Environ: []string{"GVM_HOME=" + t.TempDir()}, CWD: t.TempDir()} + if err := application.RunGVM(context.Background(), []string{"list", "extra"}); err == nil { + t.Fatal("list accepted an argument") + } + if err := application.RunGVM(context.Background(), []string{"rm"}); err == nil { + t.Fatal("rm accepted no version") + } + if err := application.RunGVM(context.Background(), []string{"rm", "../outside"}); err == nil { + t.Fatal("rm accepted a path-like version") + } +} + +func sdkBinaryNameForTest() string { + if runtime.GOOS == "windows" { + return "go.exe" + } + return "go" +} diff --git a/internal/app/update_test.go b/internal/app/update_test.go new file mode 100644 index 0000000..6c19f25 --- /dev/null +++ b/internal/app/update_test.go @@ -0,0 +1,72 @@ +package app + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReplaceManagerBinariesReplacesBothFiles(t *testing.T) { + root := t.TempDir() + staging := filepath.Join(root, "staging") + binDir := filepath.Join(root, "bin") + if err := os.MkdirAll(staging, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range managerBinaryNames() { + if err := os.WriteFile(filepath.Join(staging, name), []byte("new-"+name), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(binDir, name), []byte("old-"+name), 0o755); err != nil { + t.Fatal(err) + } + } + if err := replaceManagerBinaries(staging, binDir); err != nil { + t.Fatal(err) + } + for _, name := range managerBinaryNames() { + data, err := os.ReadFile(filepath.Join(binDir, name)) + if err != nil { + t.Fatal(err) + } + if string(data) != "new-"+name { + t.Fatalf("%s = %q", name, data) + } + } +} + +func TestReplaceManagerBinariesRejectsIncompleteStagingWithoutChangingOldFiles(t *testing.T) { + root := t.TempDir() + staging := filepath.Join(root, "staging") + binDir := filepath.Join(root, "bin") + if err := os.MkdirAll(staging, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + names := managerBinaryNames() + if err := os.WriteFile(filepath.Join(staging, names[0]), []byte("new"), 0o755); err != nil { + t.Fatal(err) + } + for _, name := range names { + if err := os.WriteFile(filepath.Join(binDir, name), []byte("old-"+name), 0o755); err != nil { + t.Fatal(err) + } + } + if err := replaceManagerBinaries(staging, binDir); err == nil { + t.Fatal("replaceManagerBinaries accepted incomplete staging") + } + for _, name := range names { + data, err := os.ReadFile(filepath.Join(binDir, name)) + if err != nil { + t.Fatal(err) + } + if string(data) != "old-"+name { + t.Fatalf("%s changed after rejected update: %q", name, data) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9a3f9ca --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,380 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package config + +import ( + "bufio" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +var ( + ErrNoVersion = errors.New("gvm: go version is not configured") + ErrInvalid = errors.New("gvm: invalid configuration") +) + +var ( + keyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + versionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+(?:\.[0-9]+)?(?:[A-Za-z][A-Za-z0-9.-]*)?$`) +) + +// Values is the parsed representation of one .gvmrc file. +type Values struct { + Path string + Fields map[string]string + Tools []string +} + +// Config is the resolved configuration for one command invocation. +type Config struct { + Home string + GoVersion string + Venv bool + Tools []string + Env map[string]string + LocalPath string + GlobalPath string +} + +// ParseFile reads a restricted dotenv file without executing shell code. +func ParseFile(path string) (Values, error) { + data, err := os.ReadFile(path) + if err != nil { + return Values{}, fmt.Errorf("read config %q: %w", path, err) + } + values, err := Parse(data) + if err != nil { + return Values{}, fmt.Errorf("parse config %q: %w", path, err) + } + values.Path = path + return values, nil +} + +// Parse parses one-line KEY=VALUE entries. Repeated GVM_TOOLS keys are kept in order. +func Parse(data []byte) (Values, error) { + values := Values{Fields: make(map[string]string)} + scanner := bufio.NewScanner(strings.NewReader(string(data))) + scanner.Buffer(make([]byte, 1024), 1024*1024) + lineNumber := 0 + for scanner.Scan() { + lineNumber++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + key, rawValue, ok := strings.Cut(line, "=") + if !ok { + return Values{}, fmt.Errorf("line %d: %w: expected KEY=VALUE", lineNumber, ErrInvalid) + } + key = strings.TrimSpace(key) + if !keyPattern.MatchString(key) { + return Values{}, fmt.Errorf("line %d: %w: invalid key %q", lineNumber, ErrInvalid, key) + } + + value, err := parseValue(strings.TrimSpace(rawValue)) + if err != nil { + return Values{}, fmt.Errorf("line %d: %w: %w", lineNumber, ErrInvalid, err) + } + if key == "GVM_TOOLS" { + if value == "" { + continue + } + if strings.ContainsAny(value, "\r\n") { + return Values{}, fmt.Errorf("line %d: %w: GVM_TOOLS cannot contain newlines", lineNumber, ErrInvalid) + } + values.Tools = append(values.Tools, value) + continue + } + values.Fields[key] = value + } + if err := scanner.Err(); err != nil { + return Values{}, fmt.Errorf("scan config: %w", err) + } + return values, nil +} + +func parseValue(raw string) (string, error) { + if raw == "" { + return "", nil + } + if raw[0] == '\'' { + end := strings.IndexByte(raw[1:], '\'') + if end < 0 { + return "", errors.New("invalid single-quoted value") + } + end++ + if suffix := strings.TrimSpace(raw[end+1:]); suffix != "" && !strings.HasPrefix(suffix, "#") { + return "", errors.New("unexpected content after quoted value") + } + return raw[1:end], nil + } + if raw[0] == '"' { + end := -1 + escaped := false + for index := 1; index < len(raw); index++ { + if raw[index] == '\\' && !escaped { + escaped = true + continue + } + if raw[index] == '"' && !escaped { + end = index + break + } + escaped = false + } + if end < 0 { + return "", errors.New("unterminated double-quoted value") + } + if suffix := strings.TrimSpace(raw[end+1:]); suffix != "" && !strings.HasPrefix(suffix, "#") { + return "", errors.New("unexpected content after quoted value") + } + value, err := strconv.Unquote(raw[:end+1]) + if err != nil { + return "", fmt.Errorf("invalid double-quoted value: %w", err) + } + return value, nil + } + if strings.ContainsAny(raw, "\r\n") { + return "", errors.New("unquoted value cannot contain newlines") + } + if index := strings.Index(raw, " #"); index >= 0 { + raw = strings.TrimSpace(raw[:index]) + } + return raw, nil +} + +// Resolve loads the nearest local file, or the global file when no local file exists. +// Process values override file values for keys present in either configuration. +func Resolve(home, cwd string, process []string) (Config, error) { + if home == "" { + return Config{}, fmt.Errorf("%w: GVM_HOME is empty", ErrInvalid) + } + absoluteHome, err := filepath.Abs(home) + if err != nil { + return Config{}, fmt.Errorf("resolve GVM_HOME: %w", err) + } + absoluteCWD, err := filepath.Abs(cwd) + if err != nil { + return Config{}, fmt.Errorf("resolve current directory: %w", err) + } + + globalPath := filepath.Join(absoluteHome, ".gvmrc") + localPath, err := findLocal(absoluteCWD) + if err != nil { + return Config{}, err + } + var selected Values + if localPath != "" { + selected, err = ParseFile(localPath) + } else if _, statErr := os.Stat(globalPath); statErr == nil { + selected, err = ParseFile(globalPath) + } else if !os.IsNotExist(statErr) { + err = fmt.Errorf("stat global config %q: %w", globalPath, statErr) + } + if err != nil { + return Config{}, err + } + + processValues := environMap(process) + merged := make(map[string]string, len(selected.Fields)+len(processValues)) + for key, value := range selected.Fields { + merged[key] = value + } + for key := range selected.Fields { + if value, ok := processValues[key]; ok { + merged[key] = value + } + } + for _, key := range []string{"GVM_GO_VERSION", "GVM_VENV", "GVM_TOOLS"} { + if value, ok := processValues[key]; ok { + merged[key] = value + } + } + + version := strings.TrimSpace(merged["GVM_GO_VERSION"]) + if version == "" { + return Config{}, fmt.Errorf("%w: set GVM_GO_VERSION in %s", ErrNoVersion, configHint(localPath, globalPath)) + } + if !versionPattern.MatchString(version) { + return Config{}, fmt.Errorf("%w: invalid GVM_GO_VERSION %q", ErrInvalid, version) + } + + venv, err := parseBool(merged["GVM_VENV"]) + if err != nil { + return Config{}, err + } + tools := append([]string(nil), selected.Tools...) + if value, ok := processValues["GVM_TOOLS"]; ok { + tools = splitTools(value) + } + for _, tool := range tools { + if strings.TrimSpace(tool) == "" || strings.ContainsAny(tool, "\r\n") { + return Config{}, fmt.Errorf("%w: invalid GVM_TOOLS entry", ErrInvalid) + } + } + + envValues := make(map[string]string, len(merged)) + for key, value := range merged { + if key == "GVM_GO_VERSION" || key == "GVM_VENV" || key == "GVM_TOOLS" || key == "GVM_HOME" { + continue + } + envValues[key] = value + } + return Config{ + Home: absoluteHome, + GoVersion: version, + Venv: venv, + Tools: tools, + Env: envValues, + LocalPath: localPath, + GlobalPath: globalPath, + }, nil +} + +func findLocal(cwd string) (string, error) { + path := cwd + for { + candidate := filepath.Join(path, ".gvmrc") + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("stat local config %q: %w", candidate, err) + } + parent := filepath.Dir(path) + if parent == path { + return "", nil + } + path = parent + } +} + +func environMap(values []string) map[string]string { + result := make(map[string]string, len(values)) + for _, entry := range values { + key, value, ok := strings.Cut(entry, "=") + if ok { + result[key] = value + } + } + return result +} + +func parseBool(value string) (bool, error) { + if value == "" { + return false, nil + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return false, fmt.Errorf("%w: GVM_VENV must be true or false", ErrInvalid) + } + return parsed, nil +} + +func splitTools(value string) []string { + fields := strings.FieldsFunc(value, func(r rune) bool { return r == '\n' || r == '\r' || r == ' ' || r == '\t' }) + return append([]string(nil), fields...) +} + +func configHint(localPath, globalPath string) string { + if localPath != "" { + return localPath + } + return globalPath +} + +// Write updates reserved values while preserving all other parsed environment values. +func Write(path string, goVersion string, venv *bool, tools []string) error { + if !versionPattern.MatchString(goVersion) { + return fmt.Errorf("%w: invalid GVM_GO_VERSION %q", ErrInvalid, goVersion) + } + values := Values{Fields: make(map[string]string)} + if data, err := os.ReadFile(path); err == nil { + parsed, parseErr := Parse(data) + if parseErr != nil { + return fmt.Errorf("read existing config: %w", parseErr) + } + values = parsed + } else if !os.IsNotExist(err) { + return fmt.Errorf("read existing config: %w", err) + } + values.Fields["GVM_GO_VERSION"] = goVersion + if venv != nil { + values.Fields["GVM_VENV"] = strconv.FormatBool(*venv) + } + if tools != nil { + values.Tools = append([]string(nil), tools...) + } + return writeValues(path, values) +} + +func writeValues(path string, values Values) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create config directory: %w", err) + } + keys := make([]string, 0, len(values.Fields)) + for key := range values.Fields { + if key != "GVM_GO_VERSION" && key != "GVM_VENV" { + keys = append(keys, key) + } + } + sort.Strings(keys) + var builder strings.Builder + if value := values.Fields["GVM_GO_VERSION"]; value != "" { + fmt.Fprintf(&builder, "GVM_GO_VERSION=%s\n", value) + } + if value, ok := values.Fields["GVM_VENV"]; ok { + fmt.Fprintf(&builder, "GVM_VENV=%s\n", value) + } + for _, tool := range values.Tools { + fmt.Fprintf(&builder, "GVM_TOOLS=%s\n", tool) + } + for _, key := range keys { + fmt.Fprintf(&builder, "%s=%s\n", key, quoteValue(values.Fields[key])) + } + + temp, err := os.CreateTemp(filepath.Dir(path), ".gvmrc-*") + if err != nil { + return fmt.Errorf("create config temp file: %w", err) + } + tempName := temp.Name() + defer func() { _ = os.Remove(tempName) }() + if err := temp.Chmod(0o600); err != nil { + _ = temp.Close() + return fmt.Errorf("set config permissions: %w", err) + } + if _, err := temp.WriteString(builder.String()); err != nil { + _ = temp.Close() + return fmt.Errorf("write config: %w", err) + } + if err := temp.Sync(); err != nil { + _ = temp.Close() + return fmt.Errorf("sync config: %w", err) + } + if err := temp.Close(); err != nil { + return fmt.Errorf("close config: %w", err) + } + if err := replaceConfigFile(tempName, path); err != nil { + return fmt.Errorf("replace config: %w", err) + } + return nil +} + +func quoteValue(value string) string { + if value == "" { + return "" + } + if strings.ContainsAny(value, " #\t\r\n\"'") { + return strconv.Quote(value) + } + return value +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..69d7759 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,82 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestParse(t *testing.T) { + values, err := Parse([]byte("# comment\nGVM_GO_VERSION=1.22.0\nGVM_TOOLS='gopls@latest'\nGVM_TOOLS=staticcheck@latest\nGOPROXY=\"https://proxy.golang.org\" # note\n")) + if err != nil { + t.Fatal(err) + } + if got := values.Fields["GVM_GO_VERSION"]; got != "1.22.0" { + t.Fatalf("version = %q", got) + } + if len(values.Tools) != 2 || values.Tools[0] != "gopls@latest" || values.Tools[1] != "staticcheck@latest" { + t.Fatalf("tools = %#v", values.Tools) + } + if got := values.Fields["GOPROXY"]; got != "https://proxy.golang.org" { + t.Fatalf("GOPROXY = %q", got) + } +} + +func TestParseRejectsShellAndLegacyFormats(t *testing.T) { + for _, input := range []string{"1.22.0\n", "bad-key=value\n"} { + if _, err := Parse([]byte(input)); !errors.Is(err, ErrInvalid) { + t.Errorf("Parse(%q) error = %v, want ErrInvalid", input, err) + } + } + values, err := Parse([]byte("VALUE=$(whoami)\n")) + if err != nil || values.Fields["VALUE"] != "$(whoami)" { + t.Fatalf("shell syntax must remain literal: values=%#v err=%v", values, err) + } +} + +func TestResolveUsesNearestLocalAndProcessPrecedence(t *testing.T) { + home := t.TempDir() + project := filepath.Join(t.TempDir(), "project") + nested := filepath.Join(project, "nested") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".gvmrc"), []byte("GVM_GO_VERSION=1.21.0\nGOPROXY=global\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(project, ".gvmrc"), []byte("GVM_GO_VERSION=1.22.0\nGOPROXY=local\nGVM_TOOLS=tool@latest\n"), 0o600); err != nil { + t.Fatal(err) + } + resolved, err := Resolve(home, nested, []string{"GOPROXY=process"}) + if err != nil { + t.Fatal(err) + } + if resolved.GoVersion != "1.22.0" || resolved.LocalPath != filepath.Join(project, ".gvmrc") { + t.Fatalf("resolved = %#v", resolved) + } + if resolved.Env["GOPROXY"] != "process" || len(resolved.Tools) != 1 { + t.Fatalf("env/tools = %#v/%#v", resolved.Env, resolved.Tools) + } +} + +func TestWritePreservesEnvironment(t *testing.T) { + path := filepath.Join(t.TempDir(), ".gvmrc") + if err := os.WriteFile(path, []byte("GVM_GO_VERSION=1.21.0\nGOPROXY=private\nGVM_TOOLS=tool@latest\n"), 0o600); err != nil { + t.Fatal(err) + } + venv := true + if err := Write(path, "1.22.0", &venv, []string{"other@latest"}); err != nil { + t.Fatal(err) + } + values, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + if values.Fields["GOPROXY"] != "private" || values.Fields["GVM_GO_VERSION"] != "1.22.0" || values.Fields["GVM_VENV"] != "true" { + t.Fatalf("values = %#v", values) + } + if len(values.Tools) != 1 || values.Tools[0] != "other@latest" { + t.Fatalf("tools = %#v", values.Tools) + } +} diff --git a/internal/config/module.go b/internal/config/module.go new file mode 100644 index 0000000..b6afef2 --- /dev/null +++ b/internal/config/module.go @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package config + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// InferGoVersion finds a Go version from the nearest workspace or module file. +// Workspace files have precedence over module files, regardless of their +// relative directory depth. +func InferGoVersion(cwd string) (string, error) { + absoluteCWD, err := filepath.Abs(cwd) + if err != nil { + return "", fmt.Errorf("resolve module directory: %w", err) + } + + for _, name := range []string{"go.work", "go.mod"} { + path, err := findUpward(absoluteCWD, name) + if err != nil { + return "", err + } + if path == "" { + continue + } + version, found, err := parseGoDirective(path) + if err != nil { + return "", err + } + if found { + return version, nil + } + } + + return "", fmt.Errorf("%w: add a valid go directive to go.work or go.mod in %s", ErrNoVersion, absoluteCWD) +} + +func findUpward(cwd, name string) (string, error) { + path := cwd + for { + candidate := filepath.Join(path, name) + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("stat module file %q: %w", candidate, err) + } + + parent := filepath.Dir(path) + if parent == path { + return "", nil + } + path = parent + } +} + +func parseGoDirective(path string) (string, bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", false, fmt.Errorf("read module file %q: %w", path, err) + } + + scanner := bufio.NewScanner(strings.NewReader(string(data))) + scanner.Buffer(make([]byte, 1024), 1024*1024) + for lineNumber := 1; scanner.Scan(); lineNumber++ { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + if comment := strings.Index(line, "//"); comment >= 0 { + line = strings.TrimSpace(line[:comment]) + } + fields := strings.Fields(line) + if len(fields) == 0 || fields[0] != "go" { + continue + } + if len(fields) != 2 || !versionPattern.MatchString(fields[1]) { + return "", false, fmt.Errorf("%w: invalid go directive in %q at line %d", ErrInvalid, path, lineNumber) + } + return fields[1], true, nil + } + if err := scanner.Err(); err != nil { + return "", false, fmt.Errorf("scan module file %q: %w", path, err) + } + return "", false, nil +} diff --git a/internal/config/module_test.go b/internal/config/module_test.go new file mode 100644 index 0000000..12b0a86 --- /dev/null +++ b/internal/config/module_test.go @@ -0,0 +1,74 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestInferGoVersion(t *testing.T) { + tests := []struct { + name string + work string + mod string + want string + wantErr error + }{ + { + name: "workspace wins over module", + work: "go 1.23.0\n", + mod: "module example.com/project\ngo 1.22.0\n", + want: "1.23.0", + }, + { + name: "module fallback", + mod: "module example.com/project\ngo 1.22.0\n", + want: "1.22.0", + }, + { + name: "missing directive", + mod: "module example.com/project\n", + wantErr: ErrNoVersion, + }, + { + name: "invalid directive", + work: "go invalid\n", + wantErr: ErrInvalid, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + nested := filepath.Join(root, "nested") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + if tt.work != "" { + if err := os.WriteFile(filepath.Join(root, "go.work"), []byte(tt.work), 0o600); err != nil { + t.Fatal(err) + } + } + if tt.mod != "" { + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte(tt.mod), 0o600); err != nil { + t.Fatal(err) + } + } + + got, err := InferGoVersion(nested) + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("InferGoVersion() error = %v, want %v", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Fatalf("InferGoVersion() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/config/replace_unix.go b/internal/config/replace_unix.go new file mode 100644 index 0000000..3582374 --- /dev/null +++ b/internal/config/replace_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package config + +import "os" + +func replaceConfigFile(source, destination string) error { + return os.Rename(source, destination) +} diff --git a/internal/config/replace_windows.go b/internal/config/replace_windows.go new file mode 100644 index 0000000..6037e83 --- /dev/null +++ b/internal/config/replace_windows.go @@ -0,0 +1,40 @@ +//go:build windows + +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package config + +import ( + "syscall" + "unsafe" +) + +const ( + moveFileReplaceExisting = 0x1 + moveFileWriteThrough = 0x8 +) + +var moveFileEx = syscall.NewLazyDLL("kernel32.dll").NewProc("MoveFileExW") + +func replaceConfigFile(source, destination string) error { + from, err := syscall.UTF16PtrFromString(source) + if err != nil { + return err + } + to, err := syscall.UTF16PtrFromString(destination) + if err != nil { + return err + } + result, _, callErr := moveFileEx.Call( + uintptr(unsafe.Pointer(from)), + uintptr(unsafe.Pointer(to)), + moveFileReplaceExisting|moveFileWriteThrough, + ) + if result == 0 { + return callErr + } + return nil +} diff --git a/internal/env/env.go b/internal/env/env.go new file mode 100644 index 0000000..ac0c9ed --- /dev/null +++ b/internal/env/env.go @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package env + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/osspkg/gvm/internal/config" +) + +const ( + HomeKey = "GVM_HOME" + GoVersionKey = "GVM_GO_VERSION" + VenvKey = "GVM_VENV" + ToolsKey = "GVM_TOOLS" + RootKey = "GOROOT" + PathKey = "PATH" + GOPATHKey = "GOPATH" + GOBINKey = "GOBIN" + GOMODCACHEKey = "GOMODCACHE" +) + +// Result contains the process environment and managed paths for an active config. +type Result struct { + Values []string + GOROOT string + GOPATH string + GOBIN string + GOMODCACHE string + PATH string +} + +// Build derives the environment for an active configuration. +func Build(cfg config.Config, cwd string, base []string) (Result, error) { + if cfg.Home == "" || cfg.GoVersion == "" { + return Result{}, fmt.Errorf("build environment: missing GVM_HOME or Go version") + } + root := filepath.Join(cfg.Home, ".cache", "src", "go"+cfg.GoVersion) + gopath := filepath.Join(cfg.Home, ".cache") + gomodcache := filepath.Join(gopath, "pkg") + gobin := filepath.Join(gopath, "bin") + venvBin := "" + if cfg.Venv { + venvBin = filepath.Join(cwd, ".venv", "bin") + gobin = venvBin + } + + values := environMap(base) + for key, value := range cfg.Env { + values[key] = value + } + values[HomeKey] = cfg.Home + values[GoVersionKey] = cfg.GoVersion + values[VenvKey] = fmt.Sprintf("%t", cfg.Venv) + if len(cfg.Tools) != 0 { + values[ToolsKey] = strings.Join(cfg.Tools, "\n") + } else { + delete(values, ToolsKey) + } + values[RootKey] = root + values[GOPATHKey] = gopath + values[GOBINKey] = gobin + values[GOMODCACHEKey] = gomodcache + + basePath := values[PathKey] + pathParts := orderedPaths(venvBin, filepath.Join(gopath, "bin"), filepath.Join(cfg.Home, "bin"), basePath) + values[PathKey] = strings.Join(pathParts, string(os.PathListSeparator)) + + result := Result{ + GOROOT: root, + GOPATH: gopath, + GOBIN: gobin, + GOMODCACHE: gomodcache, + PATH: values[PathKey], + } + result.Values = make([]string, 0, len(values)) + for key, value := range values { + result.Values = append(result.Values, key+"="+value) + } + return result, nil +} + +func environMap(values []string) map[string]string { + result := make(map[string]string, len(values)) + for _, item := range values { + key, value, ok := strings.Cut(item, "=") + if ok { + result[key] = value + } + } + return result +} + +func orderedPaths(venvBin, cacheBin, gvmBin, original string) []string { + parts := make([]string, 0, 4) + seen := make(map[string]struct{}) + for _, group := range []string{venvBin, cacheBin, gvmBin, original} { + for _, part := range filepath.SplitList(group) { + if part == "" { + continue + } + key := filepath.Clean(part) + if abs, err := filepath.Abs(key); err == nil { + key = abs + } + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + parts = append(parts, part) + } + } + return parts +} diff --git a/internal/env/env_test.go b/internal/env/env_test.go new file mode 100644 index 0000000..7adb07a --- /dev/null +++ b/internal/env/env_test.go @@ -0,0 +1,31 @@ +package env + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/osspkg/gvm/internal/config" +) + +func TestBuildOrdersAndDeduplicatesPath(t *testing.T) { + home := t.TempDir() + cwd := t.TempDir() + cfg := config.Config{Home: home, GoVersion: "1.22.0", Venv: true, Env: map[string]string{"GOPROXY": "direct"}} + result, err := Build(cfg, cwd, []string{"PATH=" + filepath.Join(home, "bin") + string(os.PathListSeparator) + "/usr/bin"}) + if err != nil { + t.Fatal(err) + } + parts := strings.Split(result.PATH, string(os.PathListSeparator)) + wantFirst := filepath.Join(cwd, ".venv", "bin") + if len(parts) < 3 || parts[0] != wantFirst || parts[1] != filepath.Join(home, ".cache", "bin") || parts[2] != filepath.Join(home, "bin") { + t.Fatalf("PATH = %#v", parts) + } + if strings.Contains(result.PATH, string(os.PathListSeparator)+string(os.PathListSeparator)) { + t.Fatalf("PATH contains empty component: %q", result.PATH) + } + if result.GOBIN != wantFirst || result.GOPATH != filepath.Join(home, ".cache") { + t.Fatalf("managed paths = %#v", result) + } +} diff --git a/internal/env/gomodcache_test.go b/internal/env/gomodcache_test.go new file mode 100644 index 0000000..e279263 --- /dev/null +++ b/internal/env/gomodcache_test.go @@ -0,0 +1,26 @@ +package env + +import ( + "path/filepath" + "testing" + + "github.com/osspkg/gvm/internal/config" +) + +func TestBuildUsesDedicatedModuleCache(t *testing.T) { + home := t.TempDir() + result, err := Build(config.Config{Home: home, GoVersion: "1.22.0"}, t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(home, ".cache", "pkg") + if result.GOMODCACHE != want { + t.Fatalf("GOMODCACHE = %q, want %q", result.GOMODCACHE, want) + } + for _, value := range result.Values { + if value == GOMODCACHEKey+"="+want { + return + } + } + t.Fatalf("environment does not contain %s", GOMODCACHEKey) +} diff --git a/internal/progress/progress.go b/internal/progress/progress.go new file mode 100644 index 0000000..f5b2489 --- /dev/null +++ b/internal/progress/progress.go @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package progress + +import ( + "context" + "fmt" + "io" + "sync" +) + +// Reporter writes phase and byte progress without requiring terminal libraries. +type Reporter struct { + out io.Writer + interactive bool + mu sync.Mutex + lastBytes int64 +} + +func New(out io.Writer, interactive bool) *Reporter { + return &Reporter{out: out, interactive: interactive} +} + +func (r *Reporter) Phase(message string) { + r.mu.Lock() + defer r.mu.Unlock() + if r.interactive { + _, _ = fmt.Fprintf(r.out, "\r%s", message) + return + } + _, _ = fmt.Fprintln(r.out, message) +} + +func (r *Reporter) Copy(ctx context.Context, dst io.Writer, src io.Reader, name string, total int64) (int64, error) { + r.lastBytes = 0 + buffer := make([]byte, 32*1024) + var copied int64 + for { + if err := ctx.Err(); err != nil { + return copied, err + } + read, readErr := src.Read(buffer) + if read > 0 { + written, writeErr := dst.Write(buffer[:read]) + copied += int64(written) + if writeErr != nil { + return copied, writeErr + } + if written != read { + return copied, io.ErrShortWrite + } + if copied-r.lastBytes >= 256*1024 || (total > 0 && copied == total) { + r.update(name, copied, total) + r.lastBytes = copied + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + return copied, readErr + } + } + r.update(name, copied, total) + if r.interactive { + r.mu.Lock() + _, _ = fmt.Fprintln(r.out) + r.mu.Unlock() + } + return copied, nil +} + +func (r *Reporter) update(name string, copied, total int64) { + r.mu.Lock() + defer r.mu.Unlock() + if total > 0 { + percent := copied * 100 / total + if r.interactive { + _, _ = fmt.Fprintf(r.out, "\r%s: %d%% (%d/%d bytes)", name, percent, copied, total) + return + } + _, _ = fmt.Fprintf(r.out, "%s: %d%% (%d/%d bytes)\n", name, percent, copied, total) + return + } + if r.interactive { + _, _ = fmt.Fprintf(r.out, "\r%s: %d bytes", name, copied) + return + } + _, _ = fmt.Fprintf(r.out, "%s: %d bytes\n", name, copied) +} diff --git a/internal/progress/progress_test.go b/internal/progress/progress_test.go new file mode 100644 index 0000000..8744ce8 --- /dev/null +++ b/internal/progress/progress_test.go @@ -0,0 +1,28 @@ +package progress + +import ( + "bytes" + "context" + "strings" + "testing" +) + +func TestCopyNonInteractive(t *testing.T) { + var output bytes.Buffer + var destination bytes.Buffer + reporter := New(&output, false) + if _, err := reporter.Copy(context.Background(), &destination, strings.NewReader("payload"), "download", 7); err != nil { + t.Fatal(err) + } + if destination.String() != "payload" || !strings.Contains(output.String(), "download: 100%") { + t.Fatalf("destination=%q output=%q", destination.String(), output.String()) + } +} + +func TestCopyCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := New(&bytes.Buffer{}, false).Copy(ctx, &bytes.Buffer{}, strings.NewReader("payload"), "download", 7); err == nil { + t.Fatal("Copy returned nil error for canceled context") + } +} diff --git a/internal/release/archive.go b/internal/release/archive.go new file mode 100644 index 0000000..0bd0365 --- /dev/null +++ b/internal/release/archive.go @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package release + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" +) + +// ExtractManagerArchive extracts only the gvm and go binaries from a verified release archive. +func ExtractManagerArchive(archivePath, destination string) error { + if err := os.MkdirAll(destination, 0o755); err != nil { + return fmt.Errorf("create release staging directory: %w", err) + } + if strings.HasSuffix(archivePath, ".zip") { + return extractZip(archivePath, destination) + } + return extractTarGz(archivePath, destination) +} + +func extractTarGz(path, destination string) (err error) { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open release archive: %w", err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close release archive: %w", closeErr) + } + }() + + reader, err := gzip.NewReader(file) + if err != nil { + return fmt.Errorf("read release archive gzip: %w", err) + } + defer func() { + if closeErr := reader.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close release archive gzip: %w", closeErr) + } + }() + + tarReader := tar.NewReader(reader) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("read release archive entry: %w", err) + } + if header.Typeflag != tar.TypeReg { + continue + } + name, ok := managerName(header.Name) + if !ok { + continue + } + if err := writeManagerFile(filepath.Join(destination, name), tarReader, header.Mode); err != nil { + return err + } + } + return validateManagerFiles(destination) +} + +func extractZip(path, destination string) (err error) { + archive, err := zip.OpenReader(path) + if err != nil { + return fmt.Errorf("open release zip archive: %w", err) + } + defer func() { + if closeErr := archive.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close release zip archive: %w", closeErr) + } + }() + + for _, entry := range archive.File { + if entry.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("symbolic link in release archive: %s", entry.Name) + } + name, ok := managerName(entry.Name) + if !ok || entry.FileInfo().IsDir() { + continue + } + file, err := entry.Open() + if err != nil { + return fmt.Errorf("open release entry: %w", err) + } + err = writeManagerFile(filepath.Join(destination, name), file, int64(entry.Mode().Perm())) + closeErr := file.Close() + if err == nil { + err = closeErr + } + if err != nil { + return err + } + } + return validateManagerFiles(destination) +} + +func managerName(name string) (string, bool) { + // Release archives contain the manager binaries at their root. Reject every + // other path, including traversal paths, rather than reducing it to a base + // name and accidentally accepting ../../gvm. + name = strings.TrimPrefix(strings.ReplaceAll(name, "\\", "/"), "./") + if name == "" || name == "." || name == ".." || strings.Contains(name, "/") { + return "", false + } + switch name { + case "gvm", "go", "gvm.exe", "go.exe": + return name, true + default: + return "", false + } +} + +func writeManagerFile(path string, source io.Reader, mode int64) error { + permissions := os.FileMode(mode) & 0o777 + if permissions == 0 { + permissions = 0o755 + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, permissions) + if err != nil { + return fmt.Errorf("create staged binary: %w", err) + } + if _, err := io.Copy(file, source); err != nil { + _ = file.Close() + return fmt.Errorf("extract staged binary: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close staged binary: %w", err) + } + return nil +} + +func validateManagerFiles(destination string) error { + names := []string{"gvm", "go"} + if runtime.GOOS == "windows" { + names = []string{"gvm.exe", "go.exe"} + } + for _, name := range names { + info, err := os.Stat(filepath.Join(destination, name)) + if err != nil || info.IsDir() { + return fmt.Errorf("release archive is missing %s", name) + } + } + return nil +} diff --git a/internal/release/archive_test.go b/internal/release/archive_test.go new file mode 100644 index 0000000..1e1399f --- /dev/null +++ b/internal/release/archive_test.go @@ -0,0 +1,88 @@ +package release + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "io" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestManagerNameRejectsNestedAndTraversalPaths(t *testing.T) { + for _, name := range []string{"../../gvm", "nested/gvm", `/tmp/gvm`, `..\..\gvm`} { + if got, ok := managerName(name); ok || got != "" { + t.Errorf("managerName(%q) = %q, %t; want rejection", name, got, ok) + } + } + if got, ok := managerName("./gvm"); !ok || got != "gvm" { + t.Fatalf("managerName(./gvm) = %q, %t; want gvm", got, ok) + } +} + +func TestExtractManagerArchiveRequiresPlatformBinaries(t *testing.T) { + archivePath := filepath.Join(t.TempDir(), "release.tar.gz") + if err := os.WriteFile(archivePath, managerArchive(t, map[string]string{managerBinaryName("gvm"): "gvm"}), 0o600); err != nil { + t.Fatal(err) + } + if err := ExtractManagerArchive(archivePath, t.TempDir()); err == nil { + t.Fatal("ExtractManagerArchive accepted an archive missing the go binary") + } +} + +func TestExtractManagerArchiveIgnoresTraversalEntry(t *testing.T) { + dir := t.TempDir() + archivePath := filepath.Join(dir, "release.tar.gz") + entries := map[string]string{ + managerBinaryName("gvm"): "gvm", + managerBinaryName("go"): "go", + "../../outside": "must not escape", + } + if err := os.WriteFile(archivePath, managerArchive(t, entries), 0o600); err != nil { + t.Fatal(err) + } + staging := filepath.Join(dir, "staging") + if err := ExtractManagerArchive(archivePath, staging); err != nil { + t.Fatal(err) + } + for _, name := range []string{managerBinaryName("gvm"), managerBinaryName("go")} { + if _, err := os.Stat(filepath.Join(staging, name)); err != nil { + t.Fatalf("staged %s: %v", name, err) + } + } + if _, err := os.Stat(filepath.Join(dir, "outside")); !os.IsNotExist(err) { + t.Fatalf("traversal entry escaped staging: err=%v", err) + } +} + +func managerBinaryName(base string) string { + if runtime.GOOS == "windows" { + return base + ".exe" + } + return base +} + +func managerArchive(t *testing.T, entries map[string]string) []byte { + t.Helper() + var output bytes.Buffer + gzipWriter := gzip.NewWriter(&output) + tarWriter := tar.NewWriter(gzipWriter) + for name, data := range entries { + header := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(data))} + if err := tarWriter.WriteHeader(header); err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(tarWriter, data); err != nil { + t.Fatal(err) + } + } + if err := tarWriter.Close(); err != nil { + t.Fatal(err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatal(err) + } + return output.Bytes() +} diff --git a/internal/release/client_test.go b/internal/release/client_test.go new file mode 100644 index 0000000..36e1b4e --- /dev/null +++ b/internal/release/client_test.go @@ -0,0 +1,75 @@ +package release + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/osspkg/gvm/internal/progress" +) + +func TestLatestAndDownloadUseReleaseAssets(t *testing.T) { + payload := []byte("release archive") + hash := sha256.Sum256(payload) + archiveName := AssetName("v1.2.3", runtime.GOOS, runtime.GOARCH) + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/repos/osspkg/gvm/releases/latest": + _ = json.NewEncoder(writer).Encode(Release{ + TagName: "v1.2.3", + Assets: []Asset{ + {Name: archiveName, BrowserDownloadURL: server.URL + "/archive"}, + {Name: "checksums.txt", BrowserDownloadURL: server.URL + "/checksums"}, + }, + }) + case "/archive": + _, _ = writer.Write(payload) + case "/checksums": + _, _ = writer.Write([]byte(hex.EncodeToString(hash[:]) + " " + archiveName + "\n")) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + client := NewClient(server.Client(), progress.New(&bytes.Buffer{}, false)) + client.APIBase = server.URL + latest, err := client.Latest(context.Background()) + if err != nil { + t.Fatal(err) + } + archive, err := CurrentAsset(latest) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), archive.Name) + if err := client.Download(context.Background(), archive, path); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != string(payload) { + t.Fatalf("downloaded payload = %q", data) + } +} + +func TestVerifyChecksumRejectsMismatch(t *testing.T) { + path := filepath.Join(t.TempDir(), "asset") + if err := os.WriteFile(path, []byte("actual"), 0o600); err != nil { + t.Fatal(err) + } + if err := VerifyChecksum(path, "not-the-checksum"); err == nil { + t.Fatal("VerifyChecksum accepted a mismatch") + } +} diff --git a/internal/release/release.go b/internal/release/release.go new file mode 100644 index 0000000..aa3b20b --- /dev/null +++ b/internal/release/release.go @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package release + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/osspkg/gvm/internal/progress" +) + +const ( + DefaultAPIBase = "https://api.github.com" + Repository = "osspkg/gvm" +) + +type Client struct { + HTTP *http.Client + APIBase string + Repository string + Progress *progress.Reporter +} + +type Release struct { + TagName string `json:"tag_name"` + Assets []Asset `json:"assets"` +} + +type Asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` +} + +func NewClient(client *http.Client, reporter *progress.Reporter) *Client { + if client == nil { + client = &http.Client{} + } + return &Client{HTTP: client, APIBase: DefaultAPIBase, Repository: Repository, Progress: reporter} +} + +func (c *Client) Latest(ctx context.Context) (result Release, err error) { + url := strings.TrimRight(c.APIBase, "/") + "/repos/" + c.Repository + "/releases/latest" + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return Release{}, fmt.Errorf("create GitHub release request: %w", err) + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("User-Agent", "gvm") + response, err := c.HTTP.Do(request) + if err != nil { + return Release{}, fmt.Errorf("fetch GitHub release: %w", err) + } + defer func() { + if closeErr := response.Body.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close GitHub release response: %w", closeErr) + } + }() + if response.StatusCode != http.StatusOK { + return Release{}, fmt.Errorf("fetch GitHub release: unexpected status %s", response.Status) + } + var release Release + if err := json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&release); err != nil { + return Release{}, fmt.Errorf("decode GitHub release: %w", err) + } + if release.TagName == "" { + return Release{}, fmt.Errorf("GitHub release has no tag") + } + return release, nil +} + +func AssetName(tag, goos, goarch string) string { + version := strings.TrimPrefix(tag, "v") + if goos == "windows" { + return fmt.Sprintf("gvm_%s_%s_%s.zip", version, goos, goarch) + } + return fmt.Sprintf("gvm_%s_%s_%s.tar.gz", version, goos, goarch) +} + +func CurrentAsset(release Release) (Asset, error) { + name := AssetName(release.TagName, runtime.GOOS, runtime.GOARCH) + for _, asset := range release.Assets { + if asset.Name == name { + return asset, nil + } + } + return Asset{}, fmt.Errorf("release %s has no asset %s", release.TagName, name) +} + +func ChecksumAsset(release Release) (Asset, error) { + for _, asset := range release.Assets { + if asset.Name == "checksums.txt" { + return asset, nil + } + } + return Asset{}, fmt.Errorf("release %s has no checksums.txt", release.TagName) +} + +func (c *Client) Download(ctx context.Context, asset Asset, destination string) (err error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.BrowserDownloadURL, nil) + if err != nil { + return fmt.Errorf("create release asset request: %w", err) + } + request.Header.Set("User-Agent", "gvm") + response, err := c.HTTP.Do(request) + if err != nil { + return fmt.Errorf("download release asset: %w", err) + } + defer func() { + if closeErr := response.Body.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close release asset response: %w", closeErr) + } + }() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("download release asset: unexpected status %s", response.Status) + } + file, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("create release asset: %w", err) + } + var copyErr error + if c.Progress != nil { + _, copyErr = c.Progress.Copy(ctx, file, response.Body, asset.Name, response.ContentLength) + } else { + _, copyErr = io.Copy(file, response.Body) + } + closeErr := file.Close() + if copyErr == nil { + copyErr = closeErr + } + if copyErr != nil { + return fmt.Errorf("write release asset: %w", copyErr) + } + return nil +} + +func VerifyChecksum(path, expected string) (err error) { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open release asset for checksum: %w", err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close release asset: %w", closeErr) + } + }() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return fmt.Errorf("hash release asset: %w", err) + } + actual := hex.EncodeToString(hash.Sum(nil)) + if !strings.EqualFold(actual, strings.TrimSpace(expected)) { + return fmt.Errorf("release asset checksum mismatch: got %s, want %s", actual, expected) + } + return nil +} + +func ParseChecksums(data []byte) map[string]string { + result := make(map[string]string) + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && len(fields[0]) == sha256.Size*2 { + result[fields[len(fields)-1]] = fields[0] + } + } + return result +} + +func DownloadPath(home string) (string, error) { + path := filepath.Join(home, ".cache", "gvm-update") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", fmt.Errorf("create update directory: %w", err) + } + return path, nil +} diff --git a/internal/release/release_test.go b/internal/release/release_test.go new file mode 100644 index 0000000..0cc6d69 --- /dev/null +++ b/internal/release/release_test.go @@ -0,0 +1,24 @@ +package release + +import ( + "crypto/sha256" + "encoding/hex" + "testing" +) + +func TestAssetName(t *testing.T) { + if got := AssetName("v0.2.0", "linux", "amd64"); got != "gvm_0.2.0_linux_amd64.tar.gz" { + t.Fatalf("asset name = %q", got) + } + if got := AssetName("v0.2.0", "windows", "arm64"); got != "gvm_0.2.0_windows_arm64.zip" { + t.Fatalf("windows asset name = %q", got) + } +} + +func TestParseChecksums(t *testing.T) { + hash := sha256.Sum256([]byte("payload")) + checksums := ParseChecksums([]byte(hex.EncodeToString(hash[:]) + " archive.tar.gz\ninvalid\n")) + if checksums["archive.tar.gz"] != hex.EncodeToString(hash[:]) { + t.Fatalf("checksums = %#v", checksums) + } +} diff --git a/internal/runner/path_test.go b/internal/runner/path_test.go new file mode 100644 index 0000000..a2a8d9e --- /dev/null +++ b/internal/runner/path_test.go @@ -0,0 +1,27 @@ +package runner + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestFindAcceptsSlashSeparatedExplicitPath(t *testing.T) { + dir := t.TempDir() + name := "tool" + if runtime.GOOS == "windows" { + name += ".exe" + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("tool"), 0o755); err != nil { + t.Fatal(err) + } + got, err := Find(filepath.ToSlash(filepath.Join(dir, "tool")), nil) + if err != nil { + t.Fatal(err) + } + if got != path { + t.Fatalf("Find returned %q, want %q", got, path) + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go new file mode 100644 index 0000000..7f93719 --- /dev/null +++ b/internal/runner/runner.go @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package runner + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +func Find(name string, directories []string) (string, error) { + if name == "" { + return "", errors.New("binary name is empty") + } + if filepath.IsAbs(name) || strings.ContainsAny(name, `/\`) { + candidate := withPlatformExtension(filepath.FromSlash(name)) + if executable(candidate) { + return candidate, nil + } + return "", fmt.Errorf("binary %q was not found", name) + } + for _, directory := range directories { + if directory == "" { + continue + } + candidate := withPlatformExtension(filepath.Join(directory, name)) + if executable(candidate) { + return candidate, nil + } + } + return "", fmt.Errorf("binary %q was not found", name) +} + +func Executable(directory, name string) (string, error) { + return Find(name, []string{directory}) +} + +func Run(ctx context.Context, path, dir string, args []string, environment []string, stdout, stderr io.Writer) error { + command := exec.CommandContext(ctx, path, args...) + command.Dir = dir + command.Env = environment + command.Stdout = stdout + command.Stderr = stderr + if err := command.Run(); err != nil { + return fmt.Errorf("run %q: %w", path, err) + } + return nil +} + +func withPlatformExtension(path string) string { + if runtime.GOOS == "windows" && filepath.Ext(path) == "" { + return path + ".exe" + } + return path +} + +func executable(path string) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false + } + if runtime.GOOS == "windows" { + return true + } + return info.Mode()&0o111 != 0 +} diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go new file mode 100644 index 0000000..b3ef8be --- /dev/null +++ b/internal/runner/runner_test.go @@ -0,0 +1,56 @@ +package runner + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestFindUsesDirectoryOrder(t *testing.T) { + first := t.TempDir() + second := t.TempDir() + name := "tool" + if runtime.GOOS == "windows" { + name += ".exe" + } + path := filepath.Join(second, name) + if err := os.WriteFile(path, []byte("executable"), 0o755); err != nil { + t.Fatal(err) + } + got, err := Find("tool", []string{first, second}) + if err != nil { + t.Fatal(err) + } + if got != path { + t.Fatalf("Find returned %q, want %q", got, path) + } +} + +func TestFindSkipsNonExecutableFilesOnUnix(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not use executable permission bits") + } + first := t.TempDir() + second := t.TempDir() + if err := os.WriteFile(filepath.Join(first, "tool"), []byte("not executable"), 0o644); err != nil { + t.Fatal(err) + } + path := filepath.Join(second, "tool") + if err := os.WriteFile(path, []byte("executable"), 0o755); err != nil { + t.Fatal(err) + } + got, err := Find("tool", []string{first, second}) + if err != nil { + t.Fatal(err) + } + if got != path { + t.Fatalf("Find returned %q, want %q", got, path) + } +} + +func TestFindRejectsMissingBinary(t *testing.T) { + if _, err := Find("missing", []string{t.TempDir()}); err == nil { + t.Fatal("Find succeeded for a missing binary") + } +} diff --git a/internal/sdk/manage.go b/internal/sdk/manage.go new file mode 100644 index 0000000..3f98f04 --- /dev/null +++ b/internal/sdk/manage.go @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package sdk + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +var installedVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+(?:\.[0-9]+)?(?:[A-Za-z][A-Za-z0-9.-]*)?$`) + +// InstalledVersions returns complete SDK versions present in the GVM cache. +// Incomplete temporary directories and unrelated cache entries are ignored. +func (s *Store) InstalledVersions() ([]string, error) { + if s.Home == "" { + return nil, errors.New("sdk: GVM_HOME is empty") + } + root := filepath.Join(s.CacheRoot(), "src") + entries, err := os.ReadDir(root) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read SDK cache: %w", err) + } + versions := make([]string, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "go") { + continue + } + version := strings.TrimPrefix(entry.Name(), "go") + if !installedVersionPattern.MatchString(version) || !hasSDK(filepath.Join(root, entry.Name())) { + continue + } + versions = append(versions, version) + } + sort.Strings(versions) + return versions, nil +} + +// Remove deletes one SDK version from the GVM cache. +// The version must match the supported Go version format; arbitrary paths are rejected. +func (s *Store) Remove(version string) error { + if s.Home == "" { + return errors.New("sdk: GVM_HOME is empty") + } + if !installedVersionPattern.MatchString(version) { + return fmt.Errorf("sdk: invalid Go version %q", version) + } + root := filepath.Join(s.CacheRoot(), "src") + target := s.SDKPath(version) + relative, err := filepath.Rel(root, target) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return fmt.Errorf("sdk: refusing to remove path for Go %q", version) + } + info, err := os.Lstat(target) + if os.IsNotExist(err) { + return fmt.Errorf("sdk %s is not installed", version) + } + if err != nil { + return fmt.Errorf("inspect SDK %s: %w", version, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("sdk %s is a symbolic link", version) + } + if !info.IsDir() { + return fmt.Errorf("sdk %s is not a directory", version) + } + if err := os.RemoveAll(target); err != nil { + return fmt.Errorf("remove SDK %s: %w", version, err) + } + return nil +} diff --git a/internal/sdk/manage_test.go b/internal/sdk/manage_test.go new file mode 100644 index 0000000..93ba591 --- /dev/null +++ b/internal/sdk/manage_test.go @@ -0,0 +1,77 @@ +package sdk + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestInstalledVersionsListsOnlyCompleteSDKs(t *testing.T) { + home := t.TempDir() + store := NewStore(home, nil, nil) + for _, version := range []string{"1.10.0", "1.22.0"} { + path := filepath.Join(store.SDKPath(version), "bin", sdkBinaryName()) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("go"), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(filepath.Join(store.CacheRoot(), "src", "go1.23.0", "bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(store.CacheRoot(), "src", ".sdk-temp"), 0o755); err != nil { + t.Fatal(err) + } + + versions, err := store.InstalledVersions() + if err != nil { + t.Fatal(err) + } + want := []string{"1.10.0", "1.22.0"} + if !reflect.DeepEqual(versions, want) { + t.Fatalf("installed versions = %#v, want %#v", versions, want) + } +} + +func TestRemoveDeletesOnlyRequestedSDK(t *testing.T) { + home := t.TempDir() + store := NewStore(home, nil, nil) + for _, version := range []string{"1.21.0", "1.22.0"} { + path := filepath.Join(store.SDKPath(version), "bin", sdkBinaryName()) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("go"), 0o755); err != nil { + t.Fatal(err) + } + } + if err := store.Remove("1.21.0"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(store.SDKPath("1.21.0")); !os.IsNotExist(err) { + t.Fatalf("removed SDK still exists: %v", err) + } + if _, err := os.Stat(store.SDKPath("1.22.0")); err != nil { + t.Fatalf("other SDK was changed: %v", err) + } +} + +func TestRemoveRejectsPathLikeVersions(t *testing.T) { + home := t.TempDir() + store := NewStore(home, nil, nil) + outside := filepath.Join(home, ".cache", "outside") + if err := os.MkdirAll(outside, 0o755); err != nil { + t.Fatal(err) + } + for _, version := range []string{"", "../outside", "1.22/../../outside", "go1.22.0"} { + if err := store.Remove(version); err == nil { + t.Errorf("Remove(%q) succeeded for invalid version", version) + } + } + if _, err := os.Stat(outside); err != nil { + t.Fatalf("invalid removal touched outside path: %v", err) + } +} diff --git a/internal/sdk/sdk.go b/internal/sdk/sdk.go new file mode 100644 index 0000000..ca0b107 --- /dev/null +++ b/internal/sdk/sdk.go @@ -0,0 +1,392 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package sdk + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/osspkg/gvm/internal/progress" +) + +const defaultMetadataURL = "https://go.dev/dl/?mode=json&include=all" + +type Release struct { + Version string `json:"version"` + Files []File `json:"files"` +} + +type File struct { + Filename string `json:"filename"` + OS string `json:"os"` + Arch string `json:"arch"` + Kind string `json:"kind"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` +} + +type Store struct { + Home string + Client *http.Client + MetadataURL string + DownloadBaseURL string + Progress *progress.Reporter +} + +func NewStore(home string, client *http.Client, reporter *progress.Reporter) *Store { + if client == nil { + client = &http.Client{} + } + return &Store{ + Home: home, + Client: client, + MetadataURL: defaultMetadataURL, + DownloadBaseURL: "https://go.dev/dl/", + Progress: reporter, + } +} + +func (s *Store) CacheRoot() string { + return filepath.Join(s.Home, ".cache") +} + +func (s *Store) SDKPath(version string) string { + return filepath.Join(s.CacheRoot(), "src", "go"+version) +} + +func (s *Store) Ensure(ctx context.Context, version string) (string, error) { + if s.Home == "" { + return "", errors.New("sdk store: GVM_HOME is empty") + } + if err := ensureDirs(s.Home); err != nil { + return "", err + } + finalPath := s.SDKPath(version) + if hasSDK(finalPath) { + return finalPath, nil + } + if !supportedPlatform(runtime.GOOS, runtime.GOARCH) { + return "", fmt.Errorf("sdk: unsupported platform %s/%s", runtime.GOOS, runtime.GOARCH) + } + + metadata, err := s.fetchMetadata(ctx) + if err != nil { + return "", err + } + archive, err := selectArchive(metadata, version, runtime.GOOS, runtime.GOARCH) + if err != nil { + return "", err + } + if s.Progress != nil { + s.Progress.Phase("downloading Go " + version) + } + archivePath, err := s.download(ctx, archive) + if err != nil { + return "", err + } + defer func() { _ = os.Remove(archivePath) }() + if err := verifySHA256(archivePath, archive.SHA256); err != nil { + return "", err + } + + srcRoot := filepath.Join(s.CacheRoot(), "src") + tempRoot, err := os.MkdirTemp(srcRoot, ".sdk-") + if err != nil { + return "", fmt.Errorf("create SDK temp directory: %w", err) + } + defer func() { _ = os.RemoveAll(tempRoot) }() + if s.Progress != nil { + s.Progress.Phase("extracting Go " + version) + } + if err := extractArchive(archivePath, tempRoot, archive.Filename); err != nil { + return "", err + } + if !hasSDK(filepath.Join(tempRoot, "go")) { + return "", fmt.Errorf("SDK archive does not contain %s", filepath.Join("go", "bin", sdkBinaryName())) + } + if err := os.RemoveAll(finalPath); err != nil { + return "", fmt.Errorf("remove incomplete SDK: %w", err) + } + if err := os.Rename(filepath.Join(tempRoot, "go"), finalPath); err != nil { + return "", fmt.Errorf("publish SDK: %w", err) + } + return finalPath, nil +} + +func ensureDirs(home string) error { + for _, path := range []string{ + filepath.Join(home, ".cache", "bin"), + filepath.Join(home, ".cache", "pkg"), + filepath.Join(home, ".cache", "src"), + } { + if err := os.MkdirAll(path, 0o755); err != nil { + return fmt.Errorf("create cache directory %q: %w", path, err) + } + } + return nil +} + +func sdkBinaryName() string { + if runtime.GOOS == "windows" { + return "go.exe" + } + return "go" +} + +func hasSDK(path string) bool { + info, err := os.Stat(filepath.Join(path, "bin", sdkBinaryName())) + return err == nil && !info.IsDir() +} + +func (s *Store) fetchMetadata(ctx context.Context) (releases []Release, err error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, s.MetadataURL, nil) + if err != nil { + return nil, fmt.Errorf("create Go metadata request: %w", err) + } + response, err := s.Client.Do(request) + if err != nil { + return nil, fmt.Errorf("fetch Go metadata: %w", err) + } + defer func() { + if closeErr := response.Body.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close Go metadata response: %w", closeErr) + } + }() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch Go metadata: unexpected status %s", response.Status) + } + var result []Release + if err := json.NewDecoder(io.LimitReader(response.Body, 16<<20)).Decode(&result); err != nil { + return nil, fmt.Errorf("decode Go metadata: %w", err) + } + return result, nil +} + +func selectArchive(releases []Release, version, goos, goarch string) (File, error) { + wanted := "go" + version + for _, release := range releases { + if release.Version != wanted && release.Version != version { + continue + } + for _, file := range release.Files { + if file.OS == goos && file.Arch == goarch && file.Kind == "archive" && file.SHA256 != "" { + return file, nil + } + } + return File{}, fmt.Errorf("sdk archive not found for %s/%s Go %s", goos, goarch, version) + } + return File{}, fmt.Errorf("go version %s is not available", version) +} + +func supportedPlatform(goos, goarch string) bool { + if goos != "linux" && goos != "darwin" && goos != "windows" { + return false + } + return goarch == "amd64" || goarch == "arm64" +} + +func (s *Store) download(ctx context.Context, archive File) (name string, err error) { + url := strings.TrimRight(s.DownloadBaseURL, "/") + "/" + archive.Filename + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("create Go archive request: %w", err) + } + response, err := s.Client.Do(request) + if err != nil { + return "", fmt.Errorf("download Go archive: %w", err) + } + defer func() { + if closeErr := response.Body.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close Go archive response: %w", closeErr) + } + }() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("download Go archive: unexpected status %s", response.Status) + } + temp, err := os.CreateTemp(s.CacheRoot(), ".go-archive-*") + if err != nil { + return "", fmt.Errorf("create archive temp file: %w", err) + } + name = temp.Name() + var copyErr error + if s.Progress != nil { + _, copyErr = s.Progress.Copy(ctx, temp, response.Body, archive.Filename, response.ContentLength) + } else { + _, copyErr = io.Copy(temp, response.Body) + } + if closeErr := temp.Close(); copyErr == nil { + copyErr = closeErr + } + if copyErr != nil { + _ = os.Remove(name) + return "", fmt.Errorf("write Go archive: %w", copyErr) + } + return name, nil +} + +func verifySHA256(path, expected string) (err error) { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open archive for checksum: %w", err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close archive: %w", closeErr) + } + }() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return fmt.Errorf("hash archive: %w", err) + } + actual := hex.EncodeToString(hash.Sum(nil)) + if !strings.EqualFold(actual, expected) { + return fmt.Errorf("go archive checksum mismatch: got %s, want %s", actual, expected) + } + return nil +} + +func extractArchive(path, destination, filename string) error { + if strings.HasSuffix(filename, ".zip") { + return extractZip(path, destination) + } + return extractTarGz(path, destination) +} + +func extractTarGz(path, destination string) (err error) { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open Go archive: %w", err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close Go archive: %w", closeErr) + } + }() + reader, err := gzip.NewReader(file) + if err != nil { + return fmt.Errorf("read Go archive gzip: %w", err) + } + defer func() { + if closeErr := reader.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close Go archive gzip: %w", closeErr) + } + }() + tarReader := tar.NewReader(reader) + for { + header, err := tarReader.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return fmt.Errorf("read Go archive entry: %w", err) + } + if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeDir { + return fmt.Errorf("unsupported Go archive entry type %q", header.Name) + } + target, err := safeTarget(destination, header.Name) + if err != nil { + return err + } + if header.Typeflag == tar.TypeDir { + if err := os.MkdirAll(target, 0o755); err != nil { + return fmt.Errorf("create archive directory: %w", err) + } + continue + } + if err := writeEntry(target, tarReader, header.Mode); err != nil { + return err + } + } +} + +func extractZip(path, destination string) (err error) { + archive, err := zip.OpenReader(path) + if err != nil { + return fmt.Errorf("open Go zip archive: %w", err) + } + defer func() { + if closeErr := archive.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close Go zip archive: %w", closeErr) + } + }() + for _, entry := range archive.File { + if entry.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("unsupported symbolic link in Go archive: %s", entry.Name) + } + target, err := safeTarget(destination, entry.Name) + if err != nil { + return err + } + if entry.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return fmt.Errorf("create zip directory: %w", err) + } + continue + } + file, err := entry.Open() + if err != nil { + return fmt.Errorf("open zip entry: %w", err) + } + err = writeEntry(target, file, int64(entry.Mode().Perm())) + closeErr := file.Close() + if err == nil { + err = closeErr + } + if err != nil { + return err + } + } + return nil +} + +func writeEntry(target string, source io.Reader, mode int64) error { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("create archive parent: %w", err) + } + permissions := os.FileMode(mode) & 0o777 + if permissions == 0 { + permissions = 0o644 + } + file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, permissions) + if err != nil { + return fmt.Errorf("create archive file: %w", err) + } + if _, err := io.Copy(file, source); err != nil { + _ = file.Close() + return fmt.Errorf("extract archive file: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close archive file: %w", err) + } + return nil +} + +func safeTarget(destination, name string) (string, error) { + name = strings.ReplaceAll(name, "\\", "/") + clean := filepath.Clean(filepath.FromSlash(name)) + if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("unsafe Go archive path %q", name) + } + target := filepath.Join(destination, clean) + rel, err := filepath.Rel(destination, target) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("unsafe Go archive path %q", name) + } + return target, nil +} diff --git a/internal/sdk/sdk_test.go b/internal/sdk/sdk_test.go new file mode 100644 index 0000000..a6e65ff --- /dev/null +++ b/internal/sdk/sdk_test.go @@ -0,0 +1,90 @@ +package sdk + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/osspkg/gvm/internal/progress" +) + +func TestEnsureDownloadsVerifiesAndPublishesSDK(t *testing.T) { + archiveData := testArchive(t) + hash := sha256.Sum256(archiveData) + filename := "go1.22.0." + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/metadata": + _ = json.NewEncoder(writer).Encode([]Release{{Version: "go1.22.0", Files: []File{{Filename: filename, OS: runtime.GOOS, Arch: runtime.GOARCH, Kind: "archive", SHA256: hex.EncodeToString(hash[:])}}}}) + case "/" + filename: + _, _ = writer.Write(archiveData) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + home := t.TempDir() + store := NewStore(home, server.Client(), progress.New(&bytes.Buffer{}, false)) + store.MetadataURL = server.URL + "/metadata" + store.DownloadBaseURL = server.URL + path, err := store.Ensure(context.Background(), "1.22.0") + if err != nil { + t.Fatal(err) + } + if path != filepath.Join(home, ".cache", "src", "go1.22.0") || !hasSDK(path) { + t.Fatalf("published path = %q", path) + } + data, err := os.ReadFile(filepath.Join(path, "bin", "go")) + if err != nil { + t.Fatal(err) + } + if string(data) != "fake go" { + t.Fatalf("SDK binary = %q", data) + } +} + +func TestSafeTargetRejectsTraversal(t *testing.T) { + if _, err := safeTarget(t.TempDir(), "go/../../escape"); err == nil { + t.Fatal("safeTarget accepted traversal") + } +} + +func testArchive(t *testing.T) []byte { + t.Helper() + var output bytes.Buffer + zipper := gzip.NewWriter(&output) + writer := tar.NewWriter(zipper) + entries := []struct { + name string + data string + mode int64 + }{{name: "go/bin/go", data: "fake go", mode: 0o755}} + for _, entry := range entries { + header := &tar.Header{Name: entry.name, Mode: entry.mode, Size: int64(len(entry.data))} + if err := writer.WriteHeader(header); err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(writer, entry.data); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := zipper.Close(); err != nil { + t.Fatal(err) + } + return output.Bytes() +} diff --git a/internal/venv/venv.go b/internal/venv/venv.go new file mode 100644 index 0000000..7450b1d --- /dev/null +++ b/internal/venv/venv.go @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package venv + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/osspkg/gvm/internal/runner" +) + +func BinDir(cwd string) string { + return filepath.Join(cwd, ".venv", "bin") +} + +func Ensure(cwd string) (string, error) { + bin := BinDir(cwd) + if err := os.MkdirAll(bin, 0o755); err != nil { + return "", fmt.Errorf("create virtual environment: %w", err) + } + if err := ensureGitignore(filepath.Join(cwd, ".gitignore")); err != nil { + return "", err + } + return bin, nil +} + +func EnsureTools(ctx context.Context, cwd, sdkRoot string, tools []string, environment []string, stdout, stderr io.Writer) error { + if len(tools) == 0 { + return nil + } + target := environmentValue(environment, "GOBIN") + if target == "" { + target = BinDir(cwd) + } + if err := os.MkdirAll(target, 0o755); err != nil { + return fmt.Errorf("create tool directory: %w", err) + } + needsInstall := false + for _, tool := range tools { + name := toolName(tool) + if name == "" { + return fmt.Errorf("invalid Go tool %q", tool) + } + if _, err := runner.Executable(target, name); err != nil { + needsInstall = true + break + } + } + if !needsInstall { + return nil + } + goBinary, err := runner.Executable(filepath.Join(sdkRoot, "bin"), "go") + if err != nil { + return fmt.Errorf("find SDK go for tools: %w", err) + } + //nolint:gosec // goBinary is resolved from the selected SDK directory, not shell input. + command := exec.CommandContext(ctx, goBinary, append([]string{"install"}, tools...)...) + command.Dir = cwd + command.Env = environment + command.Stdout = stdout + command.Stderr = stderr + if err := command.Run(); err != nil { + return fmt.Errorf("install Go tools: %w", err) + } + return nil +} + +func environmentValue(environment []string, key string) string { + for _, item := range environment { + name, value, ok := strings.Cut(item, "=") + if ok && name == key { + return value + } + } + return "" +} + +func toolName(tool string) string { + tool = strings.TrimSpace(tool) + if at := strings.LastIndexByte(tool, '@'); at >= 0 { + tool = tool[:at] + } + tool = strings.TrimSuffix(tool, "/") + if slash := strings.LastIndexByte(tool, '/'); slash >= 0 { + tool = tool[slash+1:] + } + return tool +} + +func ensureGitignore(path string) error { + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("read gitignore: %w", err) + } + if err == nil { + for _, line := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") { + if strings.TrimSpace(line) == ".venv/" || strings.TrimSpace(line) == ".venv" { + return nil + } + } + } + content := string(data) + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + content += ".venv/\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("update gitignore: %w", err) + } + return nil +} diff --git a/internal/venv/venv_test.go b/internal/venv/venv_test.go new file mode 100644 index 0000000..8eaa4a2 --- /dev/null +++ b/internal/venv/venv_test.go @@ -0,0 +1,42 @@ +package venv + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureCreatesBinAndGitignoreIdempotently(t *testing.T) { + cwd := t.TempDir() + if _, err := Ensure(cwd); err != nil { + t.Fatal(err) + } + if info, err := os.Stat(BinDir(cwd)); err != nil || !info.IsDir() { + t.Fatalf("virtual bin = %v, info=%v", err, info) + } + if _, err := Ensure(cwd); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(cwd, ".gitignore")) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(string(data), ".venv/"); got != 1 { + t.Fatalf(".venv/ appears %d times, want once: %q", got, data) + } +} + +func TestToolName(t *testing.T) { + tests := map[string]string{ + "golang.org/x/tools/gopls@latest": "gopls", + "honnef.co/go/tools/cmd/staticcheck@v0.5.0": "staticcheck", + "example.com/tool/": "tool", + "tool": "tool", + } + for input, want := range tests { + if got := toolName(input); got != want { + t.Errorf("toolName(%q) = %q, want %q", input, got, want) + } + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..5c2edbb --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package version + +var Value = "dev" diff --git a/skills/gvm-usage/SKILL.md b/skills/gvm-usage/SKILL.md new file mode 100644 index 0000000..8b5897c --- /dev/null +++ b/skills/gvm-usage/SKILL.md @@ -0,0 +1,196 @@ +--- +name: gvm-usage +description: Use this repository's gvm Go version manager to select, install, inspect, remove, and run Go SDKs and project tools; apply when work involves GVM_HOME, .gvmrc, venv, SDK selection, gvm list/rm, or wrapper diagnostics. +--- + +# GVM Usage + +Use this skill when a task requires operating the repository's `gvm` Go version manager or diagnosing its Go wrapper. Do not activate it for unrelated Go development merely because the project uses Go. + +## Core contract + +- `GVM_HOME` is read from the process environment; when unset, it defaults to `$HOME/.gvm`. +- The manager supports Linux, macOS, and Windows on amd64 and arm64. +- A selected SDK is stored at `$GVM_HOME/.cache/src/go`. +- Global installed tools are stored in `$GVM_HOME/.cache/bin`; the module cache is `$GVM_HOME/.cache/pkg`. +- `GVM_GO_VERSION` is required. Do not assume that the system Go version is the project version. +- `gvm` and `go` are normally the managed binaries in `$GVM_HOME/bin`. + +Before changing state, discover the active installation: + +`@sh +gvm version +gvm list +printf '%s\n' "$GVM_HOME" +command -v gvm +command -v go +` + +If `GVM_HOME` is custom, export it before running manager commands. On PowerShell use `$env:GVM_HOME`. + +## Configuration and `.gvmrc` + +`.gvmrc` is a dotenv-style configuration file, not a shell script. + +Configuration precedence is: + +1. Process environment. +2. The nearest `.gvmrc` in the current directory or its parents. +3. `$GVM_HOME/.gvmrc`. +4. Built-in defaults. + +The parser does not execute commands, substitutions, or shell code. Never use `source`, `eval`, or an equivalent operation on `.gvmrc`. + +A typical project configuration is: + +`dotenv +GVM_GO_VERSION=1.22.0 +GVM_VENV=true +GVM_TOOLS=golang.org/x/tools/gopls@latest +GVM_TOOLS=honnef.co/go/tools/cmd/staticcheck@latest +GOPROXY=https://proxy.golang.org +` + +`GVM_TOOLS` may be repeated; each occurrence adds one tool. Preserve unrelated variables when changing only `GVM_GO_VERSION`. + +Use: + +`sh +gvm default 1.22.0 +` + +to install the SDK and update the global `$GVM_HOME/.gvmrc`, or: + +`sh +gvm local 1.22.0 +` + +to install the SDK and update `.gvmrc` in the current project directory. Both commands preserve other configuration entries. + +## Installation and SDK lifecycle + +Unix bootstrap: + +`sh +curl --fail --location https://raw.githubusercontent.com/osspkg/gvm/master/install.sh | bash +` + +PowerShell bootstrap: + +`powershell +irm https://raw.githubusercontent.com/osspkg/gvm/master/install.ps1 | iex +` + +For a custom location: + +`sh +export GVM_HOME="$HOME/.local/gvm" +` + +`powershell +$env:GVM_HOME = "$HOME\.local\gvm" +` + +After installation, reload the shell profile if `gvm` is not found. + +Install one SDK explicitly: + +`sh +gvm install 1.22.0 +` + +With no version argument, `gvm install` uses the active `.gvmrc`. + +List installed SDKs: + +`sh +gvm list +` + +The list contains only complete SDK installations. Temporary, incomplete, and unrelated entries under `.cache/src` are ignored. + +Remove exactly one SDK: + +`sh +gvm rm 1.22.0 +` + +Pass a version, never a filesystem path. The command validates the version and refuses traversal or symlink targets. It does not edit `.gvmrc`. If the removed SDK is still selected, the next managed `go` invocation may install it again. + +Because `rm` is destructive, ask for confirmation when the user has not explicitly requested removal. For an explicit removal request, remove only the specified version and verify that other SDK directories remain. + +Update the manager binaries without changing installed SDKs: + +`sh +gvm update +` + +## Virtual environments and tools + +Create a project Go virtual environment with: + +`sh +gvm venv +` + +This creates `/.venv/bin`, adds `.venv/` to an existing `.gitignore` idempotently, enables `GVM_VENV=true` in the local configuration, and installs the configured `GVM_TOOLS`. + +When `GVM_VENV=true`, tools are installed into the project `.venv/bin`. Otherwise tools use the global `$GVM_HOME/.cache/bin`. + +Use the managed wrapper for Go commands: + +`sh +go version +go env GOROOT GOPATH GOMODCACHE GOBIN +` + +Use `gvm run` for a tool or binary with the same resolved configuration: + +`sh +gvm run gopls ./... +gvm run staticcheck ./... +` + +Arguments after the binary name are passed through unchanged. + +## Runtime environment and PATH + +For an active SDK, the wrapper computes: + +`text +GOROOT=$GVM_HOME/.cache/src/go +GOPATH=$GVM_HOME/.cache +GOMODCACHE=$GVM_HOME/.cache/pkg +GOBIN=$GVM_HOME/.cache/bin +` + +With a project virtual environment, `GOBIN` becomes `/.venv/bin`. + +The effective `PATH` is ordered as follows: + +1. `/.venv/bin` when the virtual environment is enabled. +2. `$GVM_HOME/.cache/bin`. +3. `$GVM_HOME/bin`. +4. The inherited PATH, with empty elements and duplicates removed. + +This ordering ensures project tools win over global tools, while the managed wrapper wins over a system Go binary. + +## Recommended workflow + +1. Check `GVM_HOME`, `gvm version`, and `gvm list`. +2. Select a project SDK with `gvm local VERSION`, or configure the global default with `gvm default VERSION`. +3. Put project-specific Go settings and repeated `GVM_TOOLS` entries in `.gvmrc`. +4. Run `gvm venv` when tools must be isolated per project. +5. Verify `go version` and `go env GOROOT GOPATH GOMODCACHE GOBIN`. +6. Use `go` for Go commands and `gvm run BINARY ...` for managed binaries. + +## Troubleshooting + +- `GVM_GO_VERSION is required`: create a local or global `.gvmrc`, or set `GVM_GO_VERSION` in the process environment. +- The wrong `go` is running: inspect `command -v go`, ensure `$GVM_HOME/bin` precedes the system PATH, reload the profile, and rerun `gvm version`. +- An SDK is missing: run `gvm list`, then `gvm install VERSION`. +- A tool is missing: inspect `GVM_TOOLS`, enable the project environment with `gvm venv`, and retry through `gvm run`. +- A removed SDK reappears: it is still selected by `.gvmrc`; select another version or remove the corresponding configuration entry. + +Prefer read-only diagnostics before mutating installation state. Do not manually delete SDK directories with `rm -rf`; use `gvm rm` with the exact version. Do not alter unrelated environment variables or project configuration entries. + diff --git a/skills/gvm-usage/agents/openai.yaml b/skills/gvm-usage/agents/openai.yaml new file mode 100644 index 0000000..6aa63d2 --- /dev/null +++ b/skills/gvm-usage/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "GVM Usage" + short_description: "Use gvm to manage Go SDKs and projects" + default_prompt: "Use $gvm-usage to select and run the correct Go SDK for this project."