Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ permissions:

jobs:
test:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
go-version: ['1.26']
os: [ubuntu-latest, macos-latest, windows-latest]
go-version: ['1.27']
steps:
- uses: actions/checkout@v6

Expand All @@ -26,7 +28,7 @@ jobs:
run: go vet ./...

- name: Test
run: go test -race -coverprofile=coverage.out ./...
run: go test -race "-coverprofile=coverage.out" ./...

- name: Build
run: go build -o goenv ./cmd/goenv
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:

- uses: actions/setup-go@v5
with:
go-version: '1.26'
go-version: '1.27'

- uses: goreleaser/goreleaser-action@v7
with:
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ A terminal UI (TUI) application for viewing and editing Go environment variables

## Installation

Building from source or using `go install` requires Go 1.27+. The mise configuration tracks Go 1.27 patches. Darwin binaries require macOS 13 or later ([Go 1.27 release notes](https://go.dev/doc/go1.27)).

### Homebrew (macOS / Linux)

```bash
Expand All @@ -109,6 +111,20 @@ mise run build
bin/goenv
```

### JSON compatibility

Persistence (configuration, history, snapshots and presets), `go env -json` input and JSON exports use `encoding/json/v2` directly with its new defaults ([migration guide](https://go.dev/doc/jsonv2-migration)):

- Duplicate object names and invalid UTF-8 are rejected.
- Field names match exactly, including capitalization. Unknown fields are ignored.
- Nil slices and maps serialize as `[]` and `{}` unless omitted by a field tag. Nil `favorites` is omitted; an empty non-nil list remains `[]`.
- Zero `watchInterval` and `maxHistory` values are omitted.
- Strings use minimal escaping; map key order is unspecified.

Valid older files generated by goenv remain readable. Loading does not automatically rewrite files. Manually edited files relying on case-insensitive field names, duplicate names or invalid UTF-8 need correction. Serialization finishes before any file content is written. JSON export failures produce an error on stderr and exit code 1 in the CLI; the TUI displays the error in export options without opening a preview or copying invalid output.

Building goenv with `GOEXPERIMENT=nojsonv2` is unsupported. Experiment suggestions follow the Go toolchain managed by the application: `jsonv2` and `nojsonv2` appear from Go 1.25, `simd` from Go 1.26, and `cacheprog` and `rangefunc` are excluded from Go 1.26 onward. Free-form experiment entry remains available; an unknown Go version shows all suggestions.

## Usage

```bash
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/alexrios/goenv

go 1.26
go 1.27.0

require (
charm.land/bubbles/v2 v2.0.0
Expand Down
10 changes: 9 additions & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,15 @@ func runCLIExport(opts Options) bool {
os.Exit(1)
}

output := goenv.GenerateShellExport(items, shell, filter)
return printCLIExport(items, shell, filter)
}

func printCLIExport(items []goenv.EnvVar, shell goenv.ShellType, filter goenv.ExportFilter) bool {
output, err := goenv.GenerateShellExport(items, shell, filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println(output)
return true
}
Expand Down
32 changes: 32 additions & 0 deletions internal/cli/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package cli

import (
"bytes"
"os"
"os/exec"
"strings"
"testing"

"github.com/alexrios/goenv/internal/goenv"
"github.com/alexrios/goenv/internal/testutil"
)

func TestCLIExportSerializationError(t *testing.T) {
if os.Getenv("GOENV_TEST_INVALID_EXPORT") == "1" {
printCLIExport([]goenv.EnvVar{{Key: "GOPATH", Value: "\xff"}}, goenv.ShellJSON, goenv.ExportAll)
return
}
testutil.TempConfig(t)
cmd := exec.Command(os.Args[0], "-test.run=^TestCLIExportSerializationError$")
cmd.Env = append(os.Environ(), "GOENV_TEST_INVALID_EXPORT=1")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 {
t.Fatalf("want exit 1, got %v", err)
}
if stdout.Len() != 0 || !strings.Contains(stderr.String(), "Error: failed to marshal JSON export") {
t.Fatalf("stdout=%q stderr=%q", stdout.String(), stderr.String())
}
}
2 changes: 1 addition & 1 deletion internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package commands

import (
"bytes"
"encoding/json"
"encoding/json/v2"
"fmt"
"os/exec"
"regexp"
Expand Down
50 changes: 50 additions & 0 deletions internal/commands/json_compat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package commands

import (
"os"
"path/filepath"
"runtime"
"testing"
)

func TestReloadEnvJSONValidation(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake Go command uses a POSIX shell")
}
old := goPath
t.Cleanup(func() { goPath = old })
for _, tc := range []struct {
name, all, changed string
wantErr bool
}{
{"valid", `{"GOPATH":"/go","GOPROXY":"<proxy>&"}`, `{"GOPATH":"/go"}`, false},
{"duplicate", `{"GOPATH":"/a","GOPATH":"/b"}`, `{}`, true},
{"invalid UTF-8", "{\"GOPATH\":\"\xff\"}", `{}`, true},
{"duplicate changed", `{"GOPATH":"/go"}`, `{"GOPATH":"/a","GOPATH":"/b"}`, true},
{"invalid changed UTF-8", `{"GOPATH":"/go"}`, "{\"GOPATH\":\"\xff\"}", true},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
for name, data := range map[string]string{"all": tc.all, "changed": tc.changed, "go": "#!/bin/sh\ncd -- \"$(dirname -- \"$0\")\"\nif [ \"$2\" = -changed ]; then cat changed; else cat all; fi\n"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(data), 0755); err != nil {
t.Fatal(err)
}
}
goPath = filepath.Join(dir, "go")
items, err := ReloadEnv()
if (err != nil) != tc.wantErr {
t.Fatalf("items=%v err=%v", items, err)
}
if !tc.wantErr {
if len(items) != 2 {
t.Fatal(items)
}
for _, v := range items {
if v.Key == "GOPATH" && (!v.Changed || v.Value != "/go") {
t.Fatal(v)
}
}
}
})
}
}
18 changes: 11 additions & 7 deletions internal/goenv/descriptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ var EnvVarKnownValues = map[string][]string{
"GORISCV64": {"rva20u64", "rva22u64"},
"GOWASM": {"satconv", "signext"},
"GOTOOLCHAIN": {"auto", "local", "path"},
"GOEXPERIMENT": {"arenas", "boringcrypto", "cacheprog", "newinliner", "rangefunc"},
"GOEXPERIMENT": {"arenas", "boringcrypto", "cacheprog", "jsonv2", "nojsonv2", "newinliner", "rangefunc", "simd"},
}

// GetEnvVarKnownValues returns the list of known values for a given environment variable.
Expand All @@ -265,10 +265,11 @@ func GetEnvVarKnownValues(key string) []string {
return EnvVarKnownValues[key]
}

// versionedValue represents a known value that requires a minimum Go version.
// versionedValue represents a known value that has a Go version availability range.
type versionedValue struct {
Value string
MinVersion GoVersion
MaxVersion GoVersion // Exclusive; zero means no upper limit.
}

// versionGatedValues maps env var keys to values that require specific Go versions.
Expand All @@ -281,8 +282,11 @@ var versionGatedValues = map[string][]versionedValue{
{Value: "loong64", MinVersion: MinVersionLoong64},
},
"GOEXPERIMENT": {
{Value: "rangefunc", MinVersion: MinVersionRangefunc},
{Value: "cacheprog", MinVersion: MinVersionCacheprog},
{Value: "rangefunc", MinVersion: MinVersionRangefunc, MaxVersion: GoVersion{1, 26, 0}},
{Value: "jsonv2", MinVersion: GoVersion{1, 25, 0}},
{Value: "nojsonv2", MinVersion: GoVersion{1, 25, 0}},
{Value: "simd", MinVersion: GoVersion{1, 26, 0}},
{Value: "cacheprog", MinVersion: MinVersionCacheprog, MaxVersion: GoVersion{1, 26, 0}},
},
}

Expand All @@ -294,7 +298,7 @@ var wholeVarMinVersions = map[string]GoVersion{
}

// FilterKnownValuesForVersion returns known values for key, excluding those
// that require a newer Go version than ver. If ver is zero, returns all values.
// unavailable in ver. If ver is zero, returns all values.
func FilterKnownValuesForVersion(key string, ver GoVersion) []string {
values := GetEnvVarKnownValues(key)
if values == nil {
Expand All @@ -320,7 +324,7 @@ func FilterKnownValuesForVersion(key string, ver GoVersion) []string {
for _, v := range values {
include := true
for _, gv := range gated {
if gv.Value == v && !ver.AtLeast(gv.MinVersion) {
if gv.Value == v && (!ver.AtLeast(gv.MinVersion) || (!gv.MaxVersion.IsZero() && ver.AtLeast(gv.MaxVersion))) {
include = false
break
}
Expand Down Expand Up @@ -386,7 +390,7 @@ var envVarExtendedDocs = map[string]string{
"GOPROXY": "Comma-separated list of proxy URLs. Use 'direct' to skip. Default: https://proxy.golang.org,direct",
"GOPRIVATE": "Comma-separated glob patterns (e.g. github.com/myorg/*). Also sets GONOPROXY and GONOSUMDB.",
"GODEBUG": "Comma-separated key=value pairs. Examples: gctrace=1, schedtrace=1000, http2debug=1",
"GOEXPERIMENT": "Comma-separated experiment names. Prefix with 'no' to disable (e.g. norangefunc).",
"GOEXPERIMENT": "Comma-separated experiment names. Prefix with 'no' to disable (e.g. nojsonv2).",
"GOFLAGS": "Space-separated flags applied to every go command. Example: -mod=vendor -count=1",
"GOTOOLCHAIN": "Controls toolchain selection. 'auto' downloads as needed. 'local' uses only installed version.",
"CGO_ENABLED": "Set to 0 for static builds without C dependencies. Set to 1 to enable CGO linking.",
Expand Down
59 changes: 59 additions & 0 deletions internal/goenv/json_compat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package goenv

import (
"encoding/json/v2"
"reflect"
"slices"
"strings"
"testing"
)

func TestExperimentVersionBoundaries(t *testing.T) {
for _, tc := range []struct {
version string
old, json, simd bool
}{
{"go1.24.9", true, false, false}, {"go1.25.0", true, true, false}, {"go1.25.9", true, true, false}, {"go1.26.0", false, true, true}, {"go1.27.0", false, true, true}, {"unknown", true, true, true},
} {
t.Run(tc.version, func(t *testing.T) {
got := FilterKnownValuesForVersion("GOEXPERIMENT", ParseGoVersion(tc.version))
for key, want := range map[string]bool{"cacheprog": tc.old, "rangefunc": tc.old, "jsonv2": tc.json, "nojsonv2": tc.json, "simd": tc.simd} {
if slices.Contains(got, key) != want {
t.Errorf("%s presence: %v, want %v", key, got, want)
}
}
if tc.version == "unknown" && !slices.Equal(got, GetEnvVarKnownValues("GOEXPERIMENT")) {
t.Fatal("unknown version changed")
}
})
}
}

func TestJSONExportCompatibility(t *testing.T) {
for _, vars := range [][]EnvVar{nil, {}, {{Key: "GOPROXY", Value: "<proxy>&"}, {Key: "GOPATH", Value: "/go"}}} {
output, err := GenerateShellExport(vars, ShellJSON, ExportAll)
if err != nil {
t.Fatal(err)
}
want := map[string]string{}
for _, v := range vars {
want[v.Key] = v.Value
}
var got map[string]string
if err := json.Unmarshal([]byte(output), &got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v want %v", got, want)
}
if strings.Contains(output, `\u0026`) || strings.Contains(output, `\u003c`) {
t.Fatalf("HTML escaping: %s", output)
}
}
for _, v := range []EnvVar{{Key: "GOPATH", Value: "\xff"}, {Key: "\xff", Value: "/go"}} {
output, err := GenerateShellExport([]EnvVar{v}, ShellJSON, ExportAll)
if err == nil || output != "" {
t.Fatalf("invalid UTF-8: output=%q err=%v", output, err)
}
}
}
15 changes: 8 additions & 7 deletions internal/goenv/shellexport.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package goenv

import (
"encoding/json"
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"strings"
)
Expand Down Expand Up @@ -81,7 +82,7 @@ func (f ExportFilter) Next() ExportFilter {
}

// GenerateShellExport generates shell export commands for the given variables.
func GenerateShellExport(vars []EnvVar, shell ShellType, filter ExportFilter) string {
func GenerateShellExport(vars []EnvVar, shell ShellType, filter ExportFilter) (string, error) {
// JSON format is special-cased
if shell == ShellJSON {
return generateJSONExport(vars, filter)
Expand Down Expand Up @@ -115,11 +116,11 @@ func GenerateShellExport(vars []EnvVar, shell ShellType, filter ExportFilter) st
lines = append(lines, formatExportLine(v.Key, v.Value, shell))
}

return strings.Join(lines, "\n")
return strings.Join(lines, "\n"), nil
}

// generateJSONExport produces a JSON object of variables.
func generateJSONExport(vars []EnvVar, filter ExportFilter) string {
func generateJSONExport(vars []EnvVar, filter ExportFilter) (string, error) {
m := make(map[string]string)
for _, v := range vars {
if filter == ExportModified && !v.Changed {
Expand All @@ -130,11 +131,11 @@ func generateJSONExport(vars []EnvVar, filter ExportFilter) string {
}
m[v.Key] = v.Value
}
data, err := json.MarshalIndent(m, "", " ")
data, err := json.Marshal(m, jsontext.WithIndent(" "))
if err != nil {
return "{}"
return "", fmt.Errorf("failed to marshal JSON export: %w", err)
}
return string(data)
return string(data), nil
}

// formatExportLine formats a single export line for the given shell.
Expand Down
Loading
Loading