From a1a80bb3707c9905400523ae4ad4c446d3c56b1f Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:13:00 +0100 Subject: [PATCH 01/75] build: take the Go version from go.mod go.mod declares Go 1.26.0 as the language version and go1.26.10 as the toolchain. The workflows read the version from go.mod, and the golangci-lint config no longer names one. --- .github/workflows/build.yml | 2 +- .github/workflows/golangci-lint.yaml | 8 ++++---- .github/workflows/release.yml | 2 +- .golangci.yml | 1 - go.mod | 4 +++- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b88eafa..8634363 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version-file: go.mod cache: true - name: Run tests diff --git a/.github/workflows/golangci-lint.yaml b/.github/workflows/golangci-lint.yaml index 6a4c871..af4a618 100644 --- a/.github/workflows/golangci-lint.yaml +++ b/.github/workflows/golangci-lint.yaml @@ -11,14 +11,14 @@ jobs: name: lint runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + with: + fetch-depth: '0' - name: Install Go uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version-file: go.mod cache: true - - uses: actions/checkout@v4 - with: - fetch-depth: '0' - name: golangci-lint uses: golangci/golangci-lint-action@v7 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f2c4b2..a87db09 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version-file: go.mod cache: true - name: Run GoReleaser diff --git a/.golangci.yml b/.golangci.yml index ec0c2f0..155f948 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,7 +3,6 @@ version: "2" run: - go: '1.24' timeout: 5m modules-download-mode: readonly relative-path-mode: gomod diff --git a/go.mod b/go.mod index cb93688..8afe04c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/educates/educatesenv -go 1.24 +go 1.26.0 + +toolchain go1.26.10 require ( github.com/google/go-github/v71 v71.0.0 From d7fe21820648859ab229f066034bffba9b4639fe Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:14:06 +0100 Subject: [PATCH 02/75] test(github): check that API requests carry the configured token github.New takes options, starting with WithBaseURL, so a test can point the client at a fake server. New now returns an error for an invalid base URL. --- pkg/cmd/root.go | 6 ++++- pkg/github/client.go | 36 +++++++++++++++++++++++--- pkg/github/client_test.go | 51 +++++++++++++++++++++++++++++++++++++ pkg/version/manager_test.go | 3 ++- 4 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 pkg/github/client_test.go diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 055ef85..c03a9af 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -51,7 +51,11 @@ func initDependencies() { } // Initialize GitHub client - gh = github.New(cfg) + var err error + gh, err = github.New(cfg) + if err != nil { + cobra.CheckErr(err) + } // Initialize version manager manager = version.New(cfg, gh) diff --git a/pkg/github/client.go b/pkg/github/client.go index c6dcd6a..6cbfde5 100644 --- a/pkg/github/client.go +++ b/pkg/github/client.go @@ -3,6 +3,8 @@ package github import ( "context" "fmt" + "net/url" + "strings" "github.com/educates/educatesenv/pkg/config" "github.com/google/go-github/v71/github" @@ -15,8 +17,28 @@ type Client struct { config *config.Config } -// New creates a new GitHub client with optional authentication -func New(cfg *config.Config) *Client { +// options holds the settings that Option values change. +type options struct { + baseURL string +} + +// Option changes how New builds a Client. +type Option func(*options) + +// WithBaseURL sets the GitHub API base URL. The default is github.com's API. +func WithBaseURL(baseURL string) Option { + return func(o *options) { + o.baseURL = baseURL + } +} + +// New creates a new GitHub client, authenticated when the config holds a token +func New(cfg *config.Config, opts ...Option) (*Client, error) { + var o options + for _, opt := range opts { + opt(&o) + } + ctx := context.Background() var client *github.Client @@ -27,10 +49,18 @@ func New(cfg *config.Config) *Client { client = github.NewClient(nil) } + if o.baseURL != "" { + base, err := url.Parse(strings.TrimSuffix(o.baseURL, "/") + "/") + if err != nil { + return nil, fmt.Errorf("invalid GitHub API base URL: %w", err) + } + client.BaseURL = base + } + return &Client{ client: client, config: cfg, - } + }, nil } // GetLatestReleaseVersion returns the latest stable release version diff --git a/pkg/github/client_test.go b/pkg/github/client_test.go new file mode 100644 index 0000000..b258813 --- /dev/null +++ b/pkg/github/client_test.go @@ -0,0 +1,51 @@ +package github_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/pkg/config" + "github.com/educates/educatesenv/pkg/github" +) + +func TestRequestsCarryTheConfiguredToken(t *testing.T) { + tests := []struct { + name string + token string + authorization string + }{ + {name: "with a token", token: "testtoken", authorization: "Bearer testtoken"}, + {name: "without a token", token: "", authorization: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var received []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received = append(received, r.Header.Get("Authorization")) + if r.URL.Path != "/repos/testorg/testrepo/releases" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer server.Close() + + cfg := &config.Config{ + Github: config.GithubConfig{Org: "testorg", Repository: "testrepo", Token: tt.token}, + } + client, err := github.New(cfg, github.WithBaseURL(server.URL)) + require.NoError(t, err) + + _, err = client.ListReleases() + require.NoError(t, err) + + assert.Equal(t, []string{tt.authorization}, received) + }) + } +} diff --git a/pkg/version/manager_test.go b/pkg/version/manager_test.go index bb9147a..66d7838 100644 --- a/pkg/version/manager_test.go +++ b/pkg/version/manager_test.go @@ -32,7 +32,8 @@ func setupTestManager(t *testing.T) (*Manager, string, func()) { } // Create GitHub client - gh := github.New(cfg) + gh, err := github.New(cfg) + assert.NoError(t, err) // Create manager manager := New(cfg, gh) From 34045d857a50dff76c7d3682dbba1ac8a45e76c6 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:14:59 +0100 Subject: [PATCH 03/75] build(deps): update go-github to v93, cobra to 1.10.2 and testify to 1.12.1 go-github v93 builds its client from options and returns release tags as plain strings. --- go.mod | 13 ++++++------- go.sum | 28 ++++++++++++++-------------- pkg/github/client.go | 28 +++++++++++----------------- 3 files changed, 31 insertions(+), 38 deletions(-) diff --git a/go.mod b/go.mod index 8afe04c..a927b7f 100644 --- a/go.mod +++ b/go.mod @@ -5,30 +5,29 @@ go 1.26.0 toolchain go1.26.10 require ( - github.com/google/go-github/v71 v71.0.0 - github.com/spf13/cobra v1.9.1 + github.com/google/go-github/v93 v93.0.0 + github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.20.1 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.12.1 golang.org/x/oauth2 v0.29.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect - github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect ) diff --git a/go.sum b/go.sum index bbc57b7..bfef8bf 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,5 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= @@ -8,13 +7,13 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/ github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30= -github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-github/v93 v93.0.0 h1:qElXPLv+XbSz1qrLdWyb0t3gSYGsWBTzr9T4/PZTCfk= +github.com/google/go-github/v93 v93.0.0/go.mod h1:l8I3rt4/mdjANKpg4Y84LEy9HJQCetUkZ8WTl2bPU8M= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -23,7 +22,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= @@ -36,29 +34,31 @@ github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/github/client.go b/pkg/github/client.go index 6cbfde5..b0d84a9 100644 --- a/pkg/github/client.go +++ b/pkg/github/client.go @@ -3,11 +3,9 @@ package github import ( "context" "fmt" - "net/url" - "strings" "github.com/educates/educatesenv/pkg/config" - "github.com/google/go-github/v71/github" + "github.com/google/go-github/v93/github" "golang.org/x/oauth2" ) @@ -39,22 +37,18 @@ func New(cfg *config.Config, opts ...Option) (*Client, error) { opt(&o) } - ctx := context.Background() - var client *github.Client - + var clientOpts []github.ClientOptionsFunc if cfg.Github.Token != "" { ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: cfg.Github.Token}) - client = github.NewClient(oauth2.NewClient(ctx, ts)) - } else { - client = github.NewClient(nil) + clientOpts = append(clientOpts, github.WithHTTPClient(oauth2.NewClient(context.Background(), ts))) } - if o.baseURL != "" { - base, err := url.Parse(strings.TrimSuffix(o.baseURL, "/") + "/") - if err != nil { - return nil, fmt.Errorf("invalid GitHub API base URL: %w", err) - } - client.BaseURL = base + clientOpts = append(clientOpts, github.WithURLs(&o.baseURL, nil)) + } + + client, err := github.NewClient(clientOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create GitHub client: %w", err) } return &Client{ @@ -78,8 +72,8 @@ func (c *Client) GetLatestReleaseVersion() (string, error) { } for _, rel := range releases { - if rel.TagName != nil && !rel.GetPrerelease() { - return *rel.TagName, nil + if rel.GetTagName() != "" && !rel.GetPrerelease() { + return rel.GetTagName(), nil } } return "", fmt.Errorf("no stable releases found in %s/%s. Try 'educatesenv list-remote --all' to see pre-releases", c.config.Github.Org, c.config.Github.Repository) From 3a16609d3c12d693c8f664c7fe7eeded2f007e0b Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:15:43 +0100 Subject: [PATCH 04/75] refactor(github): authenticate with go-github's token option The client passes the token through go-github's WithAuthToken, so golang.org/x/oauth2 is no longer a dependency. --- go.mod | 1 - go.sum | 2 -- pkg/github/client.go | 4 +--- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/go.mod b/go.mod index a927b7f..7db0434 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.12.1 - golang.org/x/oauth2 v0.29.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index bfef8bf..56e07f2 100644 --- a/go.sum +++ b/go.sum @@ -53,8 +53,6 @@ go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTV go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= -golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= diff --git a/pkg/github/client.go b/pkg/github/client.go index b0d84a9..48c53f5 100644 --- a/pkg/github/client.go +++ b/pkg/github/client.go @@ -6,7 +6,6 @@ import ( "github.com/educates/educatesenv/pkg/config" "github.com/google/go-github/v93/github" - "golang.org/x/oauth2" ) // Client wraps the GitHub client with our configuration @@ -39,8 +38,7 @@ func New(cfg *config.Config, opts ...Option) (*Client, error) { var clientOpts []github.ClientOptionsFunc if cfg.Github.Token != "" { - ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: cfg.Github.Token}) - clientOpts = append(clientOpts, github.WithHTTPClient(oauth2.NewClient(context.Background(), ts))) + clientOpts = append(clientOpts, github.WithAuthToken(cfg.Github.Token)) } if o.baseURL != "" { clientOpts = append(clientOpts, github.WithURLs(&o.baseURL, nil)) From 060b6daff3053846b3f98386b9db12922d38777f Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:17:19 +0100 Subject: [PATCH 05/75] fix(version): report the main module version in go install builds The version came from the build info's dependencies, where the main module never appears, so binaries built with go install reported develop. It now comes from the main module. A (devel) build still reports develop, and a version set through ldflags still wins. --- pkg/version/version.go | 30 +++++++++++++++--------------- pkg/version/version_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 15 deletions(-) create mode 100644 pkg/version/version_test.go diff --git a/pkg/version/version.go b/pkg/version/version.go index 3727179..d775767 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -9,30 +9,30 @@ var ( // -ldflags="-X 'github.com/educates/educatesenv/pkg/version.Version=$TAG'" defaultVersion = "develop" Version = "" - moduleName = "github.com/educates/educatesenv" ) func init() { - Version = version() + info, ok := debug.ReadBuildInfo() + Version = buildVersion(Version, info, ok) } -func version() string { - if Version != "" { - // Version was set via ldflags, just return it. - return Version +// buildVersion returns the version a binary reports. A version set through +// ldflags wins. Otherwise it is the main module's version from the build +// info, which `go install module@version` records, and "develop" when the +// build info has none or records "(devel)". +func buildVersion(ldflagsVersion string, info *debug.BuildInfo, ok bool) string { + if ldflagsVersion != "" { + return ldflagsVersion } - info, ok := debug.ReadBuildInfo() - if !ok { + if !ok || info == nil { return defaultVersion } - // Anything else. - for _, dep := range info.Deps { - if dep.Path == moduleName { - return dep.Version - } + switch info.Main.Version { + case "", "(devel)": + return defaultVersion + default: + return info.Main.Version } - - return defaultVersion } diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go new file mode 100644 index 0000000..3fdec2f --- /dev/null +++ b/pkg/version/version_test.go @@ -0,0 +1,36 @@ +package version + +import ( + "runtime/debug" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildVersion(t *testing.T) { + mainModule := func(version string) *debug.BuildInfo { + return &debug.BuildInfo{Main: debug.Module{Path: "github.com/educates/educatesenv", Version: version}} + } + + tests := []struct { + name string + ldflags string + info *debug.BuildInfo + ok bool + want string + }{ + {name: "go install at a tag", info: mainModule("v1.1.0"), ok: true, want: "v1.1.0"}, + {name: "go install at a pseudo-version", info: mainModule("v1.0.1-0.20261026101500-0123456789ab"), ok: true, want: "v1.0.1-0.20261026101500-0123456789ab"}, + {name: "devel build", info: mainModule("(devel)"), ok: true, want: "develop"}, + {name: "main module without a version", info: mainModule(""), ok: true, want: "develop"}, + {name: "no build info", info: nil, ok: false, want: "develop"}, + {name: "ldflags win over build info", ldflags: "v1.1.0-rc.1", info: mainModule("v1.0.0"), ok: true, want: "v1.1.0-rc.1"}, + {name: "ldflags without build info", ldflags: "v1.1.0", info: nil, ok: false, want: "v1.1.0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, buildVersion(tt.ldflags, tt.info, tt.ok)) + }) + } +} From dc1d3d6e2fbfcbabbb2949be9e28265a02a86697 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:18:09 +0100 Subject: [PATCH 06/75] ci: pin actions by commit SHA to their latest release Each action is referenced by the commit its latest release tag points at, with the tag in a trailing comment: checkout v7.0.1, setup-go v7.0.0, upload-artifact v7.0.2, golangci-lint-action v9.3.0 and goreleaser-action v7.3.0. --- .github/workflows/build.yml | 6 +++--- .github/workflows/golangci-lint.yaml | 6 +++--- .github/workflows/release.yml | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8634363..649e4dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,12 +20,12 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod cache: true @@ -46,7 +46,7 @@ jobs: mv bin/educatesenv bin/${{ steps.binary.outputs.name }} - name: Upload binary - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.binary.outputs.name }} path: bin/${{ steps.binary.outputs.name }} diff --git a/.github/workflows/golangci-lint.yaml b/.github/workflows/golangci-lint.yaml index af4a618..2868e38 100644 --- a/.github/workflows/golangci-lint.yaml +++ b/.github/workflows/golangci-lint.yaml @@ -11,16 +11,16 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: '0' - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod cache: true - name: golangci-lint - uses: golangci/golangci-lint-action@v7 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: version: latest args: -v \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a87db09..291cfe9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,18 +23,18 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.inputs.release_tag != '' && github.event.inputs.release_tag || github.ref_type == 'tag' && github.ref_name || github.sha }} - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod cache: true - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 with: # 'latest', 'nightly', or a semver version: "~> v2" From 10a3bd512760a735ce01a7c031953c7bb89f40fc Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:18:18 +0100 Subject: [PATCH 07/75] ci: pin golangci-lint to v2.15.0 and lint pull requests --- .github/workflows/golangci-lint.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/golangci-lint.yaml b/.github/workflows/golangci-lint.yaml index 2868e38..4eabcd6 100644 --- a/.github/workflows/golangci-lint.yaml +++ b/.github/workflows/golangci-lint.yaml @@ -5,6 +5,10 @@ on: branches: - develop - main + pull_request: + branches: + - develop + - main jobs: golangci: @@ -22,5 +26,5 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: latest + version: v2.15.0 args: -v \ No newline at end of file From 48b603d9be320670d6d694e5aa5e0d2049289a55 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:18:31 +0100 Subject: [PATCH 08/75] ci: pin GoReleaser to v2.19.0 --- .github/workflows/release.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 291cfe9..66317e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,8 +36,7 @@ jobs: - name: Run GoReleaser uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 with: - # 'latest', 'nightly', or a semver - version: "~> v2" + version: v2.19.0 args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 9e75346aa809d642a9d4f2d1ff05bf3b1a827ca9 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:19:46 +0100 Subject: [PATCH 09/75] ci: allow a manual release to skip macOS signing The release workflow's manual trigger gains a skip-signing input, off by default, that runs GoReleaser with --skip=notarize, so a release can ship unsigned when the Apple membership or certificate is unavailable. Releases from tag pushes always sign. --- .github/workflows/release.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 66317e1..788cc48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,11 @@ on: description: 'Tag or version to build (e.g., v1.2.3). If not set, uses latest commit.' required: false default: '' + skip-signing: + description: 'Release without signing and notarizing the macOS binaries, for when the Apple membership or certificate is unavailable.' + type: boolean + required: false + default: false jobs: release: @@ -37,7 +42,9 @@ jobs: uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 with: version: v2.19.0 - args: release --clean + # Only a manual run can skip signing; releases from tag pushes always + # sign, and fail when signing fails. + args: release --clean${{ github.event_name == 'workflow_dispatch' && inputs.skip-signing && ' --skip=notarize' || '' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The base64 of the contents of your '.p12' key. From aa92518d7cd21a0cd5eeb6d32ff5e2c2ad53493f Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:21:06 +0100 Subject: [PATCH 10/75] build: name release assets educatesenv-- The assets were named educatesenv--___, so the download command in the release notes header returned 404. The binary archive now names them educatesenv--, and the build names the binary educatesenv so the two names do not collide. --- .goreleaser.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 8bc968a..7de5a08 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -5,7 +5,7 @@ version: 2 builds: - main: ./cmd/educatesenv id: educatesenv - binary: educatesenv-{{ .Os }}-{{ .Arch }} + binary: educatesenv env: - CGO_ENABLED=0 goos: @@ -22,6 +22,9 @@ builds: archives: - formats: - binary + # Release assets are named educatesenv--, as the download + # instructions in the release notes header expect. + name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" checksum: name_template: 'checksums.txt' From 0cb83c1ea277533b85b232c5f62005f1162f96ca Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:22:29 +0100 Subject: [PATCH 11/75] ci: update GitHub Actions weekly with Dependabot --- .github/dependabot.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0b43900 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +# Keeps the GitHub Actions in .github/workflows at their latest release. +# The actions are pinned by commit SHA with the release tag in a trailing +# comment, and Dependabot updates both. The golangci-lint and GoReleaser +# versions are action inputs, which Dependabot does not update. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly From 65939d12549dddba1d18b0996058e7ec1c54236e Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:44:03 +0100 Subject: [PATCH 12/75] refactor(cmd): build the command tree with a constructor NewRootCommand builds a fresh tree each time it is called, with flag values captured per tree and the config, GitHub client and version manager loaded by the root's PersistentPreRunE instead of package-level globals, init() registration and cobra.OnInitialize. WithGitHubBaseURL points the GitHub client at another API base URL; it defaults to github.com and is not exposed to users. main builds the tree and executes it. --- cmd/educatesenv/main.go | 5 +- pkg/cmd/config.go | 126 ++++++++++++++++++++-------------------- pkg/cmd/init.go | 108 +++++++++++++++++----------------- pkg/cmd/install.go | 60 +++++++++---------- pkg/cmd/list.go | 27 +++++---- pkg/cmd/list_local.go | 20 +++---- pkg/cmd/list_remote.go | 88 ++++++++++++++-------------- pkg/cmd/root.go | 106 +++++++++++++++++++++------------ pkg/cmd/use.go | 76 ++++++++++++------------ pkg/cmd/version.go | 26 ++++----- 10 files changed, 339 insertions(+), 303 deletions(-) diff --git a/cmd/educatesenv/main.go b/cmd/educatesenv/main.go index dabc445..a41f4a3 100644 --- a/cmd/educatesenv/main.go +++ b/cmd/educatesenv/main.go @@ -8,8 +8,9 @@ import ( ) func main() { - if err := cmd.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + root := cmd.NewRootCommand() + if err := root.Execute(); err != nil { + fmt.Fprintf(root.ErrOrStderr(), "Error: %v\n", err) os.Exit(1) } } diff --git a/pkg/cmd/config.go b/pkg/cmd/config.go index 25be414..c98f7d6 100644 --- a/pkg/cmd/config.go +++ b/pkg/cmd/config.go @@ -11,77 +11,79 @@ import ( "github.com/educates/educatesenv/pkg/config" ) -var configCmd = &cobra.Command{ - Use: "config", - Short: "Manage educatesenv configuration", - SilenceErrors: true, - SilenceUsage: true, +func newConfigCommand(d *deps) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage educatesenv configuration", + SilenceErrors: true, + SilenceUsage: true, + } + cmd.AddCommand(newConfigInitCommand(), newConfigViewCommand(d)) + return cmd } -var configInitCmd = &cobra.Command{ - Use: "init", - Short: "Generate a default config file", - // Disable the automatic error printing - SilenceErrors: true, - // Disable automatic usage printing on error - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - home, err := os.UserHomeDir() - if err != nil { - home = "." - } - configDir := filepath.Join(home, config.ConfigDirName) - configPath := filepath.Join(configDir, "config.yaml") +func newConfigInitCommand() *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Generate a default config file", + // Disable the automatic error printing + SilenceErrors: true, + // Disable automatic usage printing on error + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + configDir := filepath.Join(home, config.ConfigDirName) + configPath := filepath.Join(configDir, "config.yaml") - // Check if config already exists - if _, err := os.Stat(configPath); err == nil { - return fmt.Errorf("config file already exists at %s", configPath) - } + // Check if config already exists + if _, err := os.Stat(configPath); err == nil { + return fmt.Errorf("config file already exists at %s", configPath) + } - // Create config directory if it doesn't exist - if err := os.MkdirAll(configDir, 0o755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } + // Create config directory if it doesn't exist + if err := os.MkdirAll(configDir, 0o755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } - // Create new config with defaults - configFile := config.New() + // Create new config with defaults + configFile := config.New() - // Write configuration to file - yamlBytes, err := yaml.Marshal(&configFile) - if err != nil { - return fmt.Errorf("failed to marshal config to YAML: %w", err) - } + // Write configuration to file + yamlBytes, err := yaml.Marshal(&configFile) + if err != nil { + return fmt.Errorf("failed to marshal config to YAML: %w", err) + } - if err := os.WriteFile(configPath, yamlBytes, 0o644); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } + if err := os.WriteFile(configPath, yamlBytes, 0o644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } - fmt.Printf("Config file created at %s\n", configPath) - return nil - }, + fmt.Printf("Config file created at %s\n", configPath) + return nil + }, + } } -var configViewCmd = &cobra.Command{ - Use: "view", - Short: "Show the current configuration", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - // Convert config struct to YAML - yamlBytes, err := yaml.Marshal(cfg) - if err != nil { - return fmt.Errorf("failed to marshal config to YAML: %w", err) - } +func newConfigViewCommand(d *deps) *cobra.Command { + return &cobra.Command{ + Use: "view", + Short: "Show the current configuration", + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + // Convert config struct to YAML + yamlBytes, err := yaml.Marshal(d.cfg) + if err != nil { + return fmt.Errorf("failed to marshal config to YAML: %w", err) + } - // Print the configuration - fmt.Println("Current configuration:") - fmt.Println(string(yamlBytes)) - return nil - }, -} - -func init() { - configCmd.AddCommand(configInitCmd) - configCmd.AddCommand(configViewCmd) - rootCmd.AddCommand(configCmd) + // Print the configuration + fmt.Println("Current configuration:") + fmt.Println(string(yamlBytes)) + return nil + }, + } } diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index 1dbfeec..851937f 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -10,65 +10,65 @@ import ( "github.com/educates/educatesenv/pkg/platform" ) -var ( - downloadLatest bool - overwrite bool -) - -var initCmd = &cobra.Command{ - Use: "init", - Short: "Initialize educatesenv: create default config and folders, and show PATH instructions", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - _, defaultBin, configPath, configCreated, err := config.CreateConfigAndFolders() - if err != nil { - return err - } +func newInitCommand(d *deps) *cobra.Command { + var ( + downloadLatest bool + overwrite bool + ) - if configCreated { - fmt.Printf("Config file created at %s\n", configPath) - } else { - fmt.Printf("Config file already exists at %s\n", configPath) - } - fmt.Printf("Bin directory ensured at %s\n", defaultBin) + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize educatesenv: create default config and folders, and show PATH instructions", + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + _, defaultBin, configPath, configCreated, err := config.CreateConfigAndFolders() + if err != nil { + return err + } - // Print PATH instructions - fmt.Println("\nTo use educatesenv, add the bin directory to your PATH:") - var pathCmd string - switch runtime.GOOS { - case platform.Darwin, platform.Linux: - pathCmd = fmt.Sprintf("echo 'export PATH=\"%s:$PATH\"' >> ~/.bashrc\nsource ~/.bashrc", defaultBin) - fmt.Printf("\nFor bash/zsh, run:\n %s\n", pathCmd) - fmt.Printf("\nFor fish shell, run:\n set -U fish_user_paths %s $fish_user_paths\n", defaultBin) - case platform.Windows: - fmt.Printf("\nFor Windows (PowerShell), run:\n [Environment]::SetEnvironmentVariable(\"Path\", \"%s;\" + $Env:Path, [EnvironmentVariableTarget]::User)\n", defaultBin) - default: - fmt.Printf("\nAdd %s to your PATH manually.\n", defaultBin) - } - fmt.Println("\nRestart your terminal or source your profile to apply the changes.") + if configCreated { + fmt.Printf("Config file created at %s\n", configPath) + } else { + fmt.Printf("Config file already exists at %s\n", configPath) + } + fmt.Printf("Bin directory ensured at %s\n", defaultBin) - if downloadLatest { - fmt.Println("\nFetching latest educates version...") - latest, err := gh.GetLatestReleaseVersion() - if err != nil { - return fmt.Errorf("failed to get latest release version: %w", err) + // Print PATH instructions + fmt.Println("\nTo use educatesenv, add the bin directory to your PATH:") + var pathCmd string + switch runtime.GOOS { + case platform.Darwin, platform.Linux: + pathCmd = fmt.Sprintf("echo 'export PATH=\"%s:$PATH\"' >> ~/.bashrc\nsource ~/.bashrc", defaultBin) + fmt.Printf("\nFor bash/zsh, run:\n %s\n", pathCmd) + fmt.Printf("\nFor fish shell, run:\n set -U fish_user_paths %s $fish_user_paths\n", defaultBin) + case platform.Windows: + fmt.Printf("\nFor Windows (PowerShell), run:\n [Environment]::SetEnvironmentVariable(\"Path\", \"%s;\" + $Env:Path, [EnvironmentVariableTarget]::User)\n", defaultBin) + default: + fmt.Printf("\nAdd %s to your PATH manually.\n", defaultBin) } - fmt.Printf("Latest version: %s\n", latest) + fmt.Println("\nRestart your terminal or source your profile to apply the changes.") - if err := manager.InstallVersion(latest, overwrite, true); err != nil { - return err + if downloadLatest { + fmt.Println("\nFetching latest educates version...") + latest, err := d.gh.GetLatestReleaseVersion() + if err != nil { + return fmt.Errorf("failed to get latest release version: %w", err) + } + fmt.Printf("Latest version: %s\n", latest) + + if err := d.manager.InstallVersion(latest, overwrite, true); err != nil { + return err + } + } else { + fmt.Println("\nRun 'educatesenv install ' to install a specific version") + fmt.Println("Run 'educatesenv list-remote' to see available versions") } - } else { - fmt.Println("\nRun 'educatesenv install ' to install a specific version") - fmt.Println("Run 'educatesenv list-remote' to see available versions") - } - return nil - }, -} + return nil + }, + } -func init() { - initCmd.Flags().BoolVar(&downloadLatest, "download", false, "Download and set as active the latest stable version") - initCmd.Flags().BoolVar(&overwrite, "overwrite", false, "Force download even if the version already exists") - rootCmd.AddCommand(initCmd) + cmd.Flags().BoolVar(&downloadLatest, "download", false, "Download and set as active the latest stable version") + cmd.Flags().BoolVar(&overwrite, "overwrite", false, "Force download even if the version already exists") + return cmd } diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index 4cc7629..c70a9ef 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -9,34 +9,34 @@ import ( "github.com/educates/educatesenv/pkg/platform" ) -var ( - useAfterInstall bool - forceOverwrite bool -) - -var installCmd = &cobra.Command{ - Use: "install ", - Short: "Install a specific version of educates", - Args: cobra.ExactArgs(1), - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - version := args[0] - - if !platform.IsSupportedPlatform(runtime.GOOS, runtime.GOARCH) { - return fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH) - } - - if err := manager.InstallVersion(version, forceOverwrite, useAfterInstall); err != nil { - return fmt.Errorf("failed to install version %s: %w", version, err) - } - - return nil - }, -} - -func init() { - installCmd.Flags().BoolVar(&useAfterInstall, "use", false, "Set the installed version as active") - installCmd.Flags().BoolVar(&forceOverwrite, "overwrite", false, "Force download even if the version already exists") - rootCmd.AddCommand(installCmd) +func newInstallCommand(d *deps) *cobra.Command { + var ( + useAfterInstall bool + forceOverwrite bool + ) + + cmd := &cobra.Command{ + Use: "install ", + Short: "Install a specific version of educates", + Args: cobra.ExactArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + version := args[0] + + if !platform.IsSupportedPlatform(runtime.GOOS, runtime.GOARCH) { + return fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH) + } + + if err := d.manager.InstallVersion(version, forceOverwrite, useAfterInstall); err != nil { + return fmt.Errorf("failed to install version %s: %w", version, err) + } + + return nil + }, + } + + cmd.Flags().BoolVar(&useAfterInstall, "use", false, "Set the installed version as active") + cmd.Flags().BoolVar(&forceOverwrite, "overwrite", false, "Force download even if the version already exists") + return cmd } diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 97d3444..4b234f8 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -10,12 +10,21 @@ import ( "github.com/spf13/cobra" ) -var listCmd = &cobra.Command{ - Use: "list", - Short: "List installed educates versions", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { +func newListCommand(d *deps) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List installed educates versions", + SilenceErrors: true, + SilenceUsage: true, + RunE: runList(d), + } +} + +// runList returns the run function that list and list-local share. +func runList(d *deps) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + cfg := d.cfg + // Get all files in the bin directory files, err := os.ReadDir(cfg.Local.Dir) // If the bin directory doesn't exist call init @@ -74,9 +83,5 @@ var listCmd = &cobra.Command{ } return nil - }, -} - -func init() { - rootCmd.AddCommand(listCmd) + } } diff --git a/pkg/cmd/list_local.go b/pkg/cmd/list_local.go index 0b421a4..dd23366 100644 --- a/pkg/cmd/list_local.go +++ b/pkg/cmd/list_local.go @@ -4,15 +4,13 @@ import ( "github.com/spf13/cobra" ) -var listLocalCmd = &cobra.Command{ - Use: "list-local", - Short: "List installed educates versions (alias for list)", - Hidden: true, - SilenceErrors: true, - SilenceUsage: true, - RunE: listCmd.RunE, -} - -func init() { - rootCmd.AddCommand(listLocalCmd) +func newListLocalCommand(d *deps) *cobra.Command { + return &cobra.Command{ + Use: "list-local", + Short: "List installed educates versions (alias for list)", + Hidden: true, + SilenceErrors: true, + SilenceUsage: true, + RunE: runList(d), + } } diff --git a/pkg/cmd/list_remote.go b/pkg/cmd/list_remote.go index 8fab81b..8ab99bc 100644 --- a/pkg/cmd/list_remote.go +++ b/pkg/cmd/list_remote.go @@ -8,11 +8,6 @@ import ( "github.com/spf13/cobra" ) -var ( - showAll bool - showRecents bool -) - // isPreRelease checks if a version string contains pre-release indicators func isPreRelease(version string) bool { preReleaseIndicators := []string{ @@ -28,51 +23,56 @@ func isPreRelease(version string) bool { return false } -var listRemoteCmd = &cobra.Command{ - Use: "list-remote", - Short: "List all available versions from GitHub releases", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - releases, err := gh.ListReleases() - if err != nil { - return fmt.Errorf("failed to fetch releases: %w", err) - } +func newListRemoteCommand(d *deps) *cobra.Command { + var ( + showAll bool + showRecents bool + ) - // Filter and collect versions - var versions []string - for _, rel := range releases { - version := rel.GetTagName() - if !showAll && (rel.GetPrerelease() || isPreRelease(version)) { - continue + cmd := &cobra.Command{ + Use: "list-remote", + Short: "List all available versions from GitHub releases", + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + releases, err := d.gh.ListReleases() + if err != nil { + return fmt.Errorf("failed to fetch releases: %w", err) } - versions = append(versions, version) - } - // Sort versions in reverse order (newest first) - sort.Sort(sort.Reverse(sort.StringSlice(versions))) + // Filter and collect versions + var versions []string + for _, rel := range releases { + version := rel.GetTagName() + if !showAll && (rel.GetPrerelease() || isPreRelease(version)) { + continue + } + versions = append(versions, version) + } - // Limit to recent versions if requested - if showRecents && len(versions) > 10 { - versions = versions[:10] - } + // Sort versions in reverse order (newest first) + sort.Sort(sort.Reverse(sort.StringSlice(versions))) - // Print versions - fmt.Println("Available versions:") - for _, version := range versions { - fmt.Printf("- %s\n", version) - } + // Limit to recent versions if requested + if showRecents && len(versions) > 10 { + versions = versions[:10] + } - if len(versions) == 0 { - fmt.Println("No versions found") - } + // Print versions + fmt.Println("Available versions:") + for _, version := range versions { + fmt.Printf("- %s\n", version) + } - return nil - }, -} + if len(versions) == 0 { + fmt.Println("No versions found") + } + + return nil + }, + } -func init() { - listRemoteCmd.Flags().BoolVar(&showAll, "all", false, "Show all versions including pre-releases") - listRemoteCmd.Flags().BoolVar(&showRecents, "recents", false, "Show only the 10 most recent versions") - rootCmd.AddCommand(listRemoteCmd) + cmd.Flags().BoolVar(&showAll, "all", false, "Show all versions including pre-releases") + cmd.Flags().BoolVar(&showRecents, "recents", false, "Show only the 10 most recent versions") + return cmd } diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index c03a9af..6ef8848 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -10,53 +10,87 @@ import ( "github.com/educates/educatesenv/pkg/version" ) -var ( - cfg *config.Config - gh *github.Client - manager *version.Manager -) +// Option changes how NewRootCommand builds the command tree. +type Option func(*options) -var rootCmd = &cobra.Command{ - Use: "educatesenv", - Short: "Manage multiple versions of the educates binary", - Long: `A version manager for educates, inspired by tfenv.`, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // Skip validation for help command - if cmd.Name() == "help" { - return nil - } - - // Validate development mode configuration - if err := manager.ValidateDevelopmentMode(); err != nil { - fmt.Printf("Warning: %v\n", err) - } - return nil - }, +type options struct { + githubBaseURL string } -// Execute executes the root command -func Execute() error { - return rootCmd.Execute() +// WithGitHubBaseURL points the GitHub client at baseURL instead of +// github.com's API. +func WithGitHubBaseURL(baseURL string) Option { + return func(o *options) { + o.githubBaseURL = baseURL + } } -func init() { - cobra.OnInitialize(initDependencies) +// deps holds what the commands of one tree share. The root command loads +// it before any command runs. +type deps struct { + opts options + cfg *config.Config + gh *github.Client + manager *version.Manager } -func initDependencies() { - // Initialize configuration - cfg = config.New() - if err := cfg.Load(); err != nil { - cobra.CheckErr(err) +// load reads the configuration and creates the GitHub client and the +// version manager. +func (d *deps) load() error { + d.cfg = config.New() + if err := d.cfg.Load(); err != nil { + return err } - // Initialize GitHub client var err error - gh, err = github.New(cfg) + d.gh, err = github.New(d.cfg, github.WithBaseURL(d.opts.githubBaseURL)) if err != nil { - cobra.CheckErr(err) + return err + } + + d.manager = version.New(d.cfg, d.gh) + return nil +} + +// NewRootCommand builds the educatesenv command tree. Each call builds a +// new tree with its own flag values and dependencies. +func NewRootCommand(opts ...Option) *cobra.Command { + d := &deps{} + for _, opt := range opts { + opt(&d.opts) + } + + root := &cobra.Command{ + Use: "educatesenv", + Short: "Manage multiple versions of the educates binary", + Long: `A version manager for educates, inspired by tfenv.`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if err := d.load(); err != nil { + return err + } + + // Skip validation for help command + if cmd.Name() == "help" { + return nil + } + + // Validate development mode configuration + if err := d.manager.ValidateDevelopmentMode(); err != nil { + fmt.Printf("Warning: %v\n", err) + } + return nil + }, } - // Initialize version manager - manager = version.New(cfg, gh) + root.AddCommand( + newConfigCommand(d), + newInitCommand(d), + newInstallCommand(d), + newListCommand(d), + newListLocalCommand(d), + newListRemoteCommand(d), + newUseCommand(d), + newVersionCommand(), + ) + return root } diff --git a/pkg/cmd/use.go b/pkg/cmd/use.go index b93a4ed..41c6027 100644 --- a/pkg/cmd/use.go +++ b/pkg/cmd/use.go @@ -7,50 +7,48 @@ import ( "github.com/spf13/cobra" ) -var useCmd = &cobra.Command{ - Use: "use [version|develop]", - Short: "Switch to a specific educates version", - Args: cobra.ExactArgs(1), - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - version := args[0] - - // Handle development version - if version == "develop" { - if !cfg.Development.Enabled { - return fmt.Errorf("development mode is not enabled. Enable it in the config file by setting development.enabled to true") - } - if cfg.Development.BinaryLocation == "" { - return fmt.Errorf("development binary location is not set. Set development.binaryLocation in the config file") - } +func newUseCommand(d *deps) *cobra.Command { + return &cobra.Command{ + Use: "use [version|develop]", + Short: "Switch to a specific educates version", + Args: cobra.ExactArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + version := args[0] + + // Handle development version + if version == "develop" { + if !d.cfg.Development.Enabled { + return fmt.Errorf("development mode is not enabled. Enable it in the config file by setting development.enabled to true") + } + if d.cfg.Development.BinaryLocation == "" { + return fmt.Errorf("development binary location is not set. Set development.binaryLocation in the config file") + } + + // Check if the development binary exists + if _, err := os.Stat(d.cfg.Development.BinaryLocation); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("development binary not found at %s", d.cfg.Development.BinaryLocation) + } + return fmt.Errorf("failed to check development binary: %w", err) + } - // Check if the development binary exists - if _, err := os.Stat(cfg.Development.BinaryLocation); err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("development binary not found at %s", cfg.Development.BinaryLocation) + if err := d.manager.UseVersion(version); err != nil { + return fmt.Errorf("failed to switch to development version: %w", err) } - return fmt.Errorf("failed to check development binary: %w", err) + + fmt.Printf("Now using educates development version from %s\n", d.cfg.Development.BinaryLocation) + return nil } - if err := manager.UseVersion(version); err != nil { - return fmt.Errorf("failed to switch to development version: %w", err) + // Handle regular version + if err := d.manager.UseVersion(version); err != nil { + return fmt.Errorf("version %s is not installed. You should install it first with `educatesenv install %s`", version, version) } - fmt.Printf("Now using educates development version from %s\n", cfg.Development.BinaryLocation) + fmt.Printf("Now using educates version %s\n", version) return nil - } - - // Handle regular version - if err := manager.UseVersion(version); err != nil { - return fmt.Errorf("version %s is not installed. You should install it first with `educatesenv install %s`", version, version) - } - - fmt.Printf("Now using educates version %s\n", version) - return nil - }, -} - -func init() { - rootCmd.AddCommand(useCmd) + }, + } } diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index 61b35df..f9b2cc8 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -7,18 +7,16 @@ import ( "github.com/spf13/cobra" ) -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print client version", - Args: cobra.ExactArgs(0), - SilenceErrors: true, - SilenceUsage: true, - RunE: func(_ *cobra.Command, _ []string) error { - fmt.Println(version.Version) - return nil - }, -} - -func init() { - rootCmd.AddCommand(versionCmd) +func newVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print client version", + Args: cobra.ExactArgs(0), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + _, err := fmt.Fprintln(cmd.OutOrStdout(), version.Version) + return err + }, + } } From 29194768de420feca5732fd71efb6fc5976f2297 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:44:45 +0100 Subject: [PATCH 13/75] test: run commands in-process against a fake GitHub server internal/fakegithub serves paginated release lists, releases by tag and asset downloads from releases each test sets up, records the requests it receives, and lets a test register handlers that take precedence over the defaults. internal/clitest builds a fresh command tree per run with a temporary home and working directory, a PATH that holds only an optional fake gh script, the EDUCATES_* and gh variables removed, and the GitHub client pointed at the fake server, and captures stdout, stderr and the returned error. --- internal/clitest/clitest.go | 193 ++++++++++++++++++ internal/fakegithub/server.go | 311 +++++++++++++++++++++++++++++ internal/fakegithub/server_test.go | 163 +++++++++++++++ pkg/cmd/version_test.go | 20 ++ 4 files changed, 687 insertions(+) create mode 100644 internal/clitest/clitest.go create mode 100644 internal/fakegithub/server.go create mode 100644 internal/fakegithub/server_test.go create mode 100644 pkg/cmd/version_test.go diff --git a/internal/clitest/clitest.go b/internal/clitest/clitest.go new file mode 100644 index 0000000..6ed8fa7 --- /dev/null +++ b/internal/clitest/clitest.go @@ -0,0 +1,193 @@ +// Package clitest runs educatesenv commands in-process, isolated from the +// machine they run on. +// +// New gives each test a temporary home directory and working directory, a +// PATH that holds only an optional fake gh script, an environment without +// the variables educatesenv or gh read, and a fake GitHub server. Run then +// builds a fresh command tree pointed at that server, runs it with the +// given arguments, and returns what it wrote and the error it returned. +// +// The environment is changed with t.Setenv, so tests that use an Env cannot +// run in parallel. +package clitest + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/educates/educatesenv/internal/fakegithub" + "github.com/educates/educatesenv/pkg/cmd" +) + +// scrubbedPrefixes and scrubbedVariables name the environment variables +// New removes, so that the developer's own settings and tokens never reach +// a test. +var ( + scrubbedPrefixes = []string{"EDUCATES_"} + scrubbedVariables = []string{ + "GH_TOKEN", + "GITHUB_TOKEN", + "GH_ENTERPRISE_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "GH_HOST", + "GH_CONFIG_DIR", + "XDG_CONFIG_HOME", + "SHELL", + } +) + +// Env is an isolated environment for running educatesenv commands. +type Env struct { + t *testing.T + + // Home is the temporary home directory, set as HOME. + Home string + // WorkDir is the temporary working directory the commands run in. + WorkDir string + // PathDir is the only directory on PATH. It holds nothing until FakeGH + // writes a gh script into it. + PathDir string + // GitHub is the fake GitHub server the commands call. + GitHub *fakegithub.Server +} + +// Result is what one run of a command tree produced. +type Result struct { + Stdout string + Stderr string + Err error +} + +// New sets up an isolated environment for the test. +func New(t *testing.T) *Env { + t.Helper() + + e := &Env{ + t: t, + Home: t.TempDir(), + WorkDir: t.TempDir(), + PathDir: t.TempDir(), + GitHub: fakegithub.New(t), + } + + for _, kv := range os.Environ() { + key, _, _ := strings.Cut(kv, "=") + for _, prefix := range scrubbedPrefixes { + if strings.HasPrefix(key, prefix) { + e.Unsetenv(key) + } + } + } + for _, key := range scrubbedVariables { + e.Unsetenv(key) + } + + t.Setenv("HOME", e.Home) + t.Setenv("PATH", e.PathDir) + t.Chdir(e.WorkDir) + return e +} + +// Run builds a fresh command tree and runs it as `educatesenv `. +func (e *Env) Run(args ...string) Result { + e.t.Helper() + + root := cmd.NewRootCommand(cmd.WithGitHubBaseURL(e.GitHub.URL)) + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetIn(strings.NewReader("")) + // cobra reads os.Args when the arguments are nil. + root.SetArgs(append([]string{}, args...)) + + err := root.Execute() + return Result{Stdout: stdout.String(), Stderr: stderr.String(), Err: err} +} + +// Setenv sets an environment variable for the rest of the test. +func (e *Env) Setenv(key, value string) { + e.t.Helper() + e.t.Setenv(key, value) +} + +// Unsetenv removes an environment variable for the rest of the test. +func (e *Env) Unsetenv(key string) { + e.t.Helper() + // t.Setenv restores the original value when the test ends. + e.t.Setenv(key, "") + if err := os.Unsetenv(key); err != nil { + e.t.Fatalf("unset %s: %v", key, err) + } +} + +// FakeGH writes an executable gh script into PathDir, so commands find it +// on PATH. script is the body of a /bin/sh script. PATH holds only +// PathDir, so the script can use shell builtins and absolute paths only. +func (e *Env) FakeGH(script string) { + e.t.Helper() + path := filepath.Join(e.PathDir, "gh") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script+"\n"), 0o755); err != nil { + e.t.Fatalf("write fake gh: %v", err) + } +} + +// ConfigDir returns the educatesenv directory in the home directory. +func (e *Env) ConfigDir() string { + return filepath.Join(e.Home, ".educatesenv") +} + +// BinDir returns the default bin directory, where versions are installed. +func (e *Env) BinDir() string { + return filepath.Join(e.ConfigDir(), "bin") +} + +// WriteConfig writes content as the config file in ConfigDir and returns +// its path. +func (e *Env) WriteConfig(content string) string { + e.t.Helper() + path := filepath.Join(e.ConfigDir(), "config.yaml") + e.writeFile(path, []byte(content), 0o600) + return path +} + +// WriteBinary writes an executable that stands in for an installed +// version into BinDir, and returns its path. Its content is +// "educates ". +func (e *Env) WriteBinary(version string) string { + e.t.Helper() + path := filepath.Join(e.BinDir(), "educates-"+version) + e.writeFile(path, []byte("educates "+version), 0o755) + return path +} + +// ActiveLink returns the path of the educates symlink in BinDir, which +// points at the active version. +func (e *Env) ActiveLink() string { + return filepath.Join(e.BinDir(), "educates") +} + +// PlatformBinaryName returns the name of the release asset that holds the +// educates binary for the platform the tests run on. +func PlatformBinaryName() string { + return "educates-" + runtime.GOOS + "-" + runtime.GOARCH +} + +// PlatformBinary returns a release asset holding content as the educates +// binary for the platform the tests run on. +func PlatformBinary(content []byte) fakegithub.Asset { + return fakegithub.Asset{Name: PlatformBinaryName(), Content: content} +} + +func (e *Env) writeFile(path string, content []byte, perm os.FileMode) { + e.t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + e.t.Fatalf("create %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, content, perm); err != nil { + e.t.Fatalf("write %s: %v", path, err) + } +} diff --git a/internal/fakegithub/server.go b/internal/fakegithub/server.go new file mode 100644 index 0000000..54226f5 --- /dev/null +++ b/internal/fakegithub/server.go @@ -0,0 +1,311 @@ +// Package fakegithub serves the parts of the GitHub REST API that +// educatesenv uses, from releases a test sets up, on an httptest server. +// +// It serves one repository, educates/educates-training-platform unless +// SetRepository changes it: +// +// - GET /repos/{owner}/{repo}/releases lists the releases in the order +// they were added, paginated with page and per_page and a Link header, +// as GitHub does. +// - GET /repos/{owner}/{repo}/releases/tags/{tag} returns one release. +// - GET /download/{tag}/{name} serves an asset's content. Each asset's +// browser_download_url points there. +// +// Anything else, including another repository, gets GitHub's 404 body. +// Handle registers a handler that takes precedence over these, for +// responses a test needs that the defaults do not give. +package fakegithub + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "sync" + "testing" + "time" +) + +const ( + // DefaultOwner is the owner of the repository the server serves by default. + DefaultOwner = "educates" + // DefaultRepository is the repository the server serves by default. + DefaultRepository = "educates-training-platform" + + defaultPerPage = 30 + maxPerPage = 100 +) + +// Release is a GitHub release the server serves. +type Release struct { + Tag string + // PublishedAt is served as published_at. The zero time is served as + // null, as GitHub does for drafts. + PublishedAt time.Time + Prerelease bool + Draft bool + Assets []Asset +} + +// Asset is a file attached to a release. +type Asset struct { + Name string + Content []byte +} + +// Request is a request the server received. +type Request struct { + Method string + Path string + Query url.Values + Header http.Header +} + +// Server is a fake GitHub API on an httptest server. +type Server struct { + // URL is the server's base URL, with no trailing slash. + URL string + + mu sync.Mutex + owner string + repo string + releases []storedRelease + nextID int64 + requests []Request + overrides *http.ServeMux + routes *http.ServeMux +} + +type storedRelease struct { + id int64 + release Release + assets []storedAsset +} + +type storedAsset struct { + id int64 + asset Asset +} + +// New starts a server that serves no releases yet. It is closed when the +// test ends. +func New(t testing.TB) *Server { + t.Helper() + + s := &Server{ + owner: DefaultOwner, + repo: DefaultRepository, + overrides: http.NewServeMux(), + routes: http.NewServeMux(), + } + s.routes.HandleFunc("GET /repos/{owner}/{repo}/releases", s.listReleases) + s.routes.HandleFunc("GET /repos/{owner}/{repo}/releases/tags/{tag}", s.releaseByTag) + s.routes.HandleFunc("GET /download/{tag}/{name}", s.download) + s.routes.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { notFound(w) }) + + srv := httptest.NewServer(http.HandlerFunc(s.serveHTTP)) + t.Cleanup(srv.Close) + s.URL = srv.URL + return s +} + +// SetRepository changes the repository the server serves. +func (s *Server) SetRepository(owner, repo string) { + s.mu.Lock() + defer s.mu.Unlock() + s.owner, s.repo = owner, repo +} + +// AddReleases adds releases after the ones already added. Lists serve them +// in that order, so add them newest first, as GitHub lists them. +func (s *Server) AddReleases(releases ...Release) { + s.mu.Lock() + defer s.mu.Unlock() + for _, r := range releases { + s.nextID++ + stored := storedRelease{id: s.nextID, release: r} + for _, a := range r.Assets { + s.nextID++ + stored.assets = append(stored.assets, storedAsset{id: s.nextID, asset: a}) + } + s.releases = append(s.releases, stored) + } +} + +// Handle registers handler for pattern, a net/http ServeMux pattern such +// as "GET /download/3.8.0/checksums.txt". A request that matches a pattern +// registered here gets that handler instead of the default response. +// Registering the same pattern twice panics, as ServeMux does. +func (s *Server) Handle(pattern string, handler http.HandlerFunc) { + s.overrides.Handle(pattern, handler) +} + +// DownloadURL returns the URL the server serves an asset at. +func (s *Server) DownloadURL(tag, name string) string { + return s.URL + "/download/" + url.PathEscape(tag) + "/" + url.PathEscape(name) +} + +// Requests returns the requests the server received, in order. +func (s *Server) Requests() []Request { + s.mu.Lock() + defer s.mu.Unlock() + return append([]Request(nil), s.requests...) +} + +func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + s.requests = append(s.requests, Request{ + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.Query(), + Header: r.Header.Clone(), + }) + s.mu.Unlock() + + if _, pattern := s.overrides.Handler(r); pattern != "" { + s.overrides.ServeHTTP(w, r) + return + } + s.routes.ServeHTTP(w, r) +} + +func (s *Server) listReleases(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + if !s.servesRepository(r) { + notFound(w) + return + } + + perPage := queryInt(r, "per_page", defaultPerPage) + perPage = min(max(perPage, 1), maxPerPage) + page := max(queryInt(r, "page", 1), 1) + lastPage := max((len(s.releases)+perPage-1)/perPage, 1) + + start := min((page-1)*perPage, len(s.releases)) + end := min(start+perPage, len(s.releases)) + body := make([]releaseJSON, 0, end-start) + for _, stored := range s.releases[start:end] { + body = append(body, s.toJSON(stored)) + } + + if page < lastPage { + pageURL := func(p int) string { + q := url.Values{"per_page": {strconv.Itoa(perPage)}, "page": {strconv.Itoa(p)}} + return fmt.Sprintf("%s%s?%s", s.URL, r.URL.Path, q.Encode()) + } + w.Header().Set("Link", fmt.Sprintf(`<%s>; rel="next", <%s>; rel="last"`, pageURL(page+1), pageURL(lastPage))) + } + writeJSON(w, http.StatusOK, body) +} + +func (s *Server) releaseByTag(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + if !s.servesRepository(r) { + notFound(w) + return + } + + tag := r.PathValue("tag") + for _, stored := range s.releases { + if stored.release.Tag == tag { + writeJSON(w, http.StatusOK, s.toJSON(stored)) + return + } + } + notFound(w) +} + +func (s *Server) download(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + tag, name := r.PathValue("tag"), r.PathValue("name") + for _, stored := range s.releases { + if stored.release.Tag != tag { + continue + } + for _, a := range stored.assets { + if a.asset.Name == name { + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(a.asset.Content))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(a.asset.Content) + return + } + } + } + notFound(w) +} + +func (s *Server) servesRepository(r *http.Request) bool { + return r.PathValue("owner") == s.owner && r.PathValue("repo") == s.repo +} + +type releaseJSON struct { + ID int64 `json:"id"` + TagName string `json:"tag_name"` + Name string `json:"name"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + PublishedAt *time.Time `json:"published_at"` + Assets []assetJSON `json:"assets"` +} + +type assetJSON struct { + ID int64 `json:"id"` + Name string `json:"name"` + Size int `json:"size"` + ContentType string `json:"content_type"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func (s *Server) toJSON(stored storedRelease) releaseJSON { + rel := stored.release + out := releaseJSON{ + ID: stored.id, + TagName: rel.Tag, + Name: rel.Tag, + Draft: rel.Draft, + Prerelease: rel.Prerelease, + Assets: make([]assetJSON, 0, len(stored.assets)), + } + if !rel.PublishedAt.IsZero() { + published := rel.PublishedAt.UTC() + out.PublishedAt = &published + } + for _, a := range stored.assets { + out.Assets = append(out.Assets, assetJSON{ + ID: a.id, + Name: a.asset.Name, + Size: len(a.asset.Content), + ContentType: "application/octet-stream", + BrowserDownloadURL: s.DownloadURL(rel.Tag, a.asset.Name), + }) + } + return out +} + +func queryInt(r *http.Request, key string, fallback int) int { + n, err := strconv.Atoi(r.URL.Query().Get(key)) + if err != nil { + return fallback + } + return n +} + +func notFound(w http.ResponseWriter) { + writeJSON(w, http.StatusNotFound, map[string]string{ + "message": "Not Found", + "documentation_url": "https://docs.github.com/rest", + }) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/internal/fakegithub/server_test.go b/internal/fakegithub/server_test.go new file mode 100644 index 0000000..160487f --- /dev/null +++ b/internal/fakegithub/server_test.go @@ -0,0 +1,163 @@ +package fakegithub_test + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/google/go-github/v93/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/fakegithub" +) + +const ( + owner = fakegithub.DefaultOwner + repo = fakegithub.DefaultRepository +) + +func newClient(t *testing.T, srv *fakegithub.Server, opts ...github.ClientOptionsFunc) *github.Client { + t.Helper() + client, err := github.NewClient(append([]github.ClientOptionsFunc{github.WithURLs(github.Ptr(srv.URL), nil)}, opts...)...) + require.NoError(t, err) + return client +} + +func TestListsEveryReleaseAcrossPagesInTheOrderAdded(t *testing.T) { + srv := fakegithub.New(t) + var want []string + for i := 250; i > 0; i-- { + tag := fmt.Sprintf("1.0.%d", i) + want = append(want, tag) + srv.AddReleases(fakegithub.Release{Tag: tag}) + } + client := newClient(t, srv) + + var got []string + var pages int + opt := &github.ListOptions{PerPage: 100} + for { + releases, resp, err := client.Repositories.ListReleases(context.Background(), owner, repo, opt) + require.NoError(t, err) + pages++ + for _, r := range releases { + got = append(got, r.GetTagName()) + } + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + assert.Equal(t, 3, pages) + assert.Equal(t, want, got) +} + +func TestServesReleaseFieldsAndAssets(t *testing.T) { + srv := fakegithub.New(t) + published := time.Date(2026, 8, 26, 14, 30, 0, 0, time.UTC) + srv.AddReleases( + fakegithub.Release{Tag: "4.0.0-alpha.13", Prerelease: true, PublishedAt: published.Add(time.Hour)}, + fakegithub.Release{Tag: "3.8.0", PublishedAt: published, Assets: []fakegithub.Asset{ + {Name: "educates-linux-amd64", Content: []byte("binary")}, + {Name: "checksums.txt", Content: []byte("sums")}, + }}, + fakegithub.Release{Tag: "3.9.0", Draft: true}, + ) + client := newClient(t, srv) + + releases, _, err := client.Repositories.ListReleases(context.Background(), owner, repo, nil) + require.NoError(t, err) + require.Len(t, releases, 3) + assert.True(t, releases[0].GetPrerelease()) + assert.True(t, releases[2].GetDraft()) + assert.Nil(t, releases[2].PublishedAt) + + release, _, err := client.Repositories.GetReleaseByTag(context.Background(), owner, repo, "3.8.0") + require.NoError(t, err) + assert.Equal(t, "3.8.0", release.GetTagName()) + assert.False(t, release.GetPrerelease()) + assert.Equal(t, published, release.GetPublishedAt().UTC()) + require.Len(t, release.Assets, 2) + assert.Equal(t, "educates-linux-amd64", release.Assets[0].GetName()) + assert.Equal(t, srv.DownloadURL("3.8.0", "educates-linux-amd64"), release.Assets[0].GetBrowserDownloadURL()) + + resp, err := http.Get(release.Assets[0].GetBrowserDownloadURL()) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "binary", string(body)) +} + +func TestAnswersNotFoundForUnknownTagsAssetsAndRepositories(t *testing.T) { + srv := fakegithub.New(t) + srv.AddReleases(fakegithub.Release{Tag: "3.8.0"}) + client := newClient(t, srv) + + _, resp, err := client.Repositories.GetReleaseByTag(context.Background(), owner, repo, "9.9.9") + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + _, resp, err = client.Repositories.ListReleases(context.Background(), "someone", "else", nil) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + download, err := http.Get(srv.DownloadURL("3.8.0", "missing")) + require.NoError(t, err) + _ = download.Body.Close() + assert.Equal(t, http.StatusNotFound, download.StatusCode) +} + +func TestServesTheRepositorySetForIt(t *testing.T) { + srv := fakegithub.New(t) + srv.SetRepository("acme", "tools") + srv.AddReleases(fakegithub.Release{Tag: "1.0.0"}) + client := newClient(t, srv) + + releases, _, err := client.Repositories.ListReleases(context.Background(), "acme", "tools", nil) + require.NoError(t, err) + assert.Len(t, releases, 1) + + _, resp, err := client.Repositories.ListReleases(context.Background(), owner, repo, nil) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +func TestHandlersRegisteredByATestTakePrecedence(t *testing.T) { + srv := fakegithub.New(t) + srv.AddReleases(fakegithub.Release{Tag: "3.8.0"}) + srv.Handle("GET /repos/{owner}/{repo}/releases/tags/3.8.0", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprint(w, `{"message":"Bad credentials"}`) + }) + client := newClient(t, srv) + + _, resp, err := client.Repositories.GetReleaseByTag(context.Background(), owner, repo, "3.8.0") + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + releases, _, err := client.Repositories.ListReleases(context.Background(), owner, repo, nil) + require.NoError(t, err) + assert.Len(t, releases, 1) +} + +func TestRecordsTheRequestsItReceives(t *testing.T) { + srv := fakegithub.New(t) + client := newClient(t, srv, github.WithAuthToken("tok123")) + + _, _, err := client.Repositories.ListReleases(context.Background(), owner, repo, &github.ListOptions{PerPage: 100}) + require.NoError(t, err) + + requests := srv.Requests() + require.Len(t, requests, 1) + assert.Equal(t, http.MethodGet, requests[0].Method) + assert.Equal(t, "/repos/educates/educates-training-platform/releases", requests[0].Path) + assert.Equal(t, "100", requests[0].Query.Get("per_page")) + assert.Equal(t, "Bearer tok123", requests[0].Header.Get("Authorization")) +} diff --git a/pkg/cmd/version_test.go b/pkg/cmd/version_test.go new file mode 100644 index 0000000..dc62786 --- /dev/null +++ b/pkg/cmd/version_test.go @@ -0,0 +1,20 @@ +package cmd_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +func TestVersionPrintsTheBuildVersion(t *testing.T) { + env := clitest.New(t) + + res := env.Run("version") + + require.NoError(t, res.Err) + assert.Equal(t, "develop\n", res.Stdout) + assert.Empty(t, res.Stderr) +} From cad59380fdf28702decae0ea3f69d63b33cd55ac Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:44:56 +0100 Subject: [PATCH 14/75] feat(cmd): reject stray arguments to commands that take none list-remote, list, list-local, init, config init, config view and version now fail with cobra's unknown command error when given an argument, before reading the config or calling GitHub. version used ExactArgs(0) and now reports the same error as the others. --- pkg/cmd/args_test.go | 37 +++++++++++++++++++++++++++++++++++++ pkg/cmd/config.go | 2 ++ pkg/cmd/init.go | 1 + pkg/cmd/list.go | 1 + pkg/cmd/list_local.go | 1 + pkg/cmd/list_remote.go | 1 + pkg/cmd/version.go | 2 +- 7 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 pkg/cmd/args_test.go diff --git a/pkg/cmd/args_test.go b/pkg/cmd/args_test.go new file mode 100644 index 0000000..27579fe --- /dev/null +++ b/pkg/cmd/args_test.go @@ -0,0 +1,37 @@ +package cmd_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +func TestCommandsWithoutArgumentsRejectStrayOnes(t *testing.T) { + tests := [][]string{ + {"list-remote"}, + {"list"}, + {"list-local"}, + {"init"}, + {"config", "init"}, + {"config", "view"}, + {"version"}, + } + + for _, command := range tests { + name := strings.Join(command, " ") + t.Run(name, func(t *testing.T) { + env := clitest.New(t) + + res := env.Run(append(command, "stray")...) + + require.EqualError(t, res.Err, `unknown command "stray" for "educatesenv `+name+`"`) + assert.Empty(t, res.Stdout) + assert.NoDirExists(t, env.ConfigDir()) + assert.Empty(t, env.GitHub.Requests()) + }) + } +} diff --git a/pkg/cmd/config.go b/pkg/cmd/config.go index c98f7d6..8a7fc59 100644 --- a/pkg/cmd/config.go +++ b/pkg/cmd/config.go @@ -26,6 +26,7 @@ func newConfigInitCommand() *cobra.Command { return &cobra.Command{ Use: "init", Short: "Generate a default config file", + Args: cobra.NoArgs, // Disable the automatic error printing SilenceErrors: true, // Disable automatic usage printing on error @@ -71,6 +72,7 @@ func newConfigViewCommand(d *deps) *cobra.Command { return &cobra.Command{ Use: "view", Short: "Show the current configuration", + Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index 851937f..4617064 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -19,6 +19,7 @@ func newInitCommand(d *deps) *cobra.Command { cmd := &cobra.Command{ Use: "init", Short: "Initialize educatesenv: create default config and folders, and show PATH instructions", + Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 4b234f8..055cfd0 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -14,6 +14,7 @@ func newListCommand(d *deps) *cobra.Command { return &cobra.Command{ Use: "list", Short: "List installed educates versions", + Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, RunE: runList(d), diff --git a/pkg/cmd/list_local.go b/pkg/cmd/list_local.go index dd23366..50bd14e 100644 --- a/pkg/cmd/list_local.go +++ b/pkg/cmd/list_local.go @@ -9,6 +9,7 @@ func newListLocalCommand(d *deps) *cobra.Command { Use: "list-local", Short: "List installed educates versions (alias for list)", Hidden: true, + Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, RunE: runList(d), diff --git a/pkg/cmd/list_remote.go b/pkg/cmd/list_remote.go index 8ab99bc..4000256 100644 --- a/pkg/cmd/list_remote.go +++ b/pkg/cmd/list_remote.go @@ -32,6 +32,7 @@ func newListRemoteCommand(d *deps) *cobra.Command { cmd := &cobra.Command{ Use: "list-remote", Short: "List all available versions from GitHub releases", + Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index f9b2cc8..eae519e 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -11,7 +11,7 @@ func newVersionCommand() *cobra.Command { return &cobra.Command{ Use: "version", Short: "Print client version", - Args: cobra.ExactArgs(0), + Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { From 8ade19d1df3ab1e5ecc773cf744029f853b63d8e Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:46:08 +0100 Subject: [PATCH 15/75] fix(cmd): write config init and config view output through cobra --- pkg/cmd/config.go | 7 ++++--- pkg/cmd/config_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 pkg/cmd/config_test.go diff --git a/pkg/cmd/config.go b/pkg/cmd/config.go index 8a7fc59..fd3b514 100644 --- a/pkg/cmd/config.go +++ b/pkg/cmd/config.go @@ -62,7 +62,7 @@ func newConfigInitCommand() *cobra.Command { return fmt.Errorf("failed to write config file: %w", err) } - fmt.Printf("Config file created at %s\n", configPath) + fmt.Fprintf(cmd.OutOrStdout(), "Config file created at %s\n", configPath) return nil }, } @@ -83,8 +83,9 @@ func newConfigViewCommand(d *deps) *cobra.Command { } // Print the configuration - fmt.Println("Current configuration:") - fmt.Println(string(yamlBytes)) + out := cmd.OutOrStdout() + fmt.Fprintln(out, "Current configuration:") + fmt.Fprintln(out, string(yamlBytes)) return nil }, } diff --git a/pkg/cmd/config_test.go b/pkg/cmd/config_test.go new file mode 100644 index 0000000..72f3012 --- /dev/null +++ b/pkg/cmd/config_test.go @@ -0,0 +1,33 @@ +package cmd_test + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +func TestConfigViewPrintsTheConfiguration(t *testing.T) { + env := clitest.New(t) + env.WriteConfig("github:\n org: acme\n") + + res := env.Run("config", "view") + + require.NoError(t, res.Err) + assert.Regexp(t, `^Current configuration:\ngithub:\n org: acme\n`, res.Stdout) + assert.Empty(t, res.Stderr) +} + +func TestConfigInitCreatesTheConfigFile(t *testing.T) { + env := clitest.New(t) + configPath := filepath.Join(env.ConfigDir(), "config.yaml") + + res := env.Run("config", "init") + + require.NoError(t, res.Err) + assert.Equal(t, "Config file created at "+configPath+"\n", res.Stdout) + assert.FileExists(t, configPath) +} From e656dc790f8d57c61669345068f7e1a9ebb314c4 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:46:23 +0100 Subject: [PATCH 16/75] fix(config): load the config with its own viper instance Load used viper's package-level instance, so config search paths piled up across loads and values from an earlier load survived when the config file was later missing. Each Load now starts from a new instance. --- pkg/cmd/config_test.go | 15 ++++++++++++++ pkg/config/config.go | 47 +++++++++++++++++++++--------------------- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/pkg/cmd/config_test.go b/pkg/cmd/config_test.go index 72f3012..13cc056 100644 --- a/pkg/cmd/config_test.go +++ b/pkg/cmd/config_test.go @@ -1,6 +1,7 @@ package cmd_test import ( + "os" "path/filepath" "testing" @@ -21,6 +22,20 @@ func TestConfigViewPrintsTheConfiguration(t *testing.T) { assert.Empty(t, res.Stderr) } +func TestEachRunReadsTheConfigAfresh(t *testing.T) { + env := clitest.New(t) + configPath := env.WriteConfig("github:\n org: acme\n") + res := env.Run("config", "view") + require.NoError(t, res.Err) + require.Contains(t, res.Stdout, "org: acme\n") + + require.NoError(t, os.Remove(configPath)) + res = env.Run("config", "view") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "org: educates\n") +} + func TestConfigInitCreatesTheConfigFile(t *testing.T) { env := clitest.New(t) configPath := filepath.Join(env.ConfigDir(), "config.yaml") diff --git a/pkg/config/config.go b/pkg/config/config.go index b865207..062e364 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -79,53 +79,54 @@ func (c *Config) Load() error { configDir := filepath.Join(home, ConfigDirName) defaultBin := filepath.Join(configDir, "bin") - viper.SetConfigName("config") - viper.SetConfigType("yaml") - viper.AddConfigPath(".") - viper.AddConfigPath(configDir) + v := viper.New() + v.SetConfigName("config") + v.SetConfigType("yaml") + v.AddConfigPath(".") + v.AddConfigPath(configDir) // Set defaults - viper.SetDefault("github.org", DefaultGithubOrg) - viper.SetDefault("github.repository", DefaultGithubRepo) - viper.SetDefault("github.token", "") - viper.SetDefault("local.dir", defaultBin) - viper.SetDefault("development.enabled", false) - viper.SetDefault("development.binaryLocation", "") + v.SetDefault("github.org", DefaultGithubOrg) + v.SetDefault("github.repository", DefaultGithubRepo) + v.SetDefault("github.token", "") + v.SetDefault("local.dir", defaultBin) + v.SetDefault("development.enabled", false) + v.SetDefault("development.binaryLocation", "") // Bind environment variables - if err := viper.BindEnv("github.org", "EDUCATES_GITHUB_ORG"); err != nil { + if err := v.BindEnv("github.org", "EDUCATES_GITHUB_ORG"); err != nil { return fmt.Errorf("failed to bind env EDUCATES_GITHUB_ORG: %w", err) } - if err := viper.BindEnv("github.repository", "EDUCATES_GITHUB_REPOSITORY"); err != nil { + if err := v.BindEnv("github.repository", "EDUCATES_GITHUB_REPOSITORY"); err != nil { return fmt.Errorf("failed to bind env EDUCATES_GITHUB_REPOSITORY: %w", err) } - if err := viper.BindEnv("github.token", "EDUCATES_GITHUB_TOKEN"); err != nil { + if err := v.BindEnv("github.token", "EDUCATES_GITHUB_TOKEN"); err != nil { return fmt.Errorf("failed to bind env EDUCATES_GITHUB_TOKEN: %w", err) } - if err := viper.BindEnv("local.dir", "EDUCATES_LOCAL_DIR"); err != nil { + if err := v.BindEnv("local.dir", "EDUCATES_LOCAL_DIR"); err != nil { return fmt.Errorf("failed to bind env EDUCATES_LOCAL_DIR: %w", err) } - if err := viper.BindEnv("development.enabled", "EDUCATES_DEVELOPMENT_ENABLED"); err != nil { + if err := v.BindEnv("development.enabled", "EDUCATES_DEVELOPMENT_ENABLED"); err != nil { return fmt.Errorf("failed to bind env EDUCATES_DEVELOPMENT_ENABLED: %w", err) } - if err := viper.BindEnv("development.binaryLocation", "EDUCATES_DEVELOPMENT_BINARY_LOCATION"); err != nil { + if err := v.BindEnv("development.binaryLocation", "EDUCATES_DEVELOPMENT_BINARY_LOCATION"); err != nil { return fmt.Errorf("failed to bind env EDUCATES_DEVELOPMENT_BINARY_LOCATION: %w", err) } // Read config file if present - if err := viper.ReadInConfig(); err != nil { + if err := v.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { return fmt.Errorf("error reading config file: %w", err) } } // Map the configuration to our struct - c.Github.Org = viper.GetString("github.org") - c.Github.Repository = viper.GetString("github.repository") - c.Github.Token = viper.GetString("github.token") - c.Local.Dir = viper.GetString("local.dir") - c.Development.Enabled = viper.GetBool("development.enabled") - c.Development.BinaryLocation = viper.GetString("development.binaryLocation") + c.Github.Org = v.GetString("github.org") + c.Github.Repository = v.GetString("github.repository") + c.Github.Token = v.GetString("github.token") + c.Local.Dir = v.GetString("local.dir") + c.Development.Enabled = v.GetBool("development.enabled") + c.Development.BinaryLocation = v.GetString("development.binaryLocation") return nil } From 1163d8ee417c331acf521cfde90f058bbec0fb46 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:49:16 +0100 Subject: [PATCH 17/75] fix(cmd): write every command's output through cobra's writers init, list, list-remote, use, the development-mode warning and the version manager's install progress printed to the process's stdout. They now write to the command's OutOrStdout, so the same text still reaches stdout and a caller that sets its own writers receives it. version.New takes the writer the manager reports to. The version manager tests move to the CLI harness: development-mode cleanup, use, and installing a named version from the fake GitHub server, which replaces the TODO about mocking the GitHub client. --- pkg/cmd/development_test.go | 55 ++++++++++++++++++ pkg/cmd/init.go | 28 ++++----- pkg/cmd/init_test.go | 40 +++++++++++++ pkg/cmd/install_test.go | 63 ++++++++++++++++++++ pkg/cmd/list.go | 13 +++-- pkg/cmd/list_remote.go | 8 ++- pkg/cmd/list_remote_test.go | 26 +++++++++ pkg/cmd/list_test.go | 32 +++++++++++ pkg/cmd/root.go | 11 ++-- pkg/cmd/use.go | 4 +- pkg/cmd/use_test.go | 52 +++++++++++++++++ pkg/version/manager.go | 18 +++--- pkg/version/manager_test.go | 111 ------------------------------------ 13 files changed, 313 insertions(+), 148 deletions(-) create mode 100644 pkg/cmd/development_test.go create mode 100644 pkg/cmd/init_test.go create mode 100644 pkg/cmd/install_test.go create mode 100644 pkg/cmd/list_remote_test.go create mode 100644 pkg/cmd/list_test.go create mode 100644 pkg/cmd/use_test.go delete mode 100644 pkg/version/manager_test.go diff --git a/pkg/cmd/development_test.go b/pkg/cmd/development_test.go new file mode 100644 index 0000000..678115e --- /dev/null +++ b/pkg/cmd/development_test.go @@ -0,0 +1,55 @@ +package cmd_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +// writeDevelopmentBinary writes an executable outside the bin directory, +// standing in for a locally built educates. +func writeDevelopmentBinary(t *testing.T, env *clitest.Env) string { + t.Helper() + path := filepath.Join(env.Home, "src", "educates", "educates") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("educates develop"), 0o755)) + return path +} + +func TestDisabledDevelopmentModeRemovesTheDevelopmentSymlink(t *testing.T) { + env := clitest.New(t) + devBinary := writeDevelopmentBinary(t, env) + require.NoError(t, os.MkdirAll(env.BinDir(), 0o755)) + require.NoError(t, os.Symlink(devBinary, env.ActiveLink())) + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, + "Warning: development mode is disabled; removed symlink to development binary. Please use 'educatesenv use ' to select a version\n"+ + "Installed versions:\nNo versions installed\n", + res.Stdout) + assert.NoFileExists(t, env.ActiveLink()) + assert.FileExists(t, devBinary) +} + +func TestEnabledDevelopmentModeKeepsTheDevelopmentSymlink(t *testing.T) { + env := clitest.New(t) + devBinary := writeDevelopmentBinary(t, env) + env.WriteConfig("development:\n enabled: true\n binaryLocation: " + devBinary + "\n") + require.NoError(t, os.MkdirAll(env.BinDir(), 0o755)) + require.NoError(t, os.Symlink(devBinary, env.ActiveLink())) + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, "Installed versions:\n* develop (active) -> "+devBinary+"\n", res.Stdout) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, devBinary, target) +} diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index 4617064..10552ef 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -23,47 +23,49 @@ func newInitCommand(d *deps) *cobra.Command { SilenceErrors: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() + _, defaultBin, configPath, configCreated, err := config.CreateConfigAndFolders() if err != nil { return err } if configCreated { - fmt.Printf("Config file created at %s\n", configPath) + fmt.Fprintf(out, "Config file created at %s\n", configPath) } else { - fmt.Printf("Config file already exists at %s\n", configPath) + fmt.Fprintf(out, "Config file already exists at %s\n", configPath) } - fmt.Printf("Bin directory ensured at %s\n", defaultBin) + fmt.Fprintf(out, "Bin directory ensured at %s\n", defaultBin) // Print PATH instructions - fmt.Println("\nTo use educatesenv, add the bin directory to your PATH:") + fmt.Fprintln(out, "\nTo use educatesenv, add the bin directory to your PATH:") var pathCmd string switch runtime.GOOS { case platform.Darwin, platform.Linux: pathCmd = fmt.Sprintf("echo 'export PATH=\"%s:$PATH\"' >> ~/.bashrc\nsource ~/.bashrc", defaultBin) - fmt.Printf("\nFor bash/zsh, run:\n %s\n", pathCmd) - fmt.Printf("\nFor fish shell, run:\n set -U fish_user_paths %s $fish_user_paths\n", defaultBin) + fmt.Fprintf(out, "\nFor bash/zsh, run:\n %s\n", pathCmd) + fmt.Fprintf(out, "\nFor fish shell, run:\n set -U fish_user_paths %s $fish_user_paths\n", defaultBin) case platform.Windows: - fmt.Printf("\nFor Windows (PowerShell), run:\n [Environment]::SetEnvironmentVariable(\"Path\", \"%s;\" + $Env:Path, [EnvironmentVariableTarget]::User)\n", defaultBin) + fmt.Fprintf(out, "\nFor Windows (PowerShell), run:\n [Environment]::SetEnvironmentVariable(\"Path\", \"%s;\" + $Env:Path, [EnvironmentVariableTarget]::User)\n", defaultBin) default: - fmt.Printf("\nAdd %s to your PATH manually.\n", defaultBin) + fmt.Fprintf(out, "\nAdd %s to your PATH manually.\n", defaultBin) } - fmt.Println("\nRestart your terminal or source your profile to apply the changes.") + fmt.Fprintln(out, "\nRestart your terminal or source your profile to apply the changes.") if downloadLatest { - fmt.Println("\nFetching latest educates version...") + fmt.Fprintln(out, "\nFetching latest educates version...") latest, err := d.gh.GetLatestReleaseVersion() if err != nil { return fmt.Errorf("failed to get latest release version: %w", err) } - fmt.Printf("Latest version: %s\n", latest) + fmt.Fprintf(out, "Latest version: %s\n", latest) if err := d.manager.InstallVersion(latest, overwrite, true); err != nil { return err } } else { - fmt.Println("\nRun 'educatesenv install ' to install a specific version") - fmt.Println("Run 'educatesenv list-remote' to see available versions") + fmt.Fprintln(out, "\nRun 'educatesenv install ' to install a specific version") + fmt.Fprintln(out, "Run 'educatesenv list-remote' to see available versions") } return nil }, diff --git a/pkg/cmd/init_test.go b/pkg/cmd/init_test.go new file mode 100644 index 0000000..8a9c74a --- /dev/null +++ b/pkg/cmd/init_test.go @@ -0,0 +1,40 @@ +package cmd_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +func TestInitCreatesTheConfigAndBinDirectory(t *testing.T) { + env := clitest.New(t) + configPath := filepath.Join(env.ConfigDir(), "config.yaml") + + res := env.Run("init") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Config file created at "+configPath+"\n") + assert.Contains(t, res.Stdout, "Bin directory ensured at "+env.BinDir()+"\n") + assert.Contains(t, res.Stdout, "Run 'educatesenv list-remote' to see available versions\n") + assert.Empty(t, res.Stderr) + assert.FileExists(t, configPath) + assert.DirExists(t, env.BinDir()) +} + +func TestInitKeepsAnExistingConfig(t *testing.T) { + env := clitest.New(t) + configPath := env.WriteConfig("github:\n org: acme\n") + + res := env.Run("init") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Config file already exists at "+configPath+"\n") + content, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Equal(t, "github:\n org: acme\n", string(content)) +} diff --git a/pkg/cmd/install_test.go b/pkg/cmd/install_test.go new file mode 100644 index 0000000..9eca56b --- /dev/null +++ b/pkg/cmd/install_test.go @@ -0,0 +1,63 @@ +package cmd_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" + "github.com/educates/educatesenv/internal/fakegithub" +) + +func addRelease(env *clitest.Env, tag string, binary []byte) { + env.GitHub.AddReleases(fakegithub.Release{ + Tag: tag, + Assets: []fakegithub.Asset{clitest.PlatformBinary(binary)}, + }) +} + +func TestInstallDownloadsANamedVersion(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + + res := env.Run("install", "3.8.0") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Installing version 3.8.0...\n") + assert.Contains(t, res.Stdout, "educates 3.8.0 installed successfully.\n") + assert.Empty(t, res.Stderr) + binary := filepath.Join(env.BinDir(), "educates-3.8.0") + content, err := os.ReadFile(binary) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0 binary", string(content)) + info, err := os.Stat(binary) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) + assert.NoFileExists(t, env.ActiveLink()) +} + +func TestInstallWithUseActivatesTheVersion(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + + res := env.Run("install", "3.8.0", "--use") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "educates 3.8.0 is now active.\n") + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestInstallOfAnUnknownVersionFails(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + + res := env.Run("install", "9.9.9") + + require.EqualError(t, res.Err, "failed to install version 9.9.9: version 9.9.9 not found. Run 'educatesenv list-remote' to see available versions") + assert.NoFileExists(t, filepath.Join(env.BinDir(), "educates-9.9.9")) +} diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 055cfd0..00ac967 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -25,6 +25,7 @@ func newListCommand(d *deps) *cobra.Command { func runList(d *deps) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { cfg := d.cfg + out := cmd.OutOrStdout() // Get all files in the bin directory files, err := os.ReadDir(cfg.Local.Dir) @@ -51,14 +52,14 @@ func runList(d *deps) func(cmd *cobra.Command, args []string) error { } // Print installed versions - fmt.Println("Installed versions:") + fmt.Fprintln(out, "Installed versions:") // Show development version if enabled if cfg.Development.Enabled { if isDevelopmentActive { - fmt.Printf("* develop (active) -> %s\n", cfg.Development.BinaryLocation) + fmt.Fprintf(out, "* develop (active) -> %s\n", cfg.Development.BinaryLocation) } else { - fmt.Printf(" develop -> %s\n", cfg.Development.BinaryLocation) + fmt.Fprintf(out, " develop -> %s\n", cfg.Development.BinaryLocation) } } @@ -71,16 +72,16 @@ func runList(d *deps) func(cmd *cobra.Command, args []string) error { if strings.HasPrefix(file.Name(), "educates-") { version := strings.TrimPrefix(file.Name(), "educates-") if version == activeVersion { - fmt.Printf("* %s (active)\n", version) + fmt.Fprintf(out, "* %s (active)\n", version) } else { - fmt.Printf(" %s\n", version) + fmt.Fprintf(out, " %s\n", version) } foundVersions = true } } if !foundVersions && !cfg.Development.Enabled { - fmt.Println("No versions installed") + fmt.Fprintln(out, "No versions installed") } return nil diff --git a/pkg/cmd/list_remote.go b/pkg/cmd/list_remote.go index 4000256..61df344 100644 --- a/pkg/cmd/list_remote.go +++ b/pkg/cmd/list_remote.go @@ -36,6 +36,8 @@ func newListRemoteCommand(d *deps) *cobra.Command { SilenceErrors: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() + releases, err := d.gh.ListReleases() if err != nil { return fmt.Errorf("failed to fetch releases: %w", err) @@ -60,13 +62,13 @@ func newListRemoteCommand(d *deps) *cobra.Command { } // Print versions - fmt.Println("Available versions:") + fmt.Fprintln(out, "Available versions:") for _, version := range versions { - fmt.Printf("- %s\n", version) + fmt.Fprintf(out, "- %s\n", version) } if len(versions) == 0 { - fmt.Println("No versions found") + fmt.Fprintln(out, "No versions found") } return nil diff --git a/pkg/cmd/list_remote_test.go b/pkg/cmd/list_remote_test.go new file mode 100644 index 0000000..061e49f --- /dev/null +++ b/pkg/cmd/list_remote_test.go @@ -0,0 +1,26 @@ +package cmd_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" + "github.com/educates/educatesenv/internal/fakegithub" +) + +func TestListRemoteListsStableReleasesFromGitHub(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases( + fakegithub.Release{Tag: "4.0.0-alpha.1", Prerelease: true}, + fakegithub.Release{Tag: "3.8.0"}, + fakegithub.Release{Tag: "3.7.0"}, + ) + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + assert.Equal(t, "Available versions:\n- 3.8.0\n- 3.7.0\n", res.Stdout) + assert.Empty(t, res.Stderr) +} diff --git a/pkg/cmd/list_test.go b/pkg/cmd/list_test.go new file mode 100644 index 0000000..5d10dc3 --- /dev/null +++ b/pkg/cmd/list_test.go @@ -0,0 +1,32 @@ +package cmd_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +func TestListMarksTheActiveVersion(t *testing.T) { + env := clitest.New(t) + env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, "Installed versions:\n* 3.8.0 (active)\n", res.Stdout) + assert.Empty(t, res.Stderr) +} + +func TestListSaysWhenNoVersionIsInstalled(t *testing.T) { + env := clitest.New(t) + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, "Installed versions:\nNo versions installed\n", res.Stdout) +} diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 6ef8848..59e00e4 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "io" "github.com/spf13/cobra" @@ -35,8 +36,8 @@ type deps struct { } // load reads the configuration and creates the GitHub client and the -// version manager. -func (d *deps) load() error { +// version manager, which reports progress to out. +func (d *deps) load(out io.Writer) error { d.cfg = config.New() if err := d.cfg.Load(); err != nil { return err @@ -48,7 +49,7 @@ func (d *deps) load() error { return err } - d.manager = version.New(d.cfg, d.gh) + d.manager = version.New(d.cfg, d.gh, out) return nil } @@ -65,7 +66,7 @@ func NewRootCommand(opts ...Option) *cobra.Command { Short: "Manage multiple versions of the educates binary", Long: `A version manager for educates, inspired by tfenv.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if err := d.load(); err != nil { + if err := d.load(cmd.OutOrStdout()); err != nil { return err } @@ -76,7 +77,7 @@ func NewRootCommand(opts ...Option) *cobra.Command { // Validate development mode configuration if err := d.manager.ValidateDevelopmentMode(); err != nil { - fmt.Printf("Warning: %v\n", err) + fmt.Fprintf(cmd.OutOrStdout(), "Warning: %v\n", err) } return nil }, diff --git a/pkg/cmd/use.go b/pkg/cmd/use.go index 41c6027..8d39324 100644 --- a/pkg/cmd/use.go +++ b/pkg/cmd/use.go @@ -38,7 +38,7 @@ func newUseCommand(d *deps) *cobra.Command { return fmt.Errorf("failed to switch to development version: %w", err) } - fmt.Printf("Now using educates development version from %s\n", d.cfg.Development.BinaryLocation) + fmt.Fprintf(cmd.OutOrStdout(), "Now using educates development version from %s\n", d.cfg.Development.BinaryLocation) return nil } @@ -47,7 +47,7 @@ func newUseCommand(d *deps) *cobra.Command { return fmt.Errorf("version %s is not installed. You should install it first with `educatesenv install %s`", version, version) } - fmt.Printf("Now using educates version %s\n", version) + fmt.Fprintf(cmd.OutOrStdout(), "Now using educates version %s\n", version) return nil }, } diff --git a/pkg/cmd/use_test.go b/pkg/cmd/use_test.go new file mode 100644 index 0000000..53968ef --- /dev/null +++ b/pkg/cmd/use_test.go @@ -0,0 +1,52 @@ +package cmd_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +func TestUseActivatesAnInstalledVersion(t *testing.T) { + env := clitest.New(t) + env.WriteBinary("3.7.0") + env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.7.0", env.ActiveLink())) + + res := env.Run("use", "3.8.0") + + require.NoError(t, res.Err) + assert.Equal(t, "Now using educates version 3.8.0\n", res.Stdout) + assert.Empty(t, res.Stderr) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestUseDevelopRequiresDevelopmentMode(t *testing.T) { + env := clitest.New(t) + + res := env.Run("use", "develop") + + require.EqualError(t, res.Err, "development mode is not enabled. Enable it in the config file by setting development.enabled to true") + assert.NoFileExists(t, env.ActiveLink()) +} + +func TestUseDevelopActivatesTheDevelopmentBinary(t *testing.T) { + env := clitest.New(t) + devBinary := writeDevelopmentBinary(t, env) + env.WriteConfig("development:\n enabled: true\n binaryLocation: " + devBinary + "\n") + require.NoError(t, os.MkdirAll(env.BinDir(), 0o755)) + + res := env.Run("use", "develop") + + require.NoError(t, res.Err) + assert.Equal(t, "Now using educates development version from "+devBinary+"\n", res.Stdout) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, devBinary, filepath.Join(env.BinDir(), target)) +} diff --git a/pkg/version/manager.go b/pkg/version/manager.go index 12a8ffb..ad134ae 100644 --- a/pkg/version/manager.go +++ b/pkg/version/manager.go @@ -18,13 +18,15 @@ import ( type Manager struct { config *config.Config github *github.Client + out io.Writer } -// New creates a new version manager -func New(cfg *config.Config, gh *github.Client) *Manager { +// New creates a new version manager that reports progress to out +func New(cfg *config.Config, gh *github.Client, out io.Writer) *Manager { return &Manager{ config: cfg, github: gh, + out: out, } } @@ -121,12 +123,12 @@ func (m *Manager) InstallVersion(version string, force bool, activate bool) erro // Handle installation if versionExists && !force { - fmt.Printf("Version %s is already installed.\n", version) + fmt.Fprintf(m.out, "Version %s is already installed.\n", version) } else { if versionExists { - fmt.Printf("Reinstalling version %s...\n", version) + fmt.Fprintf(m.out, "Reinstalling version %s...\n", version) } else { - fmt.Printf("Installing version %s...\n", version) + fmt.Fprintf(m.out, "Installing version %s...\n", version) } assetName, err := m.GetPlatformBinaryName() @@ -139,14 +141,14 @@ func (m *Manager) InstallVersion(version string, force bool, activate bool) erro return err // Pass through the user-friendly error from GitHub client } - fmt.Printf("Downloading %s...\n", downloadURL) + fmt.Fprintf(m.out, "Downloading %s...\n", downloadURL) if err := m.downloadFile(downloadURL, binaryPath); err != nil { return fmt.Errorf("failed to download binary (check your internet connection and try again): %w", err) } if err := os.Chmod(binaryPath, 0o755); err != nil { return fmt.Errorf("failed to set executable permissions on %s: %w", binaryPath, err) } - fmt.Printf("educates %s installed successfully.\n", version) + fmt.Fprintf(m.out, "educates %s installed successfully.\n", version) } // Handle activation if requested @@ -154,7 +156,7 @@ func (m *Manager) InstallVersion(version string, force bool, activate bool) erro if err := m.UseVersion(version); err != nil { return fmt.Errorf("installation succeeded but failed to set version %s as active: %w", version, err) } - fmt.Printf("educates %s is now active.\n", version) + fmt.Fprintf(m.out, "educates %s is now active.\n", version) } return nil diff --git a/pkg/version/manager_test.go b/pkg/version/manager_test.go deleted file mode 100644 index 66d7838..0000000 --- a/pkg/version/manager_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package version - -import ( - "os" - "path/filepath" - "testing" - - "github.com/educates/educatesenv/pkg/config" - "github.com/educates/educatesenv/pkg/github" - "github.com/stretchr/testify/assert" -) - -func setupTestManager(t *testing.T) (*Manager, string, func()) { - // Create temp directory - tmpDir, err := os.MkdirTemp("", "educatesenv-test") - assert.NoError(t, err) - - // Create config - cfg := &config.Config{ - Github: config.GithubConfig{ - Org: "testorg", - Repository: "testrepo", - Token: "testtoken", - }, - Local: config.LocalConfig{ - Dir: tmpDir, - }, - Development: config.DevelopmentConfig{ - Enabled: false, - BinaryLocation: "", - }, - } - - // Create GitHub client - gh, err := github.New(cfg) - assert.NoError(t, err) - - // Create manager - manager := New(cfg, gh) - - cleanup := func() { - err := os.RemoveAll(tmpDir) - assert.NoError(t, err) - } - - return manager, tmpDir, cleanup -} - -func TestValidateDevelopmentMode(t *testing.T) { - manager, tmpDir, cleanup := setupTestManager(t) - defer cleanup() - - // Test with no symlink - err := manager.ValidateDevelopmentMode() - assert.NoError(t, err) - - // Test with regular symlink - binaryPath := filepath.Join(tmpDir, "educates-v1.0.0") - err = os.WriteFile(binaryPath, []byte("test binary"), 0755) - assert.NoError(t, err) - - symlinkPath := filepath.Join(tmpDir, "educates") - err = os.Symlink(binaryPath, symlinkPath) - assert.NoError(t, err) - - err = manager.ValidateDevelopmentMode() - assert.NoError(t, err) - - // Test with development symlink - err = os.Remove(symlinkPath) - assert.NoError(t, err) - - devBinaryPath := "/usr/local/bin/educates-dev" - err = os.Symlink(devBinaryPath, symlinkPath) - assert.NoError(t, err) - - err = manager.ValidateDevelopmentMode() - assert.Error(t, err) - assert.Contains(t, err.Error(), "development mode is disabled") -} - -func TestUseVersion(t *testing.T) { - manager, tmpDir, cleanup := setupTestManager(t) - defer cleanup() - - // Create test binary - version := "v1.0.0" - binaryPath := filepath.Join(tmpDir, "educates-"+version) - err := os.WriteFile(binaryPath, []byte("test binary"), 0755) - assert.NoError(t, err) - - // Test using version - err = manager.UseVersion(version) - assert.NoError(t, err) - - // Verify symlink - symlinkPath := filepath.Join(tmpDir, "educates") - target, err := os.Readlink(symlinkPath) - assert.NoError(t, err) - assert.Equal(t, "educates-"+version, filepath.Base(target)) - - // Test using development version when disabled - err = manager.UseVersion("develop") - assert.Error(t, err) - assert.Contains(t, err.Error(), "development mode is not enabled") -} - -// TODO: Implement proper mocking for GitHub client -// func TestInstallVersion(t *testing.T) { -// ... -// } From 612c77096ef485ec6f31edd4ca5fd437fe5c3a38 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:50:20 +0100 Subject: [PATCH 18/75] fix(config): warn about a missing home directory through the caller's writer New and Load each printed the warning to the process's stdout, so a command without HOME printed it twice. Load now writes it once to the writer it is given, which the root command sets to cobra's stdout. --- pkg/cmd/config_test.go | 10 ++++++++++ pkg/cmd/root.go | 4 ++-- pkg/config/config.go | 12 +++++++----- pkg/config/config_test.go | 5 +++-- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/config_test.go b/pkg/cmd/config_test.go index 13cc056..a90f86e 100644 --- a/pkg/cmd/config_test.go +++ b/pkg/cmd/config_test.go @@ -36,6 +36,16 @@ func TestEachRunReadsTheConfigAfresh(t *testing.T) { assert.Contains(t, res.Stdout, "org: educates\n") } +func TestAMissingHomeDirectoryWarnsOnce(t *testing.T) { + env := clitest.New(t) + env.Unsetenv("HOME") + + res := env.Run("version") + + require.NoError(t, res.Err) + assert.Equal(t, "Warning: could not determine home directory, using current directory for bin folder.\ndevelop\n", res.Stdout) +} + func TestConfigInitCreatesTheConfigFile(t *testing.T) { env := clitest.New(t) configPath := filepath.Join(env.ConfigDir(), "config.yaml") diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 59e00e4..e595f2a 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -36,10 +36,10 @@ type deps struct { } // load reads the configuration and creates the GitHub client and the -// version manager, which reports progress to out. +// version manager. Warnings and the manager's progress go to out. func (d *deps) load(out io.Writer) error { d.cfg = config.New() - if err := d.cfg.Load(); err != nil { + if err := d.cfg.Load(out); err != nil { return err } diff --git a/pkg/config/config.go b/pkg/config/config.go index 062e364..3e0031f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "io" "os" "path/filepath" @@ -43,11 +44,11 @@ type Config struct { Development DevelopmentConfig `yaml:"development"` } -// New returns a new Config instance with defaults set +// New returns a new Config instance with defaults set. Without a home +// directory, the bin folder is under the current directory. func New() *Config { home, err := os.UserHomeDir() if err != nil { - fmt.Println("Warning: could not determine home directory, using current directory for bin folder.") home = "." } configDir := filepath.Join(home, ConfigDirName) @@ -69,11 +70,12 @@ func New() *Config { } } -// Load initializes the configuration from file and environment variables -func (c *Config) Load() error { +// Load initializes the configuration from file and environment variables. +// It writes a warning to out when there is no home directory. +func (c *Config) Load(out io.Writer) error { home, err := os.UserHomeDir() if err != nil { - fmt.Println("Warning: could not determine home directory, using current directory for bin folder.") + fmt.Fprintln(out, "Warning: could not determine home directory, using current directory for bin folder.") home = "." } configDir := filepath.Join(home, ConfigDirName) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 24b13a2..a262664 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "io" "os" "path/filepath" "testing" @@ -61,7 +62,7 @@ development: // Test loading config from file cfg := New() - err = cfg.Load() + err = cfg.Load(io.Discard) assert.NoError(t, err) assert.Equal(t, "testorg", cfg.Github.Org) assert.Equal(t, "testrepo", cfg.Github.Repository) @@ -94,7 +95,7 @@ func TestLoadWithEnvVars(t *testing.T) { // Test loading config with environment variables cfg := New() - err := cfg.Load() + err := cfg.Load(io.Discard) assert.NoError(t, err) assert.Equal(t, "envorg", cfg.Github.Org) assert.Equal(t, "envrepo", cfg.Github.Repository) From b66314fdb052a474afe39ded85587ca1b47235a5 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:50:32 +0100 Subject: [PATCH 19/75] test(config): keep the config tests out of the real home directory TestLoadWithEnvVars read the developer's own ~/.educatesenv/config.yaml, since the config is also searched for in the home directory. Each test now sets HOME to a temporary directory. --- pkg/config/config_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index a262664..cbb15aa 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -10,6 +10,7 @@ import ( ) func TestNew(t *testing.T) { + t.Setenv("HOME", t.TempDir()) cfg := New() assert.NotNil(t, cfg) assert.Equal(t, DefaultGithubOrg, cfg.Github.Org) @@ -27,6 +28,8 @@ func TestNew(t *testing.T) { } func TestLoad(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + // Setup temporary directory for test config tmpDir, err := os.MkdirTemp("", "educatesenv-test") assert.NoError(t, err) @@ -73,6 +76,8 @@ development: } func TestLoadWithEnvVars(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + // Set environment variables envVars := map[string]string{ "EDUCATES_GITHUB_ORG": "envorg", From 43b541d804991a6945b4d943ebec01f5e53ad3a7 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:52:00 +0100 Subject: [PATCH 20/75] style: ignore output write errors explicitly errcheck in golangci-lint v2.15.0 reports unchecked fmt.Fprintf and fmt.Fprintln calls on an io.Writer. A failed write to the command's own output leaves nothing to report to, so each call discards the result with _, _ =. The fake server test takes the base URL's address instead of github.Ptr, which govet's inline analyzer reports. --- cmd/educatesenv/main.go | 2 +- internal/fakegithub/server_test.go | 3 ++- pkg/cmd/config.go | 6 +++--- pkg/cmd/init.go | 26 +++++++++++++------------- pkg/cmd/list.go | 12 ++++++------ pkg/cmd/list_remote.go | 6 +++--- pkg/cmd/root.go | 2 +- pkg/cmd/use.go | 4 ++-- pkg/cmd/version.go | 4 ++-- pkg/config/config.go | 2 +- pkg/version/manager.go | 12 ++++++------ 11 files changed, 40 insertions(+), 39 deletions(-) diff --git a/cmd/educatesenv/main.go b/cmd/educatesenv/main.go index a41f4a3..460ff35 100644 --- a/cmd/educatesenv/main.go +++ b/cmd/educatesenv/main.go @@ -10,7 +10,7 @@ import ( func main() { root := cmd.NewRootCommand() if err := root.Execute(); err != nil { - fmt.Fprintf(root.ErrOrStderr(), "Error: %v\n", err) + _, _ = fmt.Fprintf(root.ErrOrStderr(), "Error: %v\n", err) os.Exit(1) } } diff --git a/internal/fakegithub/server_test.go b/internal/fakegithub/server_test.go index 160487f..da14218 100644 --- a/internal/fakegithub/server_test.go +++ b/internal/fakegithub/server_test.go @@ -22,7 +22,8 @@ const ( func newClient(t *testing.T, srv *fakegithub.Server, opts ...github.ClientOptionsFunc) *github.Client { t.Helper() - client, err := github.NewClient(append([]github.ClientOptionsFunc{github.WithURLs(github.Ptr(srv.URL), nil)}, opts...)...) + baseURL := srv.URL + client, err := github.NewClient(append([]github.ClientOptionsFunc{github.WithURLs(&baseURL, nil)}, opts...)...) require.NoError(t, err) return client } diff --git a/pkg/cmd/config.go b/pkg/cmd/config.go index fd3b514..ea20854 100644 --- a/pkg/cmd/config.go +++ b/pkg/cmd/config.go @@ -62,7 +62,7 @@ func newConfigInitCommand() *cobra.Command { return fmt.Errorf("failed to write config file: %w", err) } - fmt.Fprintf(cmd.OutOrStdout(), "Config file created at %s\n", configPath) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config file created at %s\n", configPath) return nil }, } @@ -84,8 +84,8 @@ func newConfigViewCommand(d *deps) *cobra.Command { // Print the configuration out := cmd.OutOrStdout() - fmt.Fprintln(out, "Current configuration:") - fmt.Fprintln(out, string(yamlBytes)) + _, _ = fmt.Fprintln(out, "Current configuration:") + _, _ = fmt.Fprintln(out, string(yamlBytes)) return nil }, } diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index 10552ef..5405448 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -31,41 +31,41 @@ func newInitCommand(d *deps) *cobra.Command { } if configCreated { - fmt.Fprintf(out, "Config file created at %s\n", configPath) + _, _ = fmt.Fprintf(out, "Config file created at %s\n", configPath) } else { - fmt.Fprintf(out, "Config file already exists at %s\n", configPath) + _, _ = fmt.Fprintf(out, "Config file already exists at %s\n", configPath) } - fmt.Fprintf(out, "Bin directory ensured at %s\n", defaultBin) + _, _ = fmt.Fprintf(out, "Bin directory ensured at %s\n", defaultBin) // Print PATH instructions - fmt.Fprintln(out, "\nTo use educatesenv, add the bin directory to your PATH:") + _, _ = fmt.Fprintln(out, "\nTo use educatesenv, add the bin directory to your PATH:") var pathCmd string switch runtime.GOOS { case platform.Darwin, platform.Linux: pathCmd = fmt.Sprintf("echo 'export PATH=\"%s:$PATH\"' >> ~/.bashrc\nsource ~/.bashrc", defaultBin) - fmt.Fprintf(out, "\nFor bash/zsh, run:\n %s\n", pathCmd) - fmt.Fprintf(out, "\nFor fish shell, run:\n set -U fish_user_paths %s $fish_user_paths\n", defaultBin) + _, _ = fmt.Fprintf(out, "\nFor bash/zsh, run:\n %s\n", pathCmd) + _, _ = fmt.Fprintf(out, "\nFor fish shell, run:\n set -U fish_user_paths %s $fish_user_paths\n", defaultBin) case platform.Windows: - fmt.Fprintf(out, "\nFor Windows (PowerShell), run:\n [Environment]::SetEnvironmentVariable(\"Path\", \"%s;\" + $Env:Path, [EnvironmentVariableTarget]::User)\n", defaultBin) + _, _ = fmt.Fprintf(out, "\nFor Windows (PowerShell), run:\n [Environment]::SetEnvironmentVariable(\"Path\", \"%s;\" + $Env:Path, [EnvironmentVariableTarget]::User)\n", defaultBin) default: - fmt.Fprintf(out, "\nAdd %s to your PATH manually.\n", defaultBin) + _, _ = fmt.Fprintf(out, "\nAdd %s to your PATH manually.\n", defaultBin) } - fmt.Fprintln(out, "\nRestart your terminal or source your profile to apply the changes.") + _, _ = fmt.Fprintln(out, "\nRestart your terminal or source your profile to apply the changes.") if downloadLatest { - fmt.Fprintln(out, "\nFetching latest educates version...") + _, _ = fmt.Fprintln(out, "\nFetching latest educates version...") latest, err := d.gh.GetLatestReleaseVersion() if err != nil { return fmt.Errorf("failed to get latest release version: %w", err) } - fmt.Fprintf(out, "Latest version: %s\n", latest) + _, _ = fmt.Fprintf(out, "Latest version: %s\n", latest) if err := d.manager.InstallVersion(latest, overwrite, true); err != nil { return err } } else { - fmt.Fprintln(out, "\nRun 'educatesenv install ' to install a specific version") - fmt.Fprintln(out, "Run 'educatesenv list-remote' to see available versions") + _, _ = fmt.Fprintln(out, "\nRun 'educatesenv install ' to install a specific version") + _, _ = fmt.Fprintln(out, "Run 'educatesenv list-remote' to see available versions") } return nil }, diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 00ac967..924f30e 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -52,14 +52,14 @@ func runList(d *deps) func(cmd *cobra.Command, args []string) error { } // Print installed versions - fmt.Fprintln(out, "Installed versions:") + _, _ = fmt.Fprintln(out, "Installed versions:") // Show development version if enabled if cfg.Development.Enabled { if isDevelopmentActive { - fmt.Fprintf(out, "* develop (active) -> %s\n", cfg.Development.BinaryLocation) + _, _ = fmt.Fprintf(out, "* develop (active) -> %s\n", cfg.Development.BinaryLocation) } else { - fmt.Fprintf(out, " develop -> %s\n", cfg.Development.BinaryLocation) + _, _ = fmt.Fprintf(out, " develop -> %s\n", cfg.Development.BinaryLocation) } } @@ -72,16 +72,16 @@ func runList(d *deps) func(cmd *cobra.Command, args []string) error { if strings.HasPrefix(file.Name(), "educates-") { version := strings.TrimPrefix(file.Name(), "educates-") if version == activeVersion { - fmt.Fprintf(out, "* %s (active)\n", version) + _, _ = fmt.Fprintf(out, "* %s (active)\n", version) } else { - fmt.Fprintf(out, " %s\n", version) + _, _ = fmt.Fprintf(out, " %s\n", version) } foundVersions = true } } if !foundVersions && !cfg.Development.Enabled { - fmt.Fprintln(out, "No versions installed") + _, _ = fmt.Fprintln(out, "No versions installed") } return nil diff --git a/pkg/cmd/list_remote.go b/pkg/cmd/list_remote.go index 61df344..9f4eb70 100644 --- a/pkg/cmd/list_remote.go +++ b/pkg/cmd/list_remote.go @@ -62,13 +62,13 @@ func newListRemoteCommand(d *deps) *cobra.Command { } // Print versions - fmt.Fprintln(out, "Available versions:") + _, _ = fmt.Fprintln(out, "Available versions:") for _, version := range versions { - fmt.Fprintf(out, "- %s\n", version) + _, _ = fmt.Fprintf(out, "- %s\n", version) } if len(versions) == 0 { - fmt.Fprintln(out, "No versions found") + _, _ = fmt.Fprintln(out, "No versions found") } return nil diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index e595f2a..5c951c5 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -77,7 +77,7 @@ func NewRootCommand(opts ...Option) *cobra.Command { // Validate development mode configuration if err := d.manager.ValidateDevelopmentMode(); err != nil { - fmt.Fprintf(cmd.OutOrStdout(), "Warning: %v\n", err) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Warning: %v\n", err) } return nil }, diff --git a/pkg/cmd/use.go b/pkg/cmd/use.go index 8d39324..14214e8 100644 --- a/pkg/cmd/use.go +++ b/pkg/cmd/use.go @@ -38,7 +38,7 @@ func newUseCommand(d *deps) *cobra.Command { return fmt.Errorf("failed to switch to development version: %w", err) } - fmt.Fprintf(cmd.OutOrStdout(), "Now using educates development version from %s\n", d.cfg.Development.BinaryLocation) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Now using educates development version from %s\n", d.cfg.Development.BinaryLocation) return nil } @@ -47,7 +47,7 @@ func newUseCommand(d *deps) *cobra.Command { return fmt.Errorf("version %s is not installed. You should install it first with `educatesenv install %s`", version, version) } - fmt.Fprintf(cmd.OutOrStdout(), "Now using educates version %s\n", version) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Now using educates version %s\n", version) return nil }, } diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index eae519e..4392cc0 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -15,8 +15,8 @@ func newVersionCommand() *cobra.Command { SilenceErrors: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { - _, err := fmt.Fprintln(cmd.OutOrStdout(), version.Version) - return err + _, _ = fmt.Fprintln(cmd.OutOrStdout(), version.Version) + return nil }, } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 3e0031f..5f5f680 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -75,7 +75,7 @@ func New() *Config { func (c *Config) Load(out io.Writer) error { home, err := os.UserHomeDir() if err != nil { - fmt.Fprintln(out, "Warning: could not determine home directory, using current directory for bin folder.") + _, _ = fmt.Fprintln(out, "Warning: could not determine home directory, using current directory for bin folder.") home = "." } configDir := filepath.Join(home, ConfigDirName) diff --git a/pkg/version/manager.go b/pkg/version/manager.go index ad134ae..58bbadb 100644 --- a/pkg/version/manager.go +++ b/pkg/version/manager.go @@ -123,12 +123,12 @@ func (m *Manager) InstallVersion(version string, force bool, activate bool) erro // Handle installation if versionExists && !force { - fmt.Fprintf(m.out, "Version %s is already installed.\n", version) + _, _ = fmt.Fprintf(m.out, "Version %s is already installed.\n", version) } else { if versionExists { - fmt.Fprintf(m.out, "Reinstalling version %s...\n", version) + _, _ = fmt.Fprintf(m.out, "Reinstalling version %s...\n", version) } else { - fmt.Fprintf(m.out, "Installing version %s...\n", version) + _, _ = fmt.Fprintf(m.out, "Installing version %s...\n", version) } assetName, err := m.GetPlatformBinaryName() @@ -141,14 +141,14 @@ func (m *Manager) InstallVersion(version string, force bool, activate bool) erro return err // Pass through the user-friendly error from GitHub client } - fmt.Fprintf(m.out, "Downloading %s...\n", downloadURL) + _, _ = fmt.Fprintf(m.out, "Downloading %s...\n", downloadURL) if err := m.downloadFile(downloadURL, binaryPath); err != nil { return fmt.Errorf("failed to download binary (check your internet connection and try again): %w", err) } if err := os.Chmod(binaryPath, 0o755); err != nil { return fmt.Errorf("failed to set executable permissions on %s: %w", binaryPath, err) } - fmt.Fprintf(m.out, "educates %s installed successfully.\n", version) + _, _ = fmt.Fprintf(m.out, "educates %s installed successfully.\n", version) } // Handle activation if requested @@ -156,7 +156,7 @@ func (m *Manager) InstallVersion(version string, force bool, activate bool) erro if err := m.UseVersion(version); err != nil { return fmt.Errorf("installation succeeded but failed to set version %s as active: %w", version, err) } - fmt.Fprintf(m.out, "educates %s is now active.\n", version) + _, _ = fmt.Fprintf(m.out, "educates %s is now active.\n", version) } return nil From 784e61b4a38cdbb095b6f9868acb5cda01a81f30 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:59:07 +0100 Subject: [PATCH 21/75] fix(cmd): clean up the development symlink only in commands that deal with the active binary list, use, install and init remove a development symlink left behind when development mode is disabled. version, config and list-remote no longer touch the educates symlink. --- pkg/cmd/development_test.go | 61 +++++++++++++++++++++++++++++++++++++ pkg/cmd/init.go | 1 + pkg/cmd/install.go | 1 + pkg/cmd/list.go | 1 + pkg/cmd/list_local.go | 1 + pkg/cmd/root.go | 25 +++++++-------- pkg/cmd/use.go | 1 + 7 files changed, 77 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/development_test.go b/pkg/cmd/development_test.go index 678115e..a741cd8 100644 --- a/pkg/cmd/development_test.go +++ b/pkg/cmd/development_test.go @@ -3,6 +3,7 @@ package cmd_test import ( "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -21,6 +22,66 @@ func writeDevelopmentBinary(t *testing.T, env *clitest.Env) string { return path } +// linkDevelopmentBinary writes a development binary and points the +// educates symlink at it, and returns the binary's path. +func linkDevelopmentBinary(t *testing.T, env *clitest.Env) string { + t.Helper() + devBinary := writeDevelopmentBinary(t, env) + require.NoError(t, os.MkdirAll(env.BinDir(), 0o755)) + require.NoError(t, os.Symlink(devBinary, env.ActiveLink())) + return devBinary +} + +func TestCommandsThatDealWithTheActiveBinaryRemoveTheDevelopmentSymlink(t *testing.T) { + tests := [][]string{ + {"list"}, + {"list-local"}, + {"use", "3.8.0"}, + {"install", "3.8.0"}, + {"init"}, + } + + for _, command := range tests { + t.Run(strings.Join(command, " "), func(t *testing.T) { + env := clitest.New(t) + env.WriteBinary("3.8.0") + devBinary := linkDevelopmentBinary(t, env) + + res := env.Run(command...) + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Warning: development mode is disabled; removed symlink to development binary.") + target, _ := os.Readlink(env.ActiveLink()) + assert.NotEqual(t, devBinary, target) + assert.FileExists(t, devBinary) + }) + } +} + +func TestOtherCommandsKeepTheDevelopmentSymlink(t *testing.T) { + tests := [][]string{ + {"version"}, + {"config", "view"}, + {"config", "init"}, + {"list-remote"}, + } + + for _, command := range tests { + t.Run(strings.Join(command, " "), func(t *testing.T) { + env := clitest.New(t) + devBinary := linkDevelopmentBinary(t, env) + + res := env.Run(command...) + + require.NoError(t, res.Err) + assert.NotContains(t, res.Stdout, "development mode is disabled") + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, devBinary, target) + }) + } +} + func TestDisabledDevelopmentModeRemovesTheDevelopmentSymlink(t *testing.T) { env := clitest.New(t) devBinary := writeDevelopmentBinary(t, env) diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index 5405448..f56ecc8 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -22,6 +22,7 @@ func newInitCommand(d *deps) *cobra.Command { Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, + PreRun: d.cleanUpDevelopmentSymlink, RunE: func(cmd *cobra.Command, args []string) error { out := cmd.OutOrStdout() diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index c70a9ef..b4fce42 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -21,6 +21,7 @@ func newInstallCommand(d *deps) *cobra.Command { Args: cobra.ExactArgs(1), SilenceErrors: true, SilenceUsage: true, + PreRun: d.cleanUpDevelopmentSymlink, RunE: func(cmd *cobra.Command, args []string) error { version := args[0] diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 924f30e..e73ce5d 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -17,6 +17,7 @@ func newListCommand(d *deps) *cobra.Command { Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, + PreRun: d.cleanUpDevelopmentSymlink, RunE: runList(d), } } diff --git a/pkg/cmd/list_local.go b/pkg/cmd/list_local.go index 50bd14e..53fbc3b 100644 --- a/pkg/cmd/list_local.go +++ b/pkg/cmd/list_local.go @@ -12,6 +12,7 @@ func newListLocalCommand(d *deps) *cobra.Command { Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, + PreRun: d.cleanUpDevelopmentSymlink, RunE: runList(d), } } diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 5c951c5..a299064 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -53,6 +53,16 @@ func (d *deps) load(out io.Writer) error { return nil } +// cleanUpDevelopmentSymlink removes the educates symlink when it points +// at a development binary while development mode is disabled, and warns +// about it. Only the commands that deal with the active binary run it, +// as their PreRun hook. +func (d *deps) cleanUpDevelopmentSymlink(cmd *cobra.Command, _ []string) { + if err := d.manager.ValidateDevelopmentMode(); err != nil { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Warning: %v\n", err) + } +} + // NewRootCommand builds the educatesenv command tree. Each call builds a // new tree with its own flag values and dependencies. func NewRootCommand(opts ...Option) *cobra.Command { @@ -66,20 +76,7 @@ func NewRootCommand(opts ...Option) *cobra.Command { Short: "Manage multiple versions of the educates binary", Long: `A version manager for educates, inspired by tfenv.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if err := d.load(cmd.OutOrStdout()); err != nil { - return err - } - - // Skip validation for help command - if cmd.Name() == "help" { - return nil - } - - // Validate development mode configuration - if err := d.manager.ValidateDevelopmentMode(); err != nil { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Warning: %v\n", err) - } - return nil + return d.load(cmd.OutOrStdout()) }, } diff --git a/pkg/cmd/use.go b/pkg/cmd/use.go index 14214e8..f547ac8 100644 --- a/pkg/cmd/use.go +++ b/pkg/cmd/use.go @@ -14,6 +14,7 @@ func newUseCommand(d *deps) *cobra.Command { Args: cobra.ExactArgs(1), SilenceErrors: true, SilenceUsage: true, + PreRun: d.cleanUpDevelopmentSymlink, RunE: func(cmd *cobra.Command, args []string) error { version := args[0] From a1da99edc1fc770e658936916d16c143372d8c2f Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 21:59:59 +0100 Subject: [PATCH 22/75] fix(version): compare the development symlink's target directory with the bin directory The check used a string prefix, so a target in a sibling directory such as bin2 counted as inside the bin directory. It now compares the cleaned directory paths. --- pkg/cmd/development_test.go | 35 +++++++++++++++++++++++++++++++++++ pkg/version/manager.go | 11 +++++++---- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/development_test.go b/pkg/cmd/development_test.go index a741cd8..a60e563 100644 --- a/pkg/cmd/development_test.go +++ b/pkg/cmd/development_test.go @@ -99,6 +99,41 @@ func TestDisabledDevelopmentModeRemovesTheDevelopmentSymlink(t *testing.T) { assert.FileExists(t, devBinary) } +func TestASymlinkIntoASiblingOfTheBinDirectoryCountsAsADevelopmentSymlink(t *testing.T) { + env := clitest.New(t) + sibling := env.BinDir() + "2" + siblingBinary := filepath.Join(sibling, "educates-3.8.0") + require.NoError(t, os.MkdirAll(sibling, 0o755)) + require.NoError(t, os.WriteFile(siblingBinary, []byte("educates 3.8.0"), 0o755)) + require.NoError(t, os.MkdirAll(env.BinDir(), 0o755)) + require.NoError(t, os.Symlink(filepath.Join("..", "bin2", "educates-3.8.0"), env.ActiveLink())) + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, + "Warning: development mode is disabled; removed symlink to development binary. Please use 'educatesenv use ' to select a version\n"+ + "Installed versions:\nNo versions installed\n", + res.Stdout) + assert.NoFileExists(t, env.ActiveLink()) + assert.FileExists(t, siblingBinary) +} + +func TestABinDirectoryWithATrailingSlashKeepsItsVersionSymlink(t *testing.T) { + env := clitest.New(t) + env.Setenv("EDUCATES_LOCAL_DIR", env.BinDir()+string(filepath.Separator)) + env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, "Installed versions:\n* 3.8.0 (active)\n", res.Stdout) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + func TestEnabledDevelopmentModeKeepsTheDevelopmentSymlink(t *testing.T) { env := clitest.New(t) devBinary := writeDevelopmentBinary(t, env) diff --git a/pkg/version/manager.go b/pkg/version/manager.go index 58bbadb..a83f0b3 100644 --- a/pkg/version/manager.go +++ b/pkg/version/manager.go @@ -67,10 +67,13 @@ func (m *Manager) isDevSymlink(symlinkPath string) (bool, error) { target = filepath.Join(filepath.Dir(symlinkPath), target) } - // A symlink is considered a development symlink if: - // 1. Development mode is disabled AND - // 2. The target is outside the managed bin directory OR doesn't have the expected binary prefix - return !strings.HasPrefix(target, m.config.Local.Dir) || !strings.HasPrefix(filepath.Base(target), platform.BinaryPrefix), nil + // A symlink is a development symlink when its target is not directly + // in the managed bin directory, or lacks the binary prefix. The + // directories are compared as cleaned paths, so a sibling directory + // whose name starts with the bin directory's counts as outside. + // filepath.Dir returns a cleaned path. + inBinDir := filepath.Dir(target) == filepath.Clean(m.config.Local.Dir) + return !inBinDir || !strings.HasPrefix(filepath.Base(target), platform.BinaryPrefix), nil } // UseVersion sets a version as active From 480af86337caa4aeb0fee5d0b183041467cee4e7 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:00:25 +0100 Subject: [PATCH 23/75] feat(releases): select and order Educates releases by semver or publish time A new pure package holds the release type and the selection rules: a release is a pre-release when its tag has a semver pre-release part or GitHub flags it, drafts and tags that are not full semver never take part, and selection orders by semver or publish time, applies a limit and reports the size of the candidate set. --- go.mod | 1 + go.sum | 2 + pkg/releases/releases.go | 95 ++++++++++++ pkg/releases/releases_test.go | 275 ++++++++++++++++++++++++++++++++++ 4 files changed, 373 insertions(+) create mode 100644 pkg/releases/releases.go create mode 100644 pkg/releases/releases_test.go diff --git a/go.mod b/go.mod index 7db0434..8cf03bd 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.12.1 + golang.org/x/mod v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 56e07f2..70578ee 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTV go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= diff --git a/pkg/releases/releases.go b/pkg/releases/releases.go new file mode 100644 index 0000000..7bd6657 --- /dev/null +++ b/pkg/releases/releases.go @@ -0,0 +1,95 @@ +// Package releases holds the rules about Educates releases: which ones +// take part in a listing, which ones are pre-releases, and how they are +// ordered. It does no I/O. +package releases + +import ( + "slices" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +// Release is a GitHub release of Educates. +type Release struct { + // Tag is the release's tag as published, such as "3.8.0". + Tag string + // PublishedAt is when the release was published. + PublishedAt time.Time + // GitHubPrerelease is GitHub's pre-release flag. + GitHubPrerelease bool + // Draft is GitHub's draft flag. + Draft bool +} + +// Order is the order Select returns releases in. +type Order int + +const ( + // BySemver orders releases by version, highest first. + BySemver Order = iota + // ByPublishTime orders releases by publish time, newest first. + ByPublishTime +) + +// All is the Limit that chooses every candidate. +const All = 0 + +// Options are the choices Select makes. +type Options struct { + // IncludePrereleases adds pre-releases to the candidates. + IncludePrereleases bool + // Limit is the most releases to choose. All, or any value below 1, + // chooses every candidate. + Limit int + // Order is the order of the chosen releases. + Order Order +} + +// Select chooses releases from all. It returns the chosen releases, in +// the order opts names, and the number of candidates they were chosen +// from. +func Select(all []Release, opts Options) (chosen []Release, candidates int) { + for _, r := range all { + if r.Draft || !isSemver(r.Tag) { + continue + } + if r.isPrerelease() && !opts.IncludePrereleases { + continue + } + chosen = append(chosen, r) + } + slices.SortStableFunc(chosen, func(a, b Release) int { + if opts.Order == ByPublishTime { + return b.PublishedAt.Compare(a.PublishedAt) + } + return semver.Compare(version(b.Tag), version(a.Tag)) + }) + + candidates = len(chosen) + if opts.Limit > 0 && opts.Limit < candidates { + chosen = chosen[:opts.Limit] + } + return chosen, candidates +} + +// isPrerelease reports whether r is a pre-release: its tag has a semver +// pre-release part, or GitHub flags it. +func (r Release) isPrerelease() bool { + return r.GitHubPrerelease || semver.Prerelease(version(r.Tag)) != "" +} + +// version returns tag in the form golang.org/x/mod/semver reads, which +// needs a "v" prefix that Educates tags do not have. +func version(tag string) string { + return "v" + tag +} + +// isSemver reports whether tag is a full semver version, such as "3.8.0" +// or "4.0.0-alpha.13". The shorthands golang.org/x/mod/semver accepts, +// such as "3.8", are not. +func isSemver(tag string) bool { + v := version(tag) + return semver.Canonical(v) == strings.TrimSuffix(v, semver.Build(v)) +} diff --git a/pkg/releases/releases_test.go b/pkg/releases/releases_test.go new file mode 100644 index 0000000..48f8801 --- /dev/null +++ b/pkg/releases/releases_test.go @@ -0,0 +1,275 @@ +package releases_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/educates/educatesenv/pkg/releases" +) + +// day returns noon UTC on the given day of 2026, so tests can order +// releases by publish time without spelling out timestamps. +func day(month time.Month, d int) time.Time { + return time.Date(2026, month, d, 12, 0, 0, 0, time.UTC) +} + +// stable returns a published release that GitHub does not flag. +func stable(tag string) releases.Release { + return releases.Release{Tag: tag, PublishedAt: day(time.January, 1)} +} + +// tags returns the tags of rs, in order. +func tags(rs []releases.Release) []string { + out := make([]string, 0, len(rs)) + for _, r := range rs { + out = append(out, r.Tag) + } + return out +} + +func TestSelectLeavesOutPrereleasesUnlessIncluded(t *testing.T) { + all := []releases.Release{ + stable("3.8.0"), + {Tag: "3.8.0-rc.2", PublishedAt: day(time.August, 14), GitHubPrerelease: true}, + // Older pre-releases that GitHub never flagged. + stable("2.6.0-alpha.30"), + stable("2.6.0-beta.1"), + // GitHub flags it, though the tag has no pre-release part. + {Tag: "2.5.9", PublishedAt: day(time.January, 2), GitHubPrerelease: true}, + stable("2.5.0"), + } + + tests := []struct { + name string + includePrereleases bool + want []string + }{ + { + name: "stable only", + want: []string{"3.8.0", "2.5.0"}, + }, + { + name: "with pre-releases", + includePrereleases: true, + want: []string{"3.8.0", "3.8.0-rc.2", "2.6.0-beta.1", "2.6.0-alpha.30", "2.5.9", "2.5.0"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chosen, candidates := releases.Select(all, releases.Options{ + IncludePrereleases: tt.includePrereleases, + Limit: releases.All, + }) + + assert.Equal(t, tt.want, tags(chosen)) + assert.Equal(t, len(tt.want), candidates) + }) + } +} + +func TestSelectNeverChoosesDraftsOrTagsThatAreNotSemver(t *testing.T) { + all := []releases.Release{ + {Tag: "3.9.0", Draft: true}, + {Tag: "3.9.0-rc.1", Draft: true, GitHubPrerelease: true}, + stable("3.8.0"), + stable("latest"), + stable("v3.7.0"), + stable("3.8"), + stable("3.08.0"), + stable(""), + stable("3.7.0-rc.1"), + stable("3.6.0"), + } + + chosen, candidates := releases.Select(all, releases.Options{ + IncludePrereleases: true, + Limit: releases.All, + }) + + assert.Equal(t, []string{"3.8.0", "3.7.0-rc.1", "3.6.0"}, tags(chosen)) + assert.Equal(t, 3, candidates) +} + +func TestSelectByPublishTimeOrdersNewestFirst(t *testing.T) { + at := func(d, hour int) time.Time { + return time.Date(2026, time.August, d, hour, 0, 0, 0, time.UTC) + } + all := []releases.Release{ + {Tag: "4.0.0-alpha.12", PublishedAt: at(10, 9)}, + {Tag: "3.8.0-rc.12", PublishedAt: at(25, 9)}, + {Tag: "3.8.1-rc.2", PublishedAt: at(28, 9)}, + {Tag: "3.8.0", PublishedAt: at(26, 18)}, + {Tag: "3.8.0-rc.13", PublishedAt: at(25, 17)}, + {Tag: "4.0.0-alpha.13", PublishedAt: at(27, 9)}, + } + + chosen, _ := releases.Select(all, releases.Options{ + IncludePrereleases: true, + Limit: releases.All, + Order: releases.ByPublishTime, + }) + + assert.Equal(t, []string{ + "3.8.1-rc.2", + "4.0.0-alpha.13", + "3.8.0", + "3.8.0-rc.13", + "3.8.0-rc.12", + "4.0.0-alpha.12", + }, tags(chosen)) +} + +func TestSelectCombinesPrereleasesLimitAndOrder(t *testing.T) { + // Listed in creation order, newest first, as GitHub lists them. + all := []releases.Release{ + {Tag: "3.7.4", PublishedAt: day(time.September, 2)}, + {Tag: "3.8.1-rc.2", PublishedAt: day(time.August, 28), GitHubPrerelease: true}, + {Tag: "4.0.0-alpha.13", PublishedAt: day(time.August, 27), GitHubPrerelease: true}, + {Tag: "3.8.0", PublishedAt: day(time.August, 26)}, + {Tag: "3.8.0-rc.13", PublishedAt: day(time.August, 25), GitHubPrerelease: true}, + {Tag: "3.7.3", PublishedAt: day(time.July, 15)}, + {Tag: "4.0.0-alpha.2", PublishedAt: day(time.July, 1), GitHubPrerelease: true}, + {Tag: "3.6.0", PublishedAt: day(time.May, 1)}, + } + + tests := []struct { + name string + opts releases.Options + want []string + candidates int + }{ + { + name: "stable, 3, by semver", + opts: releases.Options{Limit: 3, Order: releases.BySemver}, + want: []string{"3.8.0", "3.7.4", "3.7.3"}, + candidates: 4, + }, + { + name: "stable, all, by semver", + opts: releases.Options{Limit: releases.All, Order: releases.BySemver}, + want: []string{"3.8.0", "3.7.4", "3.7.3", "3.6.0"}, + candidates: 4, + }, + { + name: "stable, 3, by publish time", + opts: releases.Options{Limit: 3, Order: releases.ByPublishTime}, + want: []string{"3.7.4", "3.8.0", "3.7.3"}, + candidates: 4, + }, + { + name: "stable, all, by publish time", + opts: releases.Options{Limit: releases.All, Order: releases.ByPublishTime}, + want: []string{"3.7.4", "3.8.0", "3.7.3", "3.6.0"}, + candidates: 4, + }, + { + name: "with pre-releases, 3, by semver", + opts: releases.Options{IncludePrereleases: true, Limit: 3, Order: releases.BySemver}, + want: []string{"4.0.0-alpha.13", "4.0.0-alpha.2", "3.8.1-rc.2"}, + candidates: 8, + }, + { + name: "with pre-releases, all, by semver", + opts: releases.Options{IncludePrereleases: true, Limit: releases.All, Order: releases.BySemver}, + want: []string{ + "4.0.0-alpha.13", "4.0.0-alpha.2", "3.8.1-rc.2", "3.8.0", + "3.8.0-rc.13", "3.7.4", "3.7.3", "3.6.0", + }, + candidates: 8, + }, + { + name: "with pre-releases, 3, by publish time", + opts: releases.Options{IncludePrereleases: true, Limit: 3, Order: releases.ByPublishTime}, + want: []string{"3.7.4", "3.8.1-rc.2", "4.0.0-alpha.13"}, + candidates: 8, + }, + { + name: "with pre-releases, all, by publish time", + opts: releases.Options{IncludePrereleases: true, Limit: releases.All, Order: releases.ByPublishTime}, + want: []string{ + "3.7.4", "3.8.1-rc.2", "4.0.0-alpha.13", "3.8.0", + "3.8.0-rc.13", "3.7.3", "4.0.0-alpha.2", "3.6.0", + }, + candidates: 8, + }, + { + name: "a limit above the number of candidates", + opts: releases.Options{Limit: 10}, + want: []string{"3.8.0", "3.7.4", "3.7.3", "3.6.0"}, + candidates: 4, + }, + { + name: "a limit equal to the number of candidates", + opts: releases.Options{Limit: 4}, + want: []string{"3.8.0", "3.7.4", "3.7.3", "3.6.0"}, + candidates: 4, + }, + { + name: "a negative limit", + opts: releases.Options{Limit: -1}, + want: []string{"3.8.0", "3.7.4", "3.7.3", "3.6.0"}, + candidates: 4, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chosen, candidates := releases.Select(all, tt.opts) + + assert.Equal(t, tt.want, tags(chosen)) + assert.Equal(t, tt.candidates, candidates) + }) + } +} + +func TestSelectLeavesItsInputAlone(t *testing.T) { + all := []releases.Release{stable("3.7.0"), stable("3.9.0-rc.1"), stable("3.8.0")} + + _, _ = releases.Select(all, releases.Options{Limit: 1}) + + assert.Equal(t, []string{"3.7.0", "3.9.0-rc.1", "3.8.0"}, tags(all)) +} + +func TestSelectOrdersBySemverHighestFirst(t *testing.T) { + tests := []struct { + name string + tags []string + want []string + }{ + { + name: "numeric minor versions", + tags: []string{"3.9.0", "3.10.0", "3.8.1"}, + want: []string{"3.10.0", "3.9.0", "3.8.1"}, + }, + { + name: "numeric pre-release identifiers", + tags: []string{"4.0.0-alpha.2", "4.0.0-alpha.13", "4.0.0-alpha.9"}, + want: []string{"4.0.0-alpha.13", "4.0.0-alpha.9", "4.0.0-alpha.2"}, + }, + { + name: "a release above its pre-releases", + tags: []string{"3.8.0-rc.13", "3.8.0", "3.8.0-rc.2", "3.7.4"}, + want: []string{"3.8.0", "3.8.0-rc.13", "3.8.0-rc.2", "3.7.4"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var all []releases.Release + for _, tag := range tt.tags { + all = append(all, stable(tag)) + } + + chosen, _ := releases.Select(all, releases.Options{ + IncludePrereleases: true, + Limit: releases.All, + Order: releases.BySemver, + }) + + assert.Equal(t, tt.want, tags(chosen)) + }) + } +} From 63f31be156577204fe74ad4d1bde9cc3c60458a6 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:00:44 +0100 Subject: [PATCH 24/75] test(fakegithub): cut an asset download mid-transfer CutNextDownload makes the next download of an asset announce its full length, send half of it and drop the connection, and serves later downloads whole, so that tests can interrupt a download and retry it. --- internal/fakegithub/downloads.go | 57 +++++++++++++++++++++++++++ internal/fakegithub/downloads_test.go | 40 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 internal/fakegithub/downloads.go create mode 100644 internal/fakegithub/downloads_test.go diff --git a/internal/fakegithub/downloads.go b/internal/fakegithub/downloads.go new file mode 100644 index 0000000..6274624 --- /dev/null +++ b/internal/fakegithub/downloads.go @@ -0,0 +1,57 @@ +package fakegithub + +import ( + "net/http" + "strconv" + "sync/atomic" +) + +// CutNextDownload makes the next download of the asset name of the release +// tagged tag fail mid-transfer: the response announces the asset's full +// length and sends the first half of it, then the server drops the +// connection. Later downloads of the asset are served whole. Calling it +// twice for the same asset panics, as Handle does. +func (s *Server) CutNextDownload(tag, name string) { + var cut atomic.Bool + s.Handle("GET /download/"+tag+"/"+name, func(w http.ResponseWriter, r *http.Request) { + if cut.Swap(true) { + s.routes.ServeHTTP(w, r) + return + } + + content, ok := s.assetContent(tag, name) + if !ok { + notFound(w) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(content))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content[:len(content)/2]) + rc := http.NewResponseController(w) + _ = rc.Flush() + conn, _, err := rc.Hijack() + if err != nil { + panic("fakegithub: cannot drop the connection: " + err.Error()) + } + _ = conn.Close() + }) +} + +// assetContent returns the content of the asset name of the release tagged +// tag. +func (s *Server) assetContent(tag, name string) ([]byte, bool) { + s.mu.Lock() + defer s.mu.Unlock() + for _, stored := range s.releases { + if stored.release.Tag != tag { + continue + } + for _, a := range stored.assets { + if a.asset.Name == name { + return a.asset.Content, true + } + } + } + return nil, false +} diff --git a/internal/fakegithub/downloads_test.go b/internal/fakegithub/downloads_test.go new file mode 100644 index 0000000..c79c2f8 --- /dev/null +++ b/internal/fakegithub/downloads_test.go @@ -0,0 +1,40 @@ +package fakegithub_test + +import ( + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/fakegithub" +) + +func get(t *testing.T, url string) (*http.Response, []byte, error) { + t.Helper() + resp, err := http.Get(url) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + return resp, body, err +} + +func TestCutsTheNextDownloadMidTransferAndServesLaterOnesWhole(t *testing.T) { + srv := fakegithub.New(t) + srv.AddReleases(fakegithub.Release{Tag: "3.8.0", Assets: []fakegithub.Asset{ + {Name: "educates-linux-amd64", Content: []byte("0123456789")}, + }}) + srv.CutNextDownload("3.8.0", "educates-linux-amd64") + + resp, body, err := get(t, srv.DownloadURL("3.8.0", "educates-linux-amd64")) + require.ErrorIs(t, err, io.ErrUnexpectedEOF) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, int64(10), resp.ContentLength) + assert.Equal(t, "01234", string(body)) + + resp, body, err = get(t, srv.DownloadURL("3.8.0", "educates-linux-amd64")) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "0123456789", string(body)) +} From 760c5f2be0f195b5232ee338e557db43786c16fa Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:00:48 +0100 Subject: [PATCH 25/75] fix(install): download into a temporary file and rename it into place An interrupted download left a partial binary that a retry then reported as already installed. The binary now downloads into a temporary file in the bin directory, is made executable and only then renamed into place, and the temporary file is removed when any step fails. --- pkg/cmd/install_test.go | 37 ++++++++++++++++++++++++ pkg/version/manager.go | 63 +++++++++++++++++++++++++++-------------- 2 files changed, 79 insertions(+), 21 deletions(-) diff --git a/pkg/cmd/install_test.go b/pkg/cmd/install_test.go index 9eca56b..5207075 100644 --- a/pkg/cmd/install_test.go +++ b/pkg/cmd/install_test.go @@ -61,3 +61,40 @@ func TestInstallOfAnUnknownVersionFails(t *testing.T) { require.EqualError(t, res.Err, "failed to install version 9.9.9: version 9.9.9 not found. Run 'educatesenv list-remote' to see available versions") assert.NoFileExists(t, filepath.Join(env.BinDir(), "educates-9.9.9")) } + +// binDirEntries returns the names of the files in the bin directory. +func binDirEntries(t *testing.T, env *clitest.Env) []string { + t.Helper() + entries, err := os.ReadDir(env.BinDir()) + if os.IsNotExist(err) { + return nil + } + require.NoError(t, err) + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + return names +} + +func TestInstallCutMidTransferLeavesNothingBehindAndARetryInstalls(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + env.GitHub.CutNextDownload("3.8.0", clitest.PlatformBinaryName()) + + res := env.Run("install", "3.8.0") + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "failed to install version 3.8.0: failed to download binary") + assert.Empty(t, binDirEntries(t, env)) + + res = env.Run("install", "3.8.0") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Installing version 3.8.0...\n") + assert.Contains(t, res.Stdout, "educates 3.8.0 installed successfully.\n") + content, err := os.ReadFile(filepath.Join(env.BinDir(), "educates-3.8.0")) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0 binary", string(content)) + assert.Equal(t, []string{"educates-3.8.0"}, binDirEntries(t, env)) +} diff --git a/pkg/version/manager.go b/pkg/version/manager.go index 58bbadb..ed378b1 100644 --- a/pkg/version/manager.go +++ b/pkg/version/manager.go @@ -141,12 +141,8 @@ func (m *Manager) InstallVersion(version string, force bool, activate bool) erro return err // Pass through the user-friendly error from GitHub client } - _, _ = fmt.Fprintf(m.out, "Downloading %s...\n", downloadURL) - if err := m.downloadFile(downloadURL, binaryPath); err != nil { - return fmt.Errorf("failed to download binary (check your internet connection and try again): %w", err) - } - if err := os.Chmod(binaryPath, 0o755); err != nil { - return fmt.Errorf("failed to set executable permissions on %s: %w", binaryPath, err) + if err := m.installBinary(downloadURL, binaryPath); err != nil { + return err } _, _ = fmt.Fprintf(m.out, "educates %s installed successfully.\n", version) } @@ -197,32 +193,57 @@ func (m *Manager) createSymlink(source, target string) error { return nil } -// downloadFile downloads a file from a URL to a local path -func (m *Manager) downloadFile(url, outPath string) error { - resp, err := http.Get(url) +// installBinary downloads the binary at url into a temporary file next to +// binaryPath, makes it executable and only then renames it to binaryPath, +// replacing any binary already there. The temporary file is removed when +// any step fails, so binaryPath holds either the previous binary or the +// complete new one. +func (m *Manager) installBinary(url, binaryPath string) (err error) { + dir := filepath.Dir(binaryPath) + // The leading dot keeps the temporary file out of the installed versions. + tmp, err := os.CreateTemp(dir, "."+filepath.Base(binaryPath)+"-*.download") if err != nil { - return err + return fmt.Errorf("failed to create a temporary file in %s: %w", dir, err) } defer func() { - if cerr := resp.Body.Close(); cerr != nil && err == nil { - err = fmt.Errorf("error closing response body: %w", cerr) + _ = tmp.Close() + if err != nil { + _ = os.Remove(tmp.Name()) } }() - if resp.StatusCode != 200 { - return fmt.Errorf("failed to download file: %s", resp.Status) + _, _ = fmt.Fprintf(m.out, "Downloading %s...\n", url) + if err := m.download(url, tmp); err != nil { + return fmt.Errorf("failed to download binary (check your internet connection and try again): %w", err) } - out, err := os.Create(outPath) + if err := tmp.Chmod(0o755); err != nil { + return fmt.Errorf("failed to set executable permissions on %s: %w", tmp.Name(), err) + } + if err := tmp.Sync(); err != nil { + return fmt.Errorf("failed to write %s: %w", tmp.Name(), err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("failed to write %s: %w", tmp.Name(), err) + } + if err := os.Rename(tmp.Name(), binaryPath); err != nil { + return fmt.Errorf("failed to move the downloaded binary to %s: %w", binaryPath, err) + } + return nil +} + +// download writes the body of a GET of url to w. +func (m *Manager) download(url string, w io.Writer) error { + resp, err := http.Get(url) if err != nil { return err } - defer func() { - if cerr := out.Close(); cerr != nil && err == nil { - err = fmt.Errorf("error closing output file: %w", cerr) - } - }() + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download %s: %s", url, resp.Status) + } - _, err = io.Copy(out, resp.Body) + _, err = io.Copy(w, resp.Body) return err } From ebbe5486d5a00ff2990224b0dcf633f1dfa7137d Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:01:20 +0100 Subject: [PATCH 26/75] fix(use): report why switching versions failed use said a version was not installed whatever went wrong. It now says so only when the binary is missing, and otherwise passes the cause through, such as a bin directory it cannot write. --- pkg/cmd/use.go | 7 ++++++- pkg/cmd/use_test.go | 39 +++++++++++++++++++++++++++++++++++++++ pkg/version/manager.go | 6 ++++-- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/use.go b/pkg/cmd/use.go index f547ac8..5c61d37 100644 --- a/pkg/cmd/use.go +++ b/pkg/cmd/use.go @@ -1,7 +1,9 @@ package cmd import ( + "errors" "fmt" + "io/fs" "os" "github.com/spf13/cobra" @@ -45,7 +47,10 @@ func newUseCommand(d *deps) *cobra.Command { // Handle regular version if err := d.manager.UseVersion(version); err != nil { - return fmt.Errorf("version %s is not installed. You should install it first with `educatesenv install %s`", version, version) + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("version %s is not installed. You should install it first with `educatesenv install %s`", version, version) + } + return fmt.Errorf("failed to switch to version %s: %w", version, err) } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Now using educates version %s\n", version) diff --git a/pkg/cmd/use_test.go b/pkg/cmd/use_test.go index 53968ef..fcce914 100644 --- a/pkg/cmd/use_test.go +++ b/pkg/cmd/use_test.go @@ -1,6 +1,7 @@ package cmd_test import ( + "io/fs" "os" "path/filepath" "testing" @@ -27,6 +28,44 @@ func TestUseActivatesAnInstalledVersion(t *testing.T) { assert.Equal(t, "educates-3.8.0", target) } +func TestUseOfAVersionThatIsNotInstalledSaysHowToInstallIt(t *testing.T) { + env := clitest.New(t) + env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("use", "3.9.0") + + require.EqualError(t, res.Err, "version 3.9.0 is not installed. You should install it first with `educatesenv install 3.9.0`") + assert.Empty(t, res.Stdout) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestUseWithAnUnwritableBinDirectoryReportsTheCause(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("directory permissions do not apply to root") + } + env := clitest.New(t) + env.WriteBinary("3.7.0") + env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.7.0", env.ActiveLink())) + require.NoError(t, os.Chmod(env.BinDir(), 0o555)) + t.Cleanup(func() { + _ = os.Chmod(env.BinDir(), 0o755) + }) + + res := env.Run("use", "3.8.0") + + require.ErrorIs(t, res.Err, fs.ErrPermission) + assert.ErrorContains(t, res.Err, "failed to switch to version 3.8.0: ") + assert.NotContains(t, res.Err.Error(), "not installed") + assert.Empty(t, res.Stdout) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.7.0", target) +} + func TestUseDevelopRequiresDevelopmentMode(t *testing.T) { env := clitest.New(t) diff --git a/pkg/version/manager.go b/pkg/version/manager.go index a83f0b3..5496258 100644 --- a/pkg/version/manager.go +++ b/pkg/version/manager.go @@ -3,6 +3,7 @@ package version import ( "fmt" "io" + "io/fs" "net/http" "os" "path/filepath" @@ -76,7 +77,8 @@ func (m *Manager) isDevSymlink(symlinkPath string) (bool, error) { return !inBinDir || !strings.HasPrefix(filepath.Base(target), platform.BinaryPrefix), nil } -// UseVersion sets a version as active +// UseVersion sets a version as active. When the version's binary is +// missing, the error wraps fs.ErrNotExist. func (m *Manager) UseVersion(version string) error { symlinkPath := filepath.Join(m.config.Local.Dir, "educates") @@ -170,7 +172,7 @@ func (m *Manager) createSymlink(source, target string) error { // Check if source exists if _, err := os.Stat(source); err != nil { if os.IsNotExist(err) { - return fmt.Errorf("binary not found at %s", source) + return fmt.Errorf("binary not found at %s: %w", source, fs.ErrNotExist) } return fmt.Errorf("failed to check binary: %w", err) } From 6a791494af3815c158cf703e6e6d25d4b9e56472 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:02:04 +0100 Subject: [PATCH 27/75] test(config): move the config tests to the CLI harness Cover the defaults, case-insensitive keys, each environment override, empty environment variables, the boolean forms and the token sources through the commands, and drop the package tests that read the working directory. --- pkg/cmd/config_test.go | 184 ++++++++++++++++++++++++++++++++++++++ pkg/config/config_test.go | 111 ----------------------- 2 files changed, 184 insertions(+), 111 deletions(-) delete mode 100644 pkg/config/config_test.go diff --git a/pkg/cmd/config_test.go b/pkg/cmd/config_test.go index a90f86e..af0076d 100644 --- a/pkg/cmd/config_test.go +++ b/pkg/cmd/config_test.go @@ -9,8 +9,17 @@ import ( "github.com/stretchr/testify/require" "github.com/educates/educatesenv/internal/clitest" + "github.com/educates/educatesenv/internal/fakegithub" ) +// configView runs `config view` and returns what it printed. +func configView(t *testing.T, env *clitest.Env) string { + t.Helper() + res := env.Run("config", "view") + require.NoError(t, res.Err) + return res.Stdout +} + func TestConfigViewPrintsTheConfiguration(t *testing.T) { env := clitest.New(t) env.WriteConfig("github:\n org: acme\n") @@ -22,6 +31,18 @@ func TestConfigViewPrintsTheConfiguration(t *testing.T) { assert.Empty(t, res.Stderr) } +func TestConfigViewShowsTheDefaultsWithoutAConfigFile(t *testing.T) { + env := clitest.New(t) + + out := configView(t, env) + + assert.Contains(t, out, " org: educates\n") + assert.Contains(t, out, " repository: educates-training-platform\n") + assert.Contains(t, out, " dir: "+env.BinDir()+"\n") + assert.Contains(t, out, " enabled: false\n") + assert.Contains(t, out, " binaryLocation: \"\"\n") +} + func TestEachRunReadsTheConfigAfresh(t *testing.T) { env := clitest.New(t) configPath := env.WriteConfig("github:\n org: acme\n") @@ -46,6 +67,157 @@ func TestAMissingHomeDirectoryWarnsOnce(t *testing.T) { assert.Equal(t, "Warning: could not determine home directory, using current directory for bin folder.\ndevelop\n", res.Stdout) } +func TestConfigKeysMatchRegardlessOfCase(t *testing.T) { + env := clitest.New(t) + env.WriteConfig(`GitHub: + ORG: acme + Repository: widgets +LOCAL: + Dir: /opt/educates/bin +Development: + ENABLED: true + binarylocation: /src/educates/educates +`) + + out := configView(t, env) + + assert.Contains(t, out, " org: acme\n") + assert.Contains(t, out, " repository: widgets\n") + assert.Contains(t, out, " dir: /opt/educates/bin\n") + assert.Contains(t, out, " enabled: true\n") + assert.Contains(t, out, " binaryLocation: /src/educates/educates\n") +} + +func TestEnvironmentVariablesOverrideTheConfigFile(t *testing.T) { + tests := []struct { + variable string + value string + fromFile string + fromEnv string + }{ + {"EDUCATES_GITHUB_ORG", "envorg", " org: fileorg\n", " org: envorg\n"}, + {"EDUCATES_GITHUB_REPOSITORY", "envrepo", " repository: filerepo\n", " repository: envrepo\n"}, + {"EDUCATES_LOCAL_DIR", "/env/bin", " dir: /file/bin\n", " dir: /env/bin\n"}, + {"EDUCATES_DEVELOPMENT_ENABLED", "false", " enabled: true\n", " enabled: false\n"}, + {"EDUCATES_DEVELOPMENT_BINARY_LOCATION", "/env/educates", " binaryLocation: /file/educates\n", " binaryLocation: /env/educates\n"}, + } + + for _, tt := range tests { + t.Run(tt.variable, func(t *testing.T) { + env := clitest.New(t) + env.WriteConfig(`github: + org: fileorg + repository: filerepo +local: + dir: /file/bin +development: + enabled: true + binaryLocation: /file/educates +`) + env.Setenv(tt.variable, tt.value) + + out := configView(t, env) + + assert.Contains(t, out, tt.fromEnv) + assert.NotContains(t, out, tt.fromFile) + }) + } +} + +func TestAnEmptyEnvironmentVariableLeavesTheConfigFileValue(t *testing.T) { + env := clitest.New(t) + env.WriteConfig("github:\n org: acme\n") + env.Setenv("EDUCATES_GITHUB_ORG", "") + + out := configView(t, env) + + assert.Contains(t, out, " org: acme\n") +} + +func TestBooleansAcceptTheirUsualForms(t *testing.T) { + trueForms := []string{"1", "t", "T", "true", "TRUE", "True"} + falseForms := []string{"0", "f", "F", "false", "FALSE", "False"} + + for _, form := range trueForms { + t.Run("EDUCATES_DEVELOPMENT_ENABLED="+form, func(t *testing.T) { + env := clitest.New(t) + env.Setenv("EDUCATES_DEVELOPMENT_ENABLED", form) + assert.Contains(t, configView(t, env), " enabled: true\n") + }) + } + for _, form := range falseForms { + t.Run("EDUCATES_DEVELOPMENT_ENABLED="+form, func(t *testing.T) { + env := clitest.New(t) + env.WriteConfig("development:\n enabled: true\n") + env.Setenv("EDUCATES_DEVELOPMENT_ENABLED", form) + assert.Contains(t, configView(t, env), " enabled: false\n") + }) + } + + fileForms := []struct { + value string + want string + }{ + {"true", "true"}, + {"True", "true"}, + {"TRUE", "true"}, + {"1", "true"}, + {"2", "true"}, + {`"t"`, "true"}, + {`"true"`, "true"}, + {"false", "false"}, + {"False", "false"}, + {"FALSE", "false"}, + {"0", "false"}, + {`"f"`, "false"}, + {`"false"`, "false"}, + {"", "false"}, + } + for _, tt := range fileForms { + t.Run("enabled: "+tt.value, func(t *testing.T) { + env := clitest.New(t) + env.WriteConfig("development:\n enabled: " + tt.value + "\n") + assert.Contains(t, configView(t, env), " enabled: "+tt.want+"\n") + }) + } +} + +func TestRequestsCarryTheTokenFromTheEnvironmentOrTheConfigFile(t *testing.T) { + tests := []struct { + name string + envVar string + config string + want string + }{ + {name: "EDUCATES_GITHUB_TOKEN", envVar: "envtoken", want: "Bearer envtoken"}, + {name: "github.token", config: "github:\n token: filetoken\n", want: "Bearer filetoken"}, + {name: "EDUCATES_GITHUB_TOKEN over github.token", envVar: "envtoken", config: "github:\n token: filetoken\n", want: "Bearer envtoken"}, + {name: "neither", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0"}) + if tt.config != "" { + env.WriteConfig(tt.config) + } + if tt.envVar != "" { + env.Setenv("EDUCATES_GITHUB_TOKEN", tt.envVar) + } + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + requests := env.GitHub.Requests() + require.NotEmpty(t, requests) + for _, r := range requests { + assert.Equal(t, tt.want, r.Header.Get("Authorization"), r.Path) + } + }) + } +} + func TestConfigInitCreatesTheConfigFile(t *testing.T) { env := clitest.New(t) configPath := filepath.Join(env.ConfigDir(), "config.yaml") @@ -56,3 +228,15 @@ func TestConfigInitCreatesTheConfigFile(t *testing.T) { assert.Equal(t, "Config file created at "+configPath+"\n", res.Stdout) assert.FileExists(t, configPath) } + +func TestConfigInitRefusesToReplaceAnExistingConfig(t *testing.T) { + env := clitest.New(t) + configPath := env.WriteConfig("github:\n org: acme\n") + + res := env.Run("config", "init") + + require.EqualError(t, res.Err, "config file already exists at "+configPath) + content, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Equal(t, "github:\n org: acme\n", string(content)) +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go deleted file mode 100644 index cbb15aa..0000000 --- a/pkg/config/config_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package config - -import ( - "io" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNew(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - cfg := New() - assert.NotNil(t, cfg) - assert.Equal(t, DefaultGithubOrg, cfg.Github.Org) - assert.Equal(t, DefaultGithubRepo, cfg.Github.Repository) - assert.Empty(t, cfg.Github.Token) - assert.False(t, cfg.Development.Enabled) - assert.Empty(t, cfg.Development.BinaryLocation) - - // Test that Local.Dir is set to a path in the home directory - home, err := os.UserHomeDir() - if err == nil { - expectedPath := filepath.Join(home, ConfigDirName, "bin") - assert.Equal(t, expectedPath, cfg.Local.Dir) - } -} - -func TestLoad(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - // Setup temporary directory for test config - tmpDir, err := os.MkdirTemp("", "educatesenv-test") - assert.NoError(t, err) - defer func() { - err := os.RemoveAll(tmpDir) - assert.NoError(t, err) - }() - - // Create a test config file - configContent := []byte(` -github: - org: testorg - repository: testrepo - token: testtoken -local: - dir: /test/dir -development: - enabled: true - binaryLocation: /test/binary -`) - err = os.WriteFile(filepath.Join(tmpDir, "config.yaml"), configContent, 0644) - assert.NoError(t, err) - - // Set working directory to temp dir for config loading - oldWd, err := os.Getwd() - assert.NoError(t, err) - err = os.Chdir(tmpDir) - assert.NoError(t, err) - defer func() { - err := os.Chdir(oldWd) - assert.NoError(t, err) - }() - - // Test loading config from file - cfg := New() - err = cfg.Load(io.Discard) - assert.NoError(t, err) - assert.Equal(t, "testorg", cfg.Github.Org) - assert.Equal(t, "testrepo", cfg.Github.Repository) - assert.Equal(t, "testtoken", cfg.Github.Token) - assert.Equal(t, "/test/dir", cfg.Local.Dir) - assert.True(t, cfg.Development.Enabled) - assert.Equal(t, "/test/binary", cfg.Development.BinaryLocation) -} - -func TestLoadWithEnvVars(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - // Set environment variables - envVars := map[string]string{ - "EDUCATES_GITHUB_ORG": "envorg", - "EDUCATES_GITHUB_REPOSITORY": "envrepo", - "EDUCATES_GITHUB_TOKEN": "envtoken", - "EDUCATES_LOCAL_DIR": "/env/dir", - "EDUCATES_DEVELOPMENT_ENABLED": "true", - "EDUCATES_DEVELOPMENT_BINARY_LOCATION": "/env/binary", - } - - // Set environment variables - for k, v := range envVars { - err := os.Setenv(k, v) - assert.NoError(t, err) - defer func() { - err := os.Unsetenv(k) - assert.NoError(t, err) - }() - } - - // Test loading config with environment variables - cfg := New() - err := cfg.Load(io.Discard) - assert.NoError(t, err) - assert.Equal(t, "envorg", cfg.Github.Org) - assert.Equal(t, "envrepo", cfg.Github.Repository) - assert.Equal(t, "envtoken", cfg.Github.Token) - assert.Equal(t, "/env/dir", cfg.Local.Dir) - assert.True(t, cfg.Development.Enabled) - assert.Equal(t, "/env/binary", cfg.Development.BinaryLocation) -} From 50e26709a8aa489dbe3c6795179e6e6fed5c35ae Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:02:05 +0100 Subject: [PATCH 28/75] feat(install): verify the binary against the release's checksums.txt The GitHub client looks up a release's platform binary and checksums.txt by tag. The download's SHA-256 is checked against the binary's entry in checksums.txt before the binary is made executable and renamed into place, and a mismatch fails the install, leaving any installed binary intact. --- pkg/cmd/install_test.go | 44 ++++++++++++++++++++++++++++ pkg/github/client.go | 37 +++++++++++++++++++----- pkg/version/manager.go | 64 ++++++++++++++++++++++++++++++++++------- 3 files changed, 128 insertions(+), 17 deletions(-) diff --git a/pkg/cmd/install_test.go b/pkg/cmd/install_test.go index 5207075..c9b662d 100644 --- a/pkg/cmd/install_test.go +++ b/pkg/cmd/install_test.go @@ -98,3 +98,47 @@ func TestInstallCutMidTransferLeavesNothingBehindAndARetryInstalls(t *testing.T) assert.Equal(t, "educates 3.8.0 binary", string(content)) assert.Equal(t, []string{"educates-3.8.0"}, binDirEntries(t, env)) } + +// releasedSum is the SHA-256 checksum of "educates 3.8.0 binary". +const releasedSum = "21163080cb987ff30bc789604b421676c7065e47b4c77415af76459db9bfc4bd" + +// addTamperedRelease adds a release whose binary is not the one its +// checksums.txt lists. +func addTamperedRelease(env *clitest.Env, tag string) { + env.GitHub.AddReleases(fakegithub.Release{Tag: tag, Assets: []fakegithub.Asset{ + clitest.PlatformBinary([]byte("tampered binary")), + {Name: "checksums.txt", Content: []byte(releasedSum + " " + clitest.PlatformBinaryName() + "\n")}, + }}) +} + +func TestInstallFailsOnAChecksumMismatchAndLeavesNothingBehind(t *testing.T) { + env := clitest.New(t) + addTamperedRelease(env, "3.8.0") + + res := env.Run("install", "3.8.0", "--use") + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "failed to install version 3.8.0: checksum mismatch for "+clitest.PlatformBinaryName()) + assert.NotContains(t, res.Stdout, "installed successfully") + assert.Empty(t, binDirEntries(t, env)) +} + +func TestInstallOverwriteWithAChecksumMismatchKeepsTheInstalledBinary(t *testing.T) { + env := clitest.New(t) + installed := env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + addTamperedRelease(env, "3.8.0") + + res := env.Run("install", "3.8.0", "--overwrite") + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "checksum mismatch for "+clitest.PlatformBinaryName()) + assert.Contains(t, res.Stdout, "Reinstalling version 3.8.0...\n") + content, err := os.ReadFile(installed) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0", string(content)) + info, err := os.Stat(installed) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) + assert.ElementsMatch(t, []string{"educates", "educates-3.8.0"}, binDirEntries(t, env)) +} diff --git a/pkg/github/client.go b/pkg/github/client.go index 48c53f5..2966761 100644 --- a/pkg/github/client.go +++ b/pkg/github/client.go @@ -77,22 +77,45 @@ func (c *Client) GetLatestReleaseVersion() (string, error) { return "", fmt.Errorf("no stable releases found in %s/%s. Try 'educatesenv list-remote --all' to see pre-releases", c.config.Github.Org, c.config.Github.Repository) } -// GetReleaseAssetURL gets the download URL for a specific version and platform -func (c *Client) GetReleaseAssetURL(version, assetName string) (string, error) { +// ChecksumsAssetName is the name of the release asset that lists the +// SHA-256 checksum of each of the release's other assets. +const ChecksumsAssetName = "checksums.txt" + +// ReleaseAssets holds the download URLs of the release assets that +// installing a version needs. +type ReleaseAssets struct { + // BinaryURL is the download URL of the platform binary. + BinaryURL string + // ChecksumsURL is the download URL of checksums.txt. It is empty when + // the release has no checksums.txt. + ChecksumsURL string +} + +// GetReleaseAssets looks up the release of a version by its tag, and +// returns the download URLs of its binaryName asset and of its +// checksums.txt. +func (c *Client) GetReleaseAssets(version, binaryName string) (ReleaseAssets, error) { release, resp, err := c.client.Repositories.GetReleaseByTag(context.Background(), c.config.Github.Org, c.config.Github.Repository, version) if err != nil { if resp != nil && resp.StatusCode == 404 { - return "", fmt.Errorf("version %s not found. Run 'educatesenv list-remote' to see available versions", version) + return ReleaseAssets{}, fmt.Errorf("version %s not found. Run 'educatesenv list-remote' to see available versions", version) } - return "", fmt.Errorf("failed to fetch release info: %w", err) + return ReleaseAssets{}, fmt.Errorf("failed to fetch release info: %w", err) } + var assets ReleaseAssets for _, a := range release.Assets { - if a.GetName() == assetName { - return a.GetBrowserDownloadURL(), nil + switch a.GetName() { + case binaryName: + assets.BinaryURL = a.GetBrowserDownloadURL() + case ChecksumsAssetName: + assets.ChecksumsURL = a.GetBrowserDownloadURL() } } - return "", fmt.Errorf("binary for %s is not available for your platform (%s). Please check supported platforms in the documentation", version, assetName) + if assets.BinaryURL == "" { + return ReleaseAssets{}, fmt.Errorf("binary for %s is not available for your platform (%s). Please check supported platforms in the documentation", version, binaryName) + } + return assets, nil } // ListReleases returns all releases from the repository diff --git a/pkg/version/manager.go b/pkg/version/manager.go index ed378b1..60ac71c 100644 --- a/pkg/version/manager.go +++ b/pkg/version/manager.go @@ -1,6 +1,9 @@ package version import ( + "bytes" + "crypto/sha256" + "encoding/hex" "fmt" "io" "net/http" @@ -136,12 +139,17 @@ func (m *Manager) InstallVersion(version string, force bool, activate bool) erro return fmt.Errorf("failed to determine platform binary name: %w", err) } - downloadURL, err := m.github.GetReleaseAssetURL(version, assetName) + assets, err := m.github.GetReleaseAssets(version, assetName) if err != nil { return err // Pass through the user-friendly error from GitHub client } - if err := m.installBinary(downloadURL, binaryPath); err != nil { + releasedSum, err := m.releasedChecksum(assetName, assets.ChecksumsURL) + if err != nil { + return err + } + + if err := m.installBinary(assetName, assets.BinaryURL, releasedSum, binaryPath); err != nil { return err } _, _ = fmt.Fprintf(m.out, "educates %s installed successfully.\n", version) @@ -193,12 +201,41 @@ func (m *Manager) createSymlink(source, target string) error { return nil } -// installBinary downloads the binary at url into a temporary file next to -// binaryPath, makes it executable and only then renames it to binaryPath, -// replacing any binary already there. The temporary file is removed when -// any step fails, so binaryPath holds either the previous binary or the -// complete new one. -func (m *Manager) installBinary(url, binaryPath string) (err error) { +// releasedChecksum returns the SHA-256 checksum that the release's +// checksums.txt lists for assetName. It returns "" when the release has no +// checksums.txt. +func (m *Manager) releasedChecksum(assetName, checksumsURL string) (string, error) { + if checksumsURL == "" { + return "", nil + } + + var checksums bytes.Buffer + if err := m.download(checksumsURL, &checksums); err != nil { + return "", fmt.Errorf("failed to download %s (check your internet connection and try again): %w", github.ChecksumsAssetName, err) + } + sum, _ := findChecksum(checksums.String(), assetName) + return sum, nil +} + +// findChecksum returns the checksum that checksums, in the format sha256sum +// writes, lists for name. +func findChecksum(checksums, name string) (string, bool) { + for line := range strings.Lines(checksums) { + fields := strings.Fields(line) + if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == name { + return strings.ToLower(fields[0]), true + } + } + return "", false +} + +// installBinary downloads the binary assetName from url into a temporary +// file next to binaryPath, checks its SHA-256 checksum against releasedSum +// unless releasedSum is empty, makes it executable and only then renames it +// to binaryPath, replacing any binary already there. The temporary file is +// removed when any step fails, so binaryPath holds either the previous +// binary or the complete, verified new one. +func (m *Manager) installBinary(assetName, url, releasedSum, binaryPath string) (err error) { dir := filepath.Dir(binaryPath) // The leading dot keeps the temporary file out of the installed versions. tmp, err := os.CreateTemp(dir, "."+filepath.Base(binaryPath)+"-*.download") @@ -213,10 +250,17 @@ func (m *Manager) installBinary(url, binaryPath string) (err error) { }() _, _ = fmt.Fprintf(m.out, "Downloading %s...\n", url) - if err := m.download(url, tmp); err != nil { + hash := sha256.New() + if err := m.download(url, io.MultiWriter(tmp, hash)); err != nil { return fmt.Errorf("failed to download binary (check your internet connection and try again): %w", err) } + if releasedSum != "" { + if sum := hex.EncodeToString(hash.Sum(nil)); sum != releasedSum { + return fmt.Errorf("checksum mismatch for %s: %s lists %s, but the download has %s", assetName, github.ChecksumsAssetName, releasedSum, sum) + } + } + if err := tmp.Chmod(0o755); err != nil { return fmt.Errorf("failed to set executable permissions on %s: %w", tmp.Name(), err) } @@ -241,7 +285,7 @@ func (m *Manager) download(url string, w io.Writer) error { defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to download %s: %s", url, resp.Status) + return fmt.Errorf("GET %s: %s", url, resp.Status) } _, err = io.Copy(w, resp.Body) From 45b8fcf0c9935d1b0702b32666e49b6bb6355874 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:02:30 +0100 Subject: [PATCH 29/75] feat(install): fail when checksums.txt has no entry for the binary A release whose checksums.txt does not list the platform binary is not one whose binary can be verified as released, so the install fails before downloading it. --- pkg/cmd/install_test.go | 14 ++++++++++++++ pkg/version/manager.go | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/install_test.go b/pkg/cmd/install_test.go index c9b662d..147cf45 100644 --- a/pkg/cmd/install_test.go +++ b/pkg/cmd/install_test.go @@ -142,3 +142,17 @@ func TestInstallOverwriteWithAChecksumMismatchKeepsTheInstalledBinary(t *testing assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) assert.ElementsMatch(t, []string{"educates", "educates-3.8.0"}, binDirEntries(t, env)) } + +func TestInstallFailsWhenChecksumsTxtHasNoEntryForTheBinary(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0", Assets: []fakegithub.Asset{ + clitest.PlatformBinary([]byte("educates 3.8.0 binary")), + {Name: "checksums.txt", Content: []byte(releasedSum + " educates-plan9-mips\n")}, + }}) + + res := env.Run("install", "3.8.0") + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "failed to install version 3.8.0: checksums.txt has no entry for "+clitest.PlatformBinaryName()) + assert.Empty(t, binDirEntries(t, env)) +} diff --git a/pkg/version/manager.go b/pkg/version/manager.go index 60ac71c..2fb8569 100644 --- a/pkg/version/manager.go +++ b/pkg/version/manager.go @@ -213,7 +213,10 @@ func (m *Manager) releasedChecksum(assetName, checksumsURL string) (string, erro if err := m.download(checksumsURL, &checksums); err != nil { return "", fmt.Errorf("failed to download %s (check your internet connection and try again): %w", github.ChecksumsAssetName, err) } - sum, _ := findChecksum(checksums.String(), assetName) + sum, ok := findChecksum(checksums.String(), assetName) + if !ok { + return "", fmt.Errorf("%s has no entry for %s, so the binary cannot be verified as the one that was released", github.ChecksumsAssetName, assetName) + } return sum, nil } From fe7ed0d1443789a2bb2ada6af2f5f026e16aee05 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:02:51 +0100 Subject: [PATCH 30/75] feat(cmd): add uninstall to remove installed versions educatesenv uninstall ... removes installed versions. Every argument is checked before anything is removed, and every one that cannot be uninstalled is reported, so a typo removes nothing. develop is refused, since educatesenv does not manage the development binary, and so is a version that is not installed. A version is looked up by name in the bin directory, so an argument holding a path cannot reach outside it. The active version is refused unless --force is passed. With --force, the educates symlink is removed before the binary, so a failure never leaves it pointing at a removed binary, and the command says that no version is active. A version named twice is removed once. --- pkg/cmd/root.go | 1 + pkg/cmd/uninstall.go | 23 +++++ pkg/cmd/uninstall_test.go | 184 ++++++++++++++++++++++++++++++++++++++ pkg/version/uninstall.go | 113 +++++++++++++++++++++++ 4 files changed, 321 insertions(+) create mode 100644 pkg/cmd/uninstall.go create mode 100644 pkg/cmd/uninstall_test.go create mode 100644 pkg/version/uninstall.go diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 5c951c5..cce43c0 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -90,6 +90,7 @@ func NewRootCommand(opts ...Option) *cobra.Command { newListCommand(d), newListLocalCommand(d), newListRemoteCommand(d), + newUninstallCommand(d), newUseCommand(d), newVersionCommand(), ) diff --git a/pkg/cmd/uninstall.go b/pkg/cmd/uninstall.go new file mode 100644 index 0000000..7178857 --- /dev/null +++ b/pkg/cmd/uninstall.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +func newUninstallCommand(d *deps) *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "uninstall ...", + Short: "Uninstall one or more versions of educates", + Args: cobra.MinimumNArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return d.manager.UninstallVersions(args, force) + }, + } + + cmd.Flags().BoolVar(&force, "force", false, "Uninstall the active version too, leaving no version active") + return cmd +} diff --git a/pkg/cmd/uninstall_test.go b/pkg/cmd/uninstall_test.go new file mode 100644 index 0000000..5e7fab5 --- /dev/null +++ b/pkg/cmd/uninstall_test.go @@ -0,0 +1,184 @@ +package cmd_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +func TestUninstallRemovesAVersion(t *testing.T) { + env := clitest.New(t) + removed := env.WriteBinary("3.7.0") + kept := env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("uninstall", "3.7.0") + + require.NoError(t, res.Err) + assert.Equal(t, "educates 3.7.0 uninstalled.\n", res.Stdout) + assert.Empty(t, res.Stderr) + assert.NoFileExists(t, removed) + assert.FileExists(t, kept) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestUninstallRemovesSeveralVersions(t *testing.T) { + env := clitest.New(t) + first := env.WriteBinary("3.6.0") + second := env.WriteBinary("3.7.0") + kept := env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("uninstall", "3.7.0", "3.6.0") + + require.NoError(t, res.Err) + assert.Equal(t, "educates 3.7.0 uninstalled.\neducates 3.6.0 uninstalled.\n", res.Stdout) + assert.Empty(t, res.Stderr) + assert.NoFileExists(t, first) + assert.NoFileExists(t, second) + assert.FileExists(t, kept) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestUninstallOfAVersionNamedTwiceRemovesItOnce(t *testing.T) { + env := clitest.New(t) + removed := env.WriteBinary("3.7.0") + + res := env.Run("uninstall", "3.7.0", "3.7.0") + + require.NoError(t, res.Err) + assert.Equal(t, "educates 3.7.0 uninstalled.\n", res.Stdout) + assert.NoFileExists(t, removed) +} + +func TestUninstallRequiresAVersion(t *testing.T) { + env := clitest.New(t) + kept := env.WriteBinary("3.8.0") + + res := env.Run("uninstall") + + require.EqualError(t, res.Err, "requires at least 1 arg(s), only received 0") + assert.Empty(t, res.Stdout) + assert.FileExists(t, kept) +} + +func TestUninstallOfAVersionThatIsNotInstalledFails(t *testing.T) { + env := clitest.New(t) + kept := env.WriteBinary("3.8.0") + + res := env.Run("uninstall", "3.9.9") + + require.EqualError(t, res.Err, "version 3.9.9 is not installed. Run 'educatesenv list' to see installed versions") + assert.Empty(t, res.Stdout) + assert.FileExists(t, kept) +} + +func TestUninstallOfAPathIsNotAnInstalledVersion(t *testing.T) { + env := clitest.New(t) + env.WriteBinary("3.8.0") + configPath := env.WriteConfig("github:\n org: educates\n") + + // Joined to the bin directory, this argument names the config file. + res := env.Run("uninstall", "x/../../config.yaml") + + require.EqualError(t, res.Err, "version x/../../config.yaml is not installed. Run 'educatesenv list' to see installed versions") + assert.FileExists(t, configPath) +} + +func TestUninstallWithATypoAmongValidVersionsRemovesNothing(t *testing.T) { + env := clitest.New(t) + first := env.WriteBinary("3.6.0") + second := env.WriteBinary("3.7.0") + + res := env.Run("uninstall", "3.6.0", "3.7.O", "3.7.0") + + require.EqualError(t, res.Err, "version 3.7.O is not installed. Run 'educatesenv list' to see installed versions") + assert.Empty(t, res.Stdout) + assert.FileExists(t, first) + assert.FileExists(t, second) +} + +func TestUninstallReportsEveryVersionItCannotUninstall(t *testing.T) { + env := clitest.New(t) + valid := env.WriteBinary("3.6.0") + active := env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("uninstall", "3.6.0", "3.7.O", "develop", "3.8.0") + + require.EqualError(t, res.Err, + "version 3.7.O is not installed. Run 'educatesenv list' to see installed versions\n"+ + "cannot uninstall develop: educatesenv does not manage the development binary\n"+ + "version 3.8.0 is active. Switch to another version with 'educatesenv use ' first, or pass --force to uninstall it") + assert.Empty(t, res.Stdout) + assert.FileExists(t, valid) + assert.FileExists(t, active) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestUninstallOfDevelopFails(t *testing.T) { + env := clitest.New(t) + devBinary := writeDevelopmentBinary(t, env) + env.WriteConfig("development:\n enabled: true\n binaryLocation: " + devBinary + "\n") + // An educates-develop file in the bin directory does not make develop + // an installed version. + shadow := env.WriteBinary("develop") + require.NoError(t, os.Symlink(devBinary, env.ActiveLink())) + + res := env.Run("uninstall", "develop", "--force") + + require.EqualError(t, res.Err, "cannot uninstall develop: educatesenv does not manage the development binary") + assert.Empty(t, res.Stdout) + assert.FileExists(t, devBinary) + assert.FileExists(t, shadow) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, devBinary, target) +} + +func TestUninstallOfTheActiveVersionFailsWithoutForce(t *testing.T) { + env := clitest.New(t) + other := env.WriteBinary("3.7.0") + active := env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("uninstall", "3.7.0", "3.8.0") + + require.EqualError(t, res.Err, "version 3.8.0 is active. Switch to another version with 'educatesenv use ' first, or pass --force to uninstall it") + assert.Empty(t, res.Stdout) + assert.FileExists(t, other) + assert.FileExists(t, active) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestUninstallOfTheActiveVersionWithForceLeavesNoVersionActive(t *testing.T) { + env := clitest.New(t) + kept := env.WriteBinary("3.7.0") + active := env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("uninstall", "3.8.0", "--force") + + require.NoError(t, res.Err) + assert.Equal(t, + "educates 3.8.0 uninstalled.\n"+ + "No educates version is active now. Run 'educatesenv use ' to select one.\n", + res.Stdout) + assert.Empty(t, res.Stderr) + assert.NoFileExists(t, active) + assert.FileExists(t, kept) + _, err := os.Lstat(env.ActiveLink()) + assert.ErrorIs(t, err, os.ErrNotExist) +} diff --git a/pkg/version/uninstall.go b/pkg/version/uninstall.go new file mode 100644 index 0000000..85d72c5 --- /dev/null +++ b/pkg/version/uninstall.go @@ -0,0 +1,113 @@ +package version + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/educates/educatesenv/pkg/platform" +) + +// UninstallVersions removes the installed versions given, reporting each +// one it removes. It checks every version before it removes any, and +// reports every version it cannot uninstall, so that one bad argument +// leaves all of them in place. The active version is refused unless force +// is set; with force, the educates symlink goes too, and no version is +// active afterwards. +func (m *Manager) UninstallVersions(versions []string, force bool) error { + binDir := m.config.Local.Dir + activeLink := filepath.Join(binDir, "educates") + binaryPath := func(version string) string { + return filepath.Join(binDir, platform.BinaryPrefix+version) + } + + installed, err := fileNamesIn(binDir) + if err != nil { + return fmt.Errorf("failed to read bin directory %s: %w", binDir, err) + } + + var ( + toRemove []string + problems []error + activeVersion string + ) + seen := make(map[string]bool, len(versions)) + for _, version := range versions { + if seen[version] { + continue + } + seen[version] = true + + if version == "develop" { + problems = append(problems, errors.New("cannot uninstall develop: educatesenv does not manage the development binary")) + continue + } + // Looking the binary up by name keeps an argument that holds a + // path from reaching outside the bin directory. + if !installed[platform.BinaryPrefix+version] { + problems = append(problems, fmt.Errorf("version %s is not installed. Run 'educatesenv list' to see installed versions", version)) + continue + } + if linksTo(activeLink, binaryPath(version)) { + if !force { + problems = append(problems, fmt.Errorf("version %s is active. Switch to another version with 'educatesenv use ' first, or pass --force to uninstall it", version)) + continue + } + activeVersion = version + } + toRemove = append(toRemove, version) + } + if len(problems) > 0 { + return errors.Join(problems...) + } + + for _, version := range toRemove { + if version == activeVersion { + // The symlink goes first, so that a failure leaves no symlink + // pointing at a removed binary. + if err := os.Remove(activeLink); err != nil { + return fmt.Errorf("failed to remove the educates symlink: %w", err) + } + } + if err := os.Remove(binaryPath(version)); err != nil { + return fmt.Errorf("failed to uninstall version %s: %w", version, err) + } + _, _ = fmt.Fprintf(m.out, "educates %s uninstalled.\n", version) + } + + if activeVersion != "" { + _, _ = fmt.Fprintln(m.out, "No educates version is active now. Run 'educatesenv use ' to select one.") + } + return nil +} + +// fileNamesIn returns the names of the entries in dir that are not +// directories. A missing dir holds none. +func fileNamesIn(dir string) (map[string]bool, error) { + entries, err := os.ReadDir(dir) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + names := make(map[string]bool, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + names[entry.Name()] = true + } + } + return names, nil +} + +// linksTo reports whether link resolves to the same file as path. +func linksTo(link, path string) bool { + linked, err := os.Stat(link) + if err != nil { + return false + } + target, err := os.Stat(path) + if err != nil { + return false + } + return os.SameFile(linked, target) +} From 71e42ec340229d2bb0c9f25bd46ada62824a35d6 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:03:37 +0100 Subject: [PATCH 31/75] test(fakegithub): build a checksums.txt asset for a release Checksums lists the SHA-256 checksum of each asset in the format sha256sum writes and Educates releases publish. --- internal/fakegithub/downloads.go | 15 +++++++++++++++ internal/fakegithub/downloads_test.go | 11 +++++++++++ 2 files changed, 26 insertions(+) diff --git a/internal/fakegithub/downloads.go b/internal/fakegithub/downloads.go index 6274624..af0e92f 100644 --- a/internal/fakegithub/downloads.go +++ b/internal/fakegithub/downloads.go @@ -1,11 +1,26 @@ package fakegithub import ( + "crypto/sha256" + "encoding/hex" "net/http" "strconv" + "strings" "sync/atomic" ) +// Checksums returns a checksums.txt asset that lists the SHA-256 checksum +// of each of assets, in the format sha256sum writes and Educates releases +// publish. +func Checksums(assets ...Asset) Asset { + var b strings.Builder + for _, a := range assets { + sum := sha256.Sum256(a.Content) + b.WriteString(hex.EncodeToString(sum[:]) + " " + a.Name + "\n") + } + return Asset{Name: "checksums.txt", Content: []byte(b.String())} +} + // CutNextDownload makes the next download of the asset name of the release // tagged tag fail mid-transfer: the response announces the asset's full // length and sends the first half of it, then the server drops the diff --git a/internal/fakegithub/downloads_test.go b/internal/fakegithub/downloads_test.go index c79c2f8..254f017 100644 --- a/internal/fakegithub/downloads_test.go +++ b/internal/fakegithub/downloads_test.go @@ -20,6 +20,17 @@ func get(t *testing.T, url string) (*http.Response, []byte, error) { return resp, body, err } +func TestChecksumsListsEachAssetsSHA256AsSha256sumDoes(t *testing.T) { + sums := fakegithub.Checksums( + fakegithub.Asset{Name: "educates-linux-amd64", Content: []byte("educates 3.8.0 binary")}, + fakegithub.Asset{Name: "educates-darwin-arm64", Content: []byte("educates 3.8.0")}, + ) + + assert.Equal(t, "checksums.txt", sums.Name) + assert.Equal(t, "21163080cb987ff30bc789604b421676c7065e47b4c77415af76459db9bfc4bd educates-linux-amd64\n"+ + "5e990e9569bdb6a9a588600567e649ae1a63aa23b387ef9010b05dafada7657c educates-darwin-arm64\n", string(sums.Content)) +} + func TestCutsTheNextDownloadMidTransferAndServesLaterOnesWhole(t *testing.T) { srv := fakegithub.New(t) srv.AddReleases(fakegithub.Release{Tag: "3.8.0", Assets: []fakegithub.Asset{ From cd43652b9de90ff50742910e96972743a1217958 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:03:37 +0100 Subject: [PATCH 32/75] feat(install): warn on stderr when a release has no checksums.txt A release without checksums.txt stays installable: the install prints a warning on stderr that the binary is not verified, and continues. The version manager takes a writer for warnings next to the one for progress. --- pkg/cmd/install_test.go | 23 ++++++++++++++++++++++- pkg/cmd/root.go | 9 +++++---- pkg/version/manager.go | 19 ++++++++++++------- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/install_test.go b/pkg/cmd/install_test.go index 147cf45..7416583 100644 --- a/pkg/cmd/install_test.go +++ b/pkg/cmd/install_test.go @@ -12,10 +12,13 @@ import ( "github.com/educates/educatesenv/internal/fakegithub" ) +// addRelease adds a release with the platform binary and a checksums.txt +// that lists it, as every Educates release has. func addRelease(env *clitest.Env, tag string, binary []byte) { + asset := clitest.PlatformBinary(binary) env.GitHub.AddReleases(fakegithub.Release{ Tag: tag, - Assets: []fakegithub.Asset{clitest.PlatformBinary(binary)}, + Assets: []fakegithub.Asset{asset, fakegithub.Checksums(asset)}, }) } @@ -156,3 +159,21 @@ func TestInstallFailsWhenChecksumsTxtHasNoEntryForTheBinary(t *testing.T) { assert.Contains(t, res.Err.Error(), "failed to install version 3.8.0: checksums.txt has no entry for "+clitest.PlatformBinaryName()) assert.Empty(t, binDirEntries(t, env)) } + +func TestInstallWarnsAndContinuesWhenTheReleaseHasNoChecksumsTxt(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0", Assets: []fakegithub.Asset{ + clitest.PlatformBinary([]byte("educates 3.8.0 binary")), + }}) + + res := env.Run("install", "3.8.0") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stderr, "Warning: release 3.8.0 has no checksums.txt") + assert.NotContains(t, res.Stdout, "Warning") + assert.Contains(t, res.Stdout, "educates 3.8.0 installed successfully.\n") + content, err := os.ReadFile(filepath.Join(env.BinDir(), "educates-3.8.0")) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0 binary", string(content)) + assert.Equal(t, []string{"educates-3.8.0"}, binDirEntries(t, env)) +} diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 5c951c5..671e1e2 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -36,8 +36,9 @@ type deps struct { } // load reads the configuration and creates the GitHub client and the -// version manager. Warnings and the manager's progress go to out. -func (d *deps) load(out io.Writer) error { +// version manager. Config warnings and the manager's progress go to out, +// and the manager's warnings to errOut. +func (d *deps) load(out, errOut io.Writer) error { d.cfg = config.New() if err := d.cfg.Load(out); err != nil { return err @@ -49,7 +50,7 @@ func (d *deps) load(out io.Writer) error { return err } - d.manager = version.New(d.cfg, d.gh, out) + d.manager = version.New(d.cfg, d.gh, out, errOut) return nil } @@ -66,7 +67,7 @@ func NewRootCommand(opts ...Option) *cobra.Command { Short: "Manage multiple versions of the educates binary", Long: `A version manager for educates, inspired by tfenv.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if err := d.load(cmd.OutOrStdout()); err != nil { + if err := d.load(cmd.OutOrStdout(), cmd.ErrOrStderr()); err != nil { return err } diff --git a/pkg/version/manager.go b/pkg/version/manager.go index 2fb8569..d64095b 100644 --- a/pkg/version/manager.go +++ b/pkg/version/manager.go @@ -22,14 +22,17 @@ type Manager struct { config *config.Config github *github.Client out io.Writer + errOut io.Writer } -// New creates a new version manager that reports progress to out -func New(cfg *config.Config, gh *github.Client, out io.Writer) *Manager { +// New creates a new version manager that reports progress to out and +// warnings to errOut +func New(cfg *config.Config, gh *github.Client, out, errOut io.Writer) *Manager { return &Manager{ config: cfg, github: gh, out: out, + errOut: errOut, } } @@ -144,7 +147,7 @@ func (m *Manager) InstallVersion(version string, force bool, activate bool) erro return err // Pass through the user-friendly error from GitHub client } - releasedSum, err := m.releasedChecksum(assetName, assets.ChecksumsURL) + releasedSum, err := m.releasedChecksum(version, assetName, assets.ChecksumsURL) if err != nil { return err } @@ -201,11 +204,13 @@ func (m *Manager) createSymlink(source, target string) error { return nil } -// releasedChecksum returns the SHA-256 checksum that the release's -// checksums.txt lists for assetName. It returns "" when the release has no -// checksums.txt. -func (m *Manager) releasedChecksum(assetName, checksumsURL string) (string, error) { +// releasedChecksum returns the SHA-256 checksum that the checksums.txt of +// the release of version lists for assetName. When the release has no +// checksums.txt, it warns that the download cannot be verified and returns +// "". +func (m *Manager) releasedChecksum(version, assetName, checksumsURL string) (string, error) { if checksumsURL == "" { + _, _ = fmt.Fprintf(m.errOut, "Warning: release %s has no %s, so %s is installed without verifying its checksum.\n", version, github.ChecksumsAssetName, assetName) return "", nil } From 8f3adaf9457e561a89925cd0f2863b50602b92ea Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:04:01 +0100 Subject: [PATCH 33/75] feat(install): report a verified checksum A verified install says that the binary's checksum matched checksums.txt, so a user can tell a verified install from one that warned. --- pkg/cmd/install_test.go | 27 +++++++++++++++++++++++++++ pkg/version/manager.go | 1 + 2 files changed, 28 insertions(+) diff --git a/pkg/cmd/install_test.go b/pkg/cmd/install_test.go index 7416583..5d0c8a6 100644 --- a/pkg/cmd/install_test.go +++ b/pkg/cmd/install_test.go @@ -114,6 +114,33 @@ func addTamperedRelease(env *clitest.Env, tag string) { }}) } +func TestInstallVerifiesTheBinaryAgainstChecksumsTxt(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0", Assets: []fakegithub.Asset{ + clitest.PlatformBinary([]byte("educates 3.8.0 binary")), + {Name: "checksums.txt", Content: []byte( + "5e990e9569bdb6a9a588600567e649ae1a63aa23b387ef9010b05dafada7657c educates-plan9-mips\n" + + releasedSum + " " + clitest.PlatformBinaryName() + "\n" + + "2d02d0c7095de4b2570439088c502b5a3fb02a8c9aee29844bd9878894db85c9 educates-installer-app.yaml\n")}, + }}) + + res := env.Run("install", "3.8.0", "--use") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Checksum verified against checksums.txt.\n") + assert.Contains(t, res.Stdout, "educates 3.8.0 installed successfully.\n") + assert.Contains(t, res.Stdout, "educates 3.8.0 is now active.\n") + assert.Empty(t, res.Stderr) + binary := filepath.Join(env.BinDir(), "educates-3.8.0") + content, err := os.ReadFile(binary) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0 binary", string(content)) + info, err := os.Stat(binary) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) + assert.ElementsMatch(t, []string{"educates", "educates-3.8.0"}, binDirEntries(t, env)) +} + func TestInstallFailsOnAChecksumMismatchAndLeavesNothingBehind(t *testing.T) { env := clitest.New(t) addTamperedRelease(env, "3.8.0") diff --git a/pkg/version/manager.go b/pkg/version/manager.go index d64095b..9598b27 100644 --- a/pkg/version/manager.go +++ b/pkg/version/manager.go @@ -267,6 +267,7 @@ func (m *Manager) installBinary(assetName, url, releasedSum, binaryPath string) if sum := hex.EncodeToString(hash.Sum(nil)); sum != releasedSum { return fmt.Errorf("checksum mismatch for %s: %s lists %s, but the download has %s", assetName, github.ChecksumsAssetName, releasedSum, sum) } + _, _ = fmt.Fprintf(m.out, "Checksum verified against %s.\n", github.ChecksumsAssetName) } if err := tmp.Chmod(0o755); err != nil { From 3316669d41a31cbd6c9f741e9f0400073625b7b6 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:04:28 +0100 Subject: [PATCH 34/75] fix(config): read the config only from the educatesenv directory Replace viper with a YAML parse of ~/.educatesenv/config.yaml and explicit EDUCATES_* lookups. A config.yaml in the working directory no longer replaces the user's config. Keys still match regardless of case, an empty environment variable still counts as unset, and booleans take the forms viper accepted. Without a home directory no config file is read. Parse YAML with go.yaml.in/yaml/v3, and drop viper and gopkg.in/yaml.v3 from the module. --- go.mod | 16 +-- go.sum | 43 ------- pkg/cmd/config.go | 33 +----- pkg/cmd/config_test.go | 42 +++++++ pkg/cmd/root.go | 6 +- pkg/config/config.go | 259 +++++++++++++++++++++++++++++++---------- 6 files changed, 250 insertions(+), 149 deletions(-) diff --git a/go.mod b/go.mod index 7db0434..1bb5cf5 100644 --- a/go.mod +++ b/go.mod @@ -7,26 +7,12 @@ toolchain go1.26.10 require ( github.com/google/go-github/v93 v93.0.0 github.com/spf13/cobra v1.10.2 - github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.12.1 - gopkg.in/yaml.v3 v3.0.1 + go.yaml.in/yaml/v3 v3.0.5 ) require ( - github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect - github.com/sagikazarmark/locafero v0.7.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.12.0 // indirect - github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.9 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect ) diff --git a/go.sum b/go.sum index 56e07f2..8586d0c 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,4 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -16,49 +8,14 @@ github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfh github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= -github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= -github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= -github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/cmd/config.go b/pkg/cmd/config.go index ea20854..27b7430 100644 --- a/pkg/cmd/config.go +++ b/pkg/cmd/config.go @@ -2,11 +2,9 @@ package cmd import ( "fmt" - "os" - "path/filepath" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/educates/educatesenv/pkg/config" ) @@ -32,36 +30,13 @@ func newConfigInitCommand() *cobra.Command { // Disable automatic usage printing on error SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - home, err := os.UserHomeDir() + _, _, configPath, created, err := config.CreateConfigAndFolders() if err != nil { - home = "." + return err } - configDir := filepath.Join(home, config.ConfigDirName) - configPath := filepath.Join(configDir, "config.yaml") - - // Check if config already exists - if _, err := os.Stat(configPath); err == nil { + if !created { return fmt.Errorf("config file already exists at %s", configPath) } - - // Create config directory if it doesn't exist - if err := os.MkdirAll(configDir, 0o755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - - // Create new config with defaults - configFile := config.New() - - // Write configuration to file - yamlBytes, err := yaml.Marshal(&configFile) - if err != nil { - return fmt.Errorf("failed to marshal config to YAML: %w", err) - } - - if err := os.WriteFile(configPath, yamlBytes, 0o644); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config file created at %s\n", configPath) return nil }, diff --git a/pkg/cmd/config_test.go b/pkg/cmd/config_test.go index af0076d..b53ede0 100644 --- a/pkg/cmd/config_test.go +++ b/pkg/cmd/config_test.go @@ -67,6 +67,48 @@ func TestAMissingHomeDirectoryWarnsOnce(t *testing.T) { assert.Equal(t, "Warning: could not determine home directory, using current directory for bin folder.\ndevelop\n", res.Stdout) } +func TestAConfigFileInTheWorkingDirectoryIsIgnored(t *testing.T) { + const workDirConfig = "github:\n org: elsewhere\ndevelopment:\n enabled: true\n binaryLocation: /tmp/educates\n" + + t.Run("with a config in the home directory", func(t *testing.T) { + env := clitest.New(t) + env.WriteConfig("github:\n org: acme\n") + require.NoError(t, os.WriteFile(filepath.Join(env.WorkDir, "config.yaml"), []byte(workDirConfig), 0o600)) + + out := configView(t, env) + + assert.Contains(t, out, " org: acme\n") + assert.Contains(t, out, " enabled: false\n") + assert.NotContains(t, out, "/tmp/educates") + }) + + t.Run("without a config in the home directory", func(t *testing.T) { + env := clitest.New(t) + require.NoError(t, os.WriteFile(filepath.Join(env.WorkDir, "config.yaml"), []byte(workDirConfig), 0o600)) + + out := configView(t, env) + + assert.Contains(t, out, " org: educates\n") + assert.Contains(t, out, " enabled: false\n") + assert.NotContains(t, out, "/tmp/educates") + }) + + t.Run("without a home directory", func(t *testing.T) { + env := clitest.New(t) + env.Unsetenv("HOME") + require.NoError(t, os.WriteFile(filepath.Join(env.WorkDir, "config.yaml"), []byte(workDirConfig), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(env.WorkDir, ".educatesenv"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(env.WorkDir, ".educatesenv", "config.yaml"), []byte(workDirConfig), 0o600)) + + out := configView(t, env) + + assert.Contains(t, out, " org: educates\n") + assert.Contains(t, out, " dir: "+filepath.Join(".educatesenv", "bin")+"\n") + assert.Contains(t, out, " enabled: false\n") + assert.NotContains(t, out, "/tmp/educates") + }) +} + func TestConfigKeysMatchRegardlessOfCase(t *testing.T) { env := clitest.New(t) env.WriteConfig(`GitHub: diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 5c951c5..44cb9d0 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -38,12 +38,12 @@ type deps struct { // load reads the configuration and creates the GitHub client and the // version manager. Warnings and the manager's progress go to out. func (d *deps) load(out io.Writer) error { - d.cfg = config.New() - if err := d.cfg.Load(out); err != nil { + var err error + d.cfg, err = config.Load(out) + if err != nil { return err } - var err error d.gh, err = github.New(d.cfg, github.WithBaseURL(d.opts.githubBaseURL)) if err != nil { return err diff --git a/pkg/config/config.go b/pkg/config/config.go index 5f5f680..53b4e9a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,13 +1,19 @@ +// Package config loads educatesenv's configuration from the config file in +// the user's educatesenv directory and from EDUCATES_* environment +// variables. package config import ( + "errors" "fmt" "io" + "io/fs" "os" "path/filepath" + "strconv" + "strings" - "github.com/spf13/viper" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" ) const ( @@ -17,6 +23,8 @@ const ( DefaultGithubRepo = "educates-training-platform" // ConfigDirName is the name of the directory where educatesenv stores its configuration ConfigDirName = ".educatesenv" + // ConfigFileName is the name of the config file in the educatesenv directory + ConfigFileName = "config.yaml" ) // GithubConfig holds GitHub-related configuration @@ -44,93 +52,226 @@ type Config struct { Development DevelopmentConfig `yaml:"development"` } -// New returns a new Config instance with defaults set. Without a home -// directory, the bin folder is under the current directory. -func New() *Config { +// Paths are the places educatesenv keeps its files in. +type Paths struct { + // Home is the user's home directory. + Home string + // Dir is the educatesenv directory in Home. + Dir string + // BinDir is the default directory installed versions go in. + BinDir string + // File is the config file in Dir. + File string +} + +// homePaths returns the paths in the user's home directory. +func homePaths() (Paths, error) { home, err := os.UserHomeDir() if err != nil { - home = "." + return Paths{}, fmt.Errorf("could not determine home directory: %w", err) } - configDir := filepath.Join(home, ConfigDirName) - defaultBin := filepath.Join(configDir, "bin") + return pathsIn(home), nil +} + +// pathsIn returns the paths in home. +func pathsIn(home string) Paths { + dir := filepath.Join(home, ConfigDirName) + return Paths{ + Home: home, + Dir: dir, + BinDir: filepath.Join(dir, "bin"), + File: filepath.Join(dir, ConfigFileName), + } +} +// defaults returns the configuration that applies when neither the config +// file nor the environment sets a value. +func defaults(paths Paths) *Config { return &Config{ Github: GithubConfig{ Org: DefaultGithubOrg, Repository: DefaultGithubRepo, - Token: "", }, Local: LocalConfig{ - Dir: defaultBin, - }, - Development: DevelopmentConfig{ - Enabled: false, - BinaryLocation: "", + Dir: paths.BinDir, }, } } -// Load initializes the configuration from file and environment variables. -// It writes a warning to out when there is no home directory. -func (c *Config) Load(out io.Writer) error { - home, err := os.UserHomeDir() +// Load returns the configuration: the defaults, overridden by the config +// file in the user's educatesenv directory, overridden in turn by the +// EDUCATES_* environment variables. An environment variable set to the +// empty string counts as not set. +// +// Without a home directory, Load writes a warning to out, reads no config +// file, and puts the bin directory under the current directory. +func Load(out io.Writer) (*Config, error) { + paths, err := homePaths() if err != nil { _, _ = fmt.Fprintln(out, "Warning: could not determine home directory, using current directory for bin folder.") - home = "." + cfg := defaults(pathsIn(".")) + if err := cfg.applyEnv(); err != nil { + return nil, err + } + return cfg, nil } - configDir := filepath.Join(home, ConfigDirName) - defaultBin := filepath.Join(configDir, "bin") - v := viper.New() - v.SetConfigName("config") - v.SetConfigType("yaml") - v.AddConfigPath(".") - v.AddConfigPath(configDir) + cfg := defaults(paths) + if err := cfg.applyFile(paths.File); err != nil { + return nil, err + } + if err := cfg.applyEnv(); err != nil { + return nil, err + } + return cfg, nil +} + +// setting is one value of the configuration. +type setting struct { + // key is the value's key in the config file, as section.key. Keys + // match regardless of case. + key string + // env is the environment variable that overrides the config file. + env string + // Exactly one of str and boolean points at the field the value sets. + str *string + boolean *bool +} + +func (c *Config) settings() []setting { + return []setting{ + {key: "github.org", env: "EDUCATES_GITHUB_ORG", str: &c.Github.Org}, + {key: "github.repository", env: "EDUCATES_GITHUB_REPOSITORY", str: &c.Github.Repository}, + {key: "github.token", env: "EDUCATES_GITHUB_TOKEN", str: &c.Github.Token}, + {key: "local.dir", env: "EDUCATES_LOCAL_DIR", str: &c.Local.Dir}, + {key: "development.enabled", env: "EDUCATES_DEVELOPMENT_ENABLED", boolean: &c.Development.Enabled}, + {key: "development.binaryLocation", env: "EDUCATES_DEVELOPMENT_BINARY_LOCATION", str: &c.Development.BinaryLocation}, + } +} + +// applyEnv sets every value whose environment variable is set and not +// empty. +func (c *Config) applyEnv() error { + for _, s := range c.settings() { + value := os.Getenv(s.env) + if value == "" { + continue + } + if s.str != nil { + *s.str = value + continue + } + b, err := parseBool(value) + if err != nil { + return fmt.Errorf("%s: %w", s.env, err) + } + *s.boolean = b + } + return nil +} + +// applyFile sets every value the config file at path sets. A missing file +// sets nothing, and so does a key whose value is null. +func (c *Config) applyFile(path string) error { + values, err := readFile(path) + if err != nil { + return err + } - // Set defaults - v.SetDefault("github.org", DefaultGithubOrg) - v.SetDefault("github.repository", DefaultGithubRepo) - v.SetDefault("github.token", "") - v.SetDefault("local.dir", defaultBin) - v.SetDefault("development.enabled", false) - v.SetDefault("development.binaryLocation", "") + for _, s := range c.settings() { + n, ok := values[strings.ToLower(s.key)] + if !ok || n.ShortTag() == "!!null" { + continue + } + if n.Kind != yaml.ScalarNode { + return fmt.Errorf("%s in %s: expected a single value", s.key, path) + } + if s.str != nil { + *s.str = n.Value + continue + } + b, err := fileBool(n) + if err != nil { + return fmt.Errorf("%s in %s: %w", s.key, path, err) + } + *s.boolean = b + } + return nil +} - // Bind environment variables - if err := v.BindEnv("github.org", "EDUCATES_GITHUB_ORG"); err != nil { - return fmt.Errorf("failed to bind env EDUCATES_GITHUB_ORG: %w", err) +// readFile returns the values in the config file at path, by their key as +// section.key in lower case. When a key appears more than once in +// different cases, the last one wins. +func readFile(path string) (map[string]*yaml.Node, error) { + content, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil } - if err := v.BindEnv("github.repository", "EDUCATES_GITHUB_REPOSITORY"); err != nil { - return fmt.Errorf("failed to bind env EDUCATES_GITHUB_REPOSITORY: %w", err) + if err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) } - if err := v.BindEnv("github.token", "EDUCATES_GITHUB_TOKEN"); err != nil { - return fmt.Errorf("failed to bind env EDUCATES_GITHUB_TOKEN: %w", err) + + var doc yaml.Node + if err := yaml.Unmarshal(content, &doc); err != nil { + return nil, fmt.Errorf("error reading config file %s: %w", path, err) } - if err := v.BindEnv("local.dir", "EDUCATES_LOCAL_DIR"); err != nil { - return fmt.Errorf("failed to bind env EDUCATES_LOCAL_DIR: %w", err) + if len(doc.Content) == 0 { + return nil, nil } - if err := v.BindEnv("development.enabled", "EDUCATES_DEVELOPMENT_ENABLED"); err != nil { - return fmt.Errorf("failed to bind env EDUCATES_DEVELOPMENT_ENABLED: %w", err) + + root := resolve(doc.Content[0]) + if root.ShortTag() == "!!null" { + return nil, nil } - if err := v.BindEnv("development.binaryLocation", "EDUCATES_DEVELOPMENT_BINARY_LOCATION"); err != nil { - return fmt.Errorf("failed to bind env EDUCATES_DEVELOPMENT_BINARY_LOCATION: %w", err) + if root.Kind != yaml.MappingNode { + return nil, fmt.Errorf("error reading config file %s: expected a mapping of sections", path) } - // Read config file if present - if err := v.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - return fmt.Errorf("error reading config file: %w", err) + values := map[string]*yaml.Node{} + for i := 0; i+1 < len(root.Content); i += 2 { + section, body := root.Content[i].Value, resolve(root.Content[i+1]) + if body.Kind != yaml.MappingNode { + continue + } + for j := 0; j+1 < len(body.Content); j += 2 { + key := strings.ToLower(section + "." + body.Content[j].Value) + values[key] = resolve(body.Content[j+1]) } } + return values, nil +} - // Map the configuration to our struct - c.Github.Org = v.GetString("github.org") - c.Github.Repository = v.GetString("github.repository") - c.Github.Token = v.GetString("github.token") - c.Local.Dir = v.GetString("local.dir") - c.Development.Enabled = v.GetBool("development.enabled") - c.Development.BinaryLocation = v.GetString("development.binaryLocation") +// resolve returns the node an alias points at, or n itself. +func resolve(n *yaml.Node) *yaml.Node { + for n.Kind == yaml.AliasNode { + n = n.Alias + } + return n +} - return nil +// parseBool parses the boolean forms strconv.ParseBool accepts: 1, t, T, +// TRUE, true, True, 0, f, F, FALSE, false and False. +func parseBool(value string) (bool, error) { + b, err := strconv.ParseBool(value) + if err != nil { + return false, fmt.Errorf("%q is not a boolean; use true or false", value) + } + return b, nil +} + +// fileBool parses a boolean from the config file. A number is true when it +// is not zero, and anything else takes the forms parseBool accepts. +func fileBool(n *yaml.Node) (bool, error) { + switch n.ShortTag() { + case "!!int", "!!float": + var f float64 + if err := n.Decode(&f); err != nil { + return false, fmt.Errorf("%q is not a boolean; use true or false", n.Value) + } + return f != 0, nil + default: + return parseBool(n.Value) + } } // CreateConfigAndFolders ensures the config and bin directories exist, and creates a default config.yaml if not present. @@ -157,7 +298,7 @@ func CreateConfigAndFolders() (string, string, string, bool, error) { configCreated = false } else { // Create new config with defaults - configFile := New() + configFile := defaults(pathsIn(home)) yamlBytes, err := yaml.Marshal(&configFile) if err != nil { return configDir, binDir, configPath, false, fmt.Errorf("failed to marshal config to YAML: %w", err) From 0c4f7f5f2b382acad5317b5e961ffe053ae19f1f Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:04:42 +0100 Subject: [PATCH 35/75] fix(install): time out a stalled download without capping the transfer Downloads used http.Get, which waits forever on a server that never answers. The download client now times out on connecting, on the TLS handshake and on waiting for the response headers, and sets no limit on the whole transfer, so a slow download that keeps making progress finishes. --- pkg/version/manager.go | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/pkg/version/manager.go b/pkg/version/manager.go index 9598b27..22df220 100644 --- a/pkg/version/manager.go +++ b/pkg/version/manager.go @@ -6,21 +6,34 @@ import ( "encoding/hex" "fmt" "io" + "net" "net/http" "os" "path/filepath" "runtime" "strings" + "time" "github.com/educates/educatesenv/pkg/config" "github.com/educates/educatesenv/pkg/github" "github.com/educates/educatesenv/pkg/platform" ) +// Timeouts of the HTTP client that downloads release assets. Each one bounds +// a step that waits on the server before any of the body arrives. Nothing +// bounds the whole transfer, so a slow download that keeps making progress +// finishes. +const ( + connectTimeout = 30 * time.Second + tlsHandshakeTimeout = 10 * time.Second + responseHeaderTimeout = 30 * time.Second +) + // Manager handles version-related operations type Manager struct { config *config.Config github *github.Client + http *http.Client out io.Writer errOut io.Writer } @@ -31,11 +44,27 @@ func New(cfg *config.Config, gh *github.Client, out, errOut io.Writer) *Manager return &Manager{ config: cfg, github: gh, + http: newDownloadClient(), out: out, errOut: errOut, } } +// newDownloadClient returns the HTTP client that downloads release assets. +// It times out on connecting, on the TLS handshake and on waiting for the +// response headers, and sets no limit on the whole transfer. +func newDownloadClient() *http.Client { + return &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: connectTimeout}).DialContext, + TLSHandshakeTimeout: tlsHandshakeTimeout, + ResponseHeaderTimeout: responseHeaderTimeout, + ForceAttemptHTTP2: true, + }, + } +} + // ValidateDevelopmentMode checks and cleans up development symlinks when development mode is disabled func (m *Manager) ValidateDevelopmentMode() error { if m.config.Development.Enabled { @@ -287,7 +316,7 @@ func (m *Manager) installBinary(assetName, url, releasedSum, binaryPath string) // download writes the body of a GET of url to w. func (m *Manager) download(url string, w io.Writer) error { - resp, err := http.Get(url) + resp, err := m.http.Get(url) if err != nil { return err } From f65f51f621f3c99bade571a3f1666f00f516084d Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:04:53 +0100 Subject: [PATCH 36/75] test(config): cover malformed config values --- pkg/cmd/config_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/pkg/cmd/config_test.go b/pkg/cmd/config_test.go index b53ede0..128416d 100644 --- a/pkg/cmd/config_test.go +++ b/pkg/cmd/config_test.go @@ -224,6 +224,45 @@ func TestBooleansAcceptTheirUsualForms(t *testing.T) { } } +func TestAMalformedValueIsAnError(t *testing.T) { + t.Run("a boolean in the environment", func(t *testing.T) { + env := clitest.New(t) + env.Setenv("EDUCATES_DEVELOPMENT_ENABLED", "yes") + + res := env.Run("config", "view") + + require.EqualError(t, res.Err, `EDUCATES_DEVELOPMENT_ENABLED: "yes" is not a boolean; use true or false`) + }) + + t.Run("a boolean in the config file", func(t *testing.T) { + env := clitest.New(t) + configPath := env.WriteConfig("development:\n enabled: yes\n") + + res := env.Run("config", "view") + + require.EqualError(t, res.Err, `development.enabled in `+configPath+`: "yes" is not a boolean; use true or false`) + }) + + t.Run("a list in the config file", func(t *testing.T) { + env := clitest.New(t) + configPath := env.WriteConfig("github:\n org: [acme, widgets]\n") + + res := env.Run("config", "view") + + require.EqualError(t, res.Err, `github.org in `+configPath+`: expected a single value`) + }) + + t.Run("YAML that does not parse", func(t *testing.T) { + env := clitest.New(t) + configPath := env.WriteConfig("github:\n org: acme\n bad indentation\n") + + res := env.Run("config", "view") + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "error reading config file "+configPath+": ") + }) +} + func TestRequestsCarryTheTokenFromTheEnvironmentOrTheConfigFile(t *testing.T) { tests := []struct { name string From ec497dbb0e277083139ec1ea1c0ad5460adb34ed Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:06:18 +0100 Subject: [PATCH 37/75] fix(config): create the default config readable only by its owner init, config init and list's first-run setup create the config through one function. It writes the file with mode 0600, never replaces an existing one, and leaves out the empty token key. --- pkg/cmd/config.go | 6 ++--- pkg/cmd/init.go | 7 ++--- pkg/cmd/init_test.go | 34 ++++++++++++++++++++++++ pkg/cmd/list.go | 3 +-- pkg/config/config.go | 62 +++++++++++++++++++++++--------------------- 5 files changed, 74 insertions(+), 38 deletions(-) diff --git a/pkg/cmd/config.go b/pkg/cmd/config.go index 27b7430..334f5e2 100644 --- a/pkg/cmd/config.go +++ b/pkg/cmd/config.go @@ -30,14 +30,14 @@ func newConfigInitCommand() *cobra.Command { // Disable automatic usage printing on error SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - _, _, configPath, created, err := config.CreateConfigAndFolders() + paths, created, err := config.CreateDefault() if err != nil { return err } if !created { - return fmt.Errorf("config file already exists at %s", configPath) + return fmt.Errorf("config file already exists at %s", paths.File) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config file created at %s\n", configPath) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config file created at %s\n", paths.File) return nil }, } diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index 5405448..c30eb19 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -25,15 +25,16 @@ func newInitCommand(d *deps) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { out := cmd.OutOrStdout() - _, defaultBin, configPath, configCreated, err := config.CreateConfigAndFolders() + paths, configCreated, err := config.CreateDefault() if err != nil { return err } + defaultBin := paths.BinDir if configCreated { - _, _ = fmt.Fprintf(out, "Config file created at %s\n", configPath) + _, _ = fmt.Fprintf(out, "Config file created at %s\n", paths.File) } else { - _, _ = fmt.Fprintf(out, "Config file already exists at %s\n", configPath) + _, _ = fmt.Fprintf(out, "Config file already exists at %s\n", paths.File) } _, _ = fmt.Fprintf(out, "Bin directory ensured at %s\n", defaultBin) diff --git a/pkg/cmd/init_test.go b/pkg/cmd/init_test.go index 8a9c74a..1f4ad54 100644 --- a/pkg/cmd/init_test.go +++ b/pkg/cmd/init_test.go @@ -11,6 +11,40 @@ import ( "github.com/educates/educatesenv/internal/clitest" ) +// defaultConfig returns the config file that init and config init create. +func defaultConfig(env *clitest.Env) string { + return "github:\n" + + " org: educates\n" + + " repository: educates-training-platform\n" + + "local:\n" + + " dir: " + env.BinDir() + "\n" + + "development:\n" + + " enabled: false\n" + + " binaryLocation: \"\"\n" +} + +// assertDefaultConfig checks that the config file holds the default config +// and that only its owner can read it. +func assertDefaultConfig(t *testing.T, env *clitest.Env) { + t.Helper() + configPath := filepath.Join(env.ConfigDir(), "config.yaml") + info, err := os.Stat(configPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + content, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Equal(t, defaultConfig(env), string(content)) +} + +func TestInitWritesTheDefaultConfigReadableOnlyByItsOwner(t *testing.T) { + env := clitest.New(t) + + res := env.Run("init") + + require.NoError(t, res.Err) + assertDefaultConfig(t, env) +} + func TestInitCreatesTheConfigAndBinDirectory(t *testing.T) { env := clitest.New(t) configPath := filepath.Join(env.ConfigDir(), "config.yaml") diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 924f30e..307b3bd 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -31,8 +31,7 @@ func runList(d *deps) func(cmd *cobra.Command, args []string) error { files, err := os.ReadDir(cfg.Local.Dir) // If the bin directory doesn't exist call init if err != nil { - _, _, _, _, err := config.CreateConfigAndFolders() - if err != nil { + if _, _, err := config.CreateDefault(); err != nil { return fmt.Errorf("you should run `educatesenv init` first") } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 53b4e9a..e118e00 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -27,11 +27,12 @@ const ( ConfigFileName = "config.yaml" ) -// GithubConfig holds GitHub-related configuration +// GithubConfig holds GitHub-related configuration. An empty token is left +// out of the YAML, so the default config file holds no token key. type GithubConfig struct { Org string `yaml:"org"` Repository string `yaml:"repository"` - Token string `yaml:"token"` + Token string `yaml:"token,omitempty"` } // LocalConfig holds local directory configuration @@ -274,39 +275,40 @@ func fileBool(n *yaml.Node) (bool, error) { } } -// CreateConfigAndFolders ensures the config and bin directories exist, and creates a default config.yaml if not present. -// Returns (configDir, binDir, configPath, configCreated, error) -func CreateConfigAndFolders() (string, string, string, bool, error) { - home, err := os.UserHomeDir() +// CreateDefault creates the educatesenv directory in the user's home +// directory and its bin directory, and writes the default config file there +// unless a config file exists. The file is readable only by its owner and +// holds no token. CreateDefault returns the paths and whether it wrote the +// file, and fails when the home directory cannot be determined. +func CreateDefault() (Paths, bool, error) { + paths, err := homePaths() if err != nil { - home = "." + return Paths{}, false, err } - configDir := filepath.Join(home, ConfigDirName) - binDir := filepath.Join(configDir, "bin") - configPath := filepath.Join(configDir, "config.yaml") - if err := os.MkdirAll(binDir, 0o755); err != nil { - return configDir, binDir, configPath, false, fmt.Errorf("failed to create bin directory: %w", err) + if err := os.MkdirAll(paths.BinDir, 0o755); err != nil { + return paths, false, fmt.Errorf("failed to create bin directory: %w", err) } - if err := os.MkdirAll(configDir, 0o755); err != nil { - return configDir, binDir, configPath, false, fmt.Errorf("failed to create config directory: %w", err) + + content, err := yaml.Marshal(defaults(paths)) + if err != nil { + return paths, false, fmt.Errorf("failed to marshal config to YAML: %w", err) } - configCreated := false - if _, err := os.Stat(configPath); err == nil { - // Config file already exists - configCreated = false - } else { - // Create new config with defaults - configFile := defaults(pathsIn(home)) - yamlBytes, err := yaml.Marshal(&configFile) - if err != nil { - return configDir, binDir, configPath, false, fmt.Errorf("failed to marshal config to YAML: %w", err) - } - if err := os.WriteFile(configPath, yamlBytes, 0o644); err != nil { - return configDir, binDir, configPath, false, fmt.Errorf("failed to write config file: %w", err) - } - configCreated = true + f, err := os.OpenFile(paths.File, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if errors.Is(err, fs.ErrExist) { + return paths, false, nil + } + if err != nil { + return paths, false, fmt.Errorf("failed to write config file: %w", err) + } + _, err = f.Write(content) + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err != nil { + _ = os.Remove(paths.File) + return paths, false, fmt.Errorf("failed to write config file: %w", err) } - return configDir, binDir, configPath, configCreated, nil + return paths, true, nil } From 9b23a060d0211c2a1de66c07793fce1aeaa7bb74 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:06:39 +0100 Subject: [PATCH 38/75] test(config): check that config init creates the same config as init --- pkg/cmd/config_test.go | 4 ++-- pkg/cmd/init_test.go | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/config_test.go b/pkg/cmd/config_test.go index 128416d..2958cb9 100644 --- a/pkg/cmd/config_test.go +++ b/pkg/cmd/config_test.go @@ -299,7 +299,7 @@ func TestRequestsCarryTheTokenFromTheEnvironmentOrTheConfigFile(t *testing.T) { } } -func TestConfigInitCreatesTheConfigFile(t *testing.T) { +func TestConfigInitCreatesTheSameConfigAsInit(t *testing.T) { env := clitest.New(t) configPath := filepath.Join(env.ConfigDir(), "config.yaml") @@ -307,7 +307,7 @@ func TestConfigInitCreatesTheConfigFile(t *testing.T) { require.NoError(t, res.Err) assert.Equal(t, "Config file created at "+configPath+"\n", res.Stdout) - assert.FileExists(t, configPath) + assertDefaultConfig(t, env) } func TestConfigInitRefusesToReplaceAnExistingConfig(t *testing.T) { diff --git a/pkg/cmd/init_test.go b/pkg/cmd/init_test.go index 1f4ad54..12ae209 100644 --- a/pkg/cmd/init_test.go +++ b/pkg/cmd/init_test.go @@ -3,6 +3,7 @@ package cmd_test import ( "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -45,6 +46,21 @@ func TestInitWritesTheDefaultConfigReadableOnlyByItsOwner(t *testing.T) { assertDefaultConfig(t, env) } +func TestCreatingTheConfigWithoutAHomeDirectoryFails(t *testing.T) { + for _, args := range [][]string{{"init"}, {"config", "init"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + env := clitest.New(t) + env.Unsetenv("HOME") + + res := env.Run(args...) + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "could not determine home directory") + assert.NoDirExists(t, filepath.Join(env.WorkDir, ".educatesenv")) + }) + } +} + func TestInitCreatesTheConfigAndBinDirectory(t *testing.T) { env := clitest.New(t) configPath := filepath.Join(env.ConfigDir(), "config.yaml") From 47dc3a1c6c235bf6e4717a17c8c9878141bd2594 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:07:36 +0100 Subject: [PATCH 39/75] fix(init): give PATH instructions for the user's shell Pick the instructions from the base name of $SHELL: ~/.zshrc for zsh, ~/.bashrc for bash, fish_user_paths for fish, and generic instructions for any other shell. Windows keeps its PowerShell instructions. --- pkg/cmd/init.go | 26 ++++++++++++++++----- pkg/cmd/init_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index c30eb19..f056792 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -2,6 +2,9 @@ package cmd import ( "fmt" + "io" + "os" + "path/filepath" "runtime" "github.com/spf13/cobra" @@ -40,16 +43,11 @@ func newInitCommand(d *deps) *cobra.Command { // Print PATH instructions _, _ = fmt.Fprintln(out, "\nTo use educatesenv, add the bin directory to your PATH:") - var pathCmd string switch runtime.GOOS { - case platform.Darwin, platform.Linux: - pathCmd = fmt.Sprintf("echo 'export PATH=\"%s:$PATH\"' >> ~/.bashrc\nsource ~/.bashrc", defaultBin) - _, _ = fmt.Fprintf(out, "\nFor bash/zsh, run:\n %s\n", pathCmd) - _, _ = fmt.Fprintf(out, "\nFor fish shell, run:\n set -U fish_user_paths %s $fish_user_paths\n", defaultBin) case platform.Windows: _, _ = fmt.Fprintf(out, "\nFor Windows (PowerShell), run:\n [Environment]::SetEnvironmentVariable(\"Path\", \"%s;\" + $Env:Path, [EnvironmentVariableTarget]::User)\n", defaultBin) default: - _, _ = fmt.Fprintf(out, "\nAdd %s to your PATH manually.\n", defaultBin) + writeShellPathInstructions(out, os.Getenv("SHELL"), defaultBin) } _, _ = fmt.Fprintln(out, "\nRestart your terminal or source your profile to apply the changes.") @@ -76,3 +74,19 @@ func newInitCommand(d *deps) *cobra.Command { cmd.Flags().BoolVar(&overwrite, "overwrite", false, "Force download even if the version already exists") return cmd } + +// writeShellPathInstructions writes how to add binDir to the PATH in the +// shell at the path shell, as $SHELL gives it: zsh and bash get a line for +// their rc file, fish gets fish_user_paths, and any other shell gets +// generic instructions. +func writeShellPathInstructions(out io.Writer, shell, binDir string) { + switch name := filepath.Base(shell); name { + case "zsh", "bash": + rcFile := "~/." + name + "rc" + _, _ = fmt.Fprintf(out, "\nFor %s, run:\n echo 'export PATH=\"%s:$PATH\"' >> %s\n source %s\n", name, binDir, rcFile, rcFile) + case "fish": + _, _ = fmt.Fprintf(out, "\nFor fish, run:\n set -U fish_user_paths %s $fish_user_paths\n", binDir) + default: + _, _ = fmt.Fprintf(out, "\nAdd %s to your PATH in your shell's startup file.\n", binDir) + } +} diff --git a/pkg/cmd/init_test.go b/pkg/cmd/init_test.go index 12ae209..27b38c1 100644 --- a/pkg/cmd/init_test.go +++ b/pkg/cmd/init_test.go @@ -3,6 +3,7 @@ package cmd_test import ( "os" "path/filepath" + "runtime" "strings" "testing" @@ -46,6 +47,59 @@ func TestInitWritesTheDefaultConfigReadableOnlyByItsOwner(t *testing.T) { assertDefaultConfig(t, env) } +// pathInstructions returns the part of init's output that says how to add +// the bin directory to the PATH. +func pathInstructions(t *testing.T, stdout string) string { + t.Helper() + _, rest, found := strings.Cut(stdout, "To use educatesenv, add the bin directory to your PATH:\n") + require.True(t, found, stdout) + instructions, _, found := strings.Cut(rest, "\nRestart your terminal or source your profile to apply the changes.\n") + require.True(t, found, stdout) + return instructions +} + +func TestInitGivesPathInstructionsForTheShell(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows gets PowerShell instructions") + } + + tests := []struct { + name string + shell string + want func(bin string) string + }{ + {name: "zsh", shell: "/bin/zsh", want: func(bin string) string { + return "\nFor zsh, run:\n echo 'export PATH=\"" + bin + ":$PATH\"' >> ~/.zshrc\n source ~/.zshrc\n" + }}, + {name: "bash", shell: "/opt/homebrew/bin/bash", want: func(bin string) string { + return "\nFor bash, run:\n echo 'export PATH=\"" + bin + ":$PATH\"' >> ~/.bashrc\n source ~/.bashrc\n" + }}, + {name: "fish", shell: "/usr/local/bin/fish", want: func(bin string) string { + return "\nFor fish, run:\n set -U fish_user_paths " + bin + " $fish_user_paths\n" + }}, + {name: "another shell", shell: "/bin/sh", want: func(bin string) string { + return "\nAdd " + bin + " to your PATH in your shell's startup file.\n" + }}, + {name: "SHELL not set", want: func(bin string) string { + return "\nAdd " + bin + " to your PATH in your shell's startup file.\n" + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := clitest.New(t) + if tt.shell != "" { + env.Setenv("SHELL", tt.shell) + } + + res := env.Run("init") + + require.NoError(t, res.Err) + assert.Equal(t, tt.want(env.BinDir()), pathInstructions(t, res.Stdout)) + }) + } +} + func TestCreatingTheConfigWithoutAHomeDirectoryFails(t *testing.T) { for _, args := range [][]string{{"init"}, {"config", "init"}} { t.Run(strings.Join(args, " "), func(t *testing.T) { From f64b3a4627943126ead390d5c08d00bd93ff2b12 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:07:46 +0100 Subject: [PATCH 40/75] feat(list-remote): show the newest releases by semver with publish dates list-remote shows the 10 newest stable releases, highest version first, as VERSION and RELEASED columns with the publish date in UTC. --recent N (-n N) sets how many and --all shows every one; the two conflict when both are set. --pre-releases adds pre-releases and --by-date orders by publish time. When releases were left out, a hint on stderr says how many were shown out of how many. The GitHub client reads every page of releases, 100 at a time, and maps each one to a releases.Release with its publish time, so the selection sees all of them. The releases package now decides what a pre-release is, replacing the substring check. --recents is hidden and prints a deprecation warning on stderr. --- pkg/cmd/list_remote.go | 101 +++++----- pkg/cmd/list_remote_test.go | 375 +++++++++++++++++++++++++++++++++++- pkg/github/client.go | 32 ++- 3 files changed, 448 insertions(+), 60 deletions(-) diff --git a/pkg/cmd/list_remote.go b/pkg/cmd/list_remote.go index 9f4eb70..fc31f5a 100644 --- a/pkg/cmd/list_remote.go +++ b/pkg/cmd/list_remote.go @@ -2,80 +2,85 @@ package cmd import ( "fmt" - "sort" - "strings" + "text/tabwriter" + "time" "github.com/spf13/cobra" -) -// isPreRelease checks if a version string contains pre-release indicators -func isPreRelease(version string) bool { - preReleaseIndicators := []string{ - "-alpha", "-beta", "-rc", - ".alpha.", ".beta.", ".rc.", - } - version = strings.ToLower(version) - for _, indicator := range preReleaseIndicators { - if strings.Contains(version, indicator) { - return true - } - } - return false -} + "github.com/educates/educatesenv/pkg/releases" +) func newListRemoteCommand(d *deps) *cobra.Command { var ( - showAll bool - showRecents bool + recent int + all bool + preReleases bool + byDate bool + recents bool ) cmd := &cobra.Command{ - Use: "list-remote", - Short: "List all available versions from GitHub releases", + Use: "list-remote", + Short: "List the newest versions available from GitHub releases", + Long: `List the versions available from GitHub releases, with the date each +was published. By default it shows the 10 newest stable releases, +highest version first.`, Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - out := cmd.OutOrStdout() + if recents { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Flag --recents has been deprecated, the 10 most recent releases are now shown by default") + } + if recent < 1 { + return fmt.Errorf("--recent must be at least 1, got %d", recent) + } - releases, err := d.gh.ListReleases() + published, err := d.gh.ListReleases() if err != nil { - return fmt.Errorf("failed to fetch releases: %w", err) + return err } - // Filter and collect versions - var versions []string - for _, rel := range releases { - version := rel.GetTagName() - if !showAll && (rel.GetPrerelease() || isPreRelease(version)) { - continue - } - versions = append(versions, version) + opts := releases.Options{IncludePrereleases: preReleases, Limit: recent} + if all { + opts.Limit = releases.All } - - // Sort versions in reverse order (newest first) - sort.Sort(sort.Reverse(sort.StringSlice(versions))) - - // Limit to recent versions if requested - if showRecents && len(versions) > 10 { - versions = versions[:10] + if byDate { + opts.Order = releases.ByPublishTime } - - // Print versions - _, _ = fmt.Fprintln(out, "Available versions:") - for _, version := range versions { - _, _ = fmt.Fprintf(out, "- %s\n", version) + chosen, candidates := releases.Select(published, opts) + if len(chosen) == 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No versions found") + return nil } - if len(versions) == 0 { - _, _ = fmt.Fprintln(out, "No versions found") + table := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(table, "VERSION\tRELEASED") + for _, r := range chosen { + _, _ = fmt.Fprintf(table, "%s\t%s\n", r.Tag, r.PublishedAt.UTC().Format(time.DateOnly)) } + _ = table.Flush() + if len(chosen) < candidates { + if preReleases { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Showing %d of %d releases. Use --all to see all.\n", len(chosen), candidates) + } else { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Showing %d of %d stable releases. Use --all to see all, --pre-releases to include pre-releases.\n", len(chosen), candidates) + } + } return nil }, } - cmd.Flags().BoolVar(&showAll, "all", false, "Show all versions including pre-releases") - cmd.Flags().BoolVar(&showRecents, "recents", false, "Show only the 10 most recent versions") + cmd.Flags().IntVarP(&recent, "recent", "n", 10, "Show the N newest releases") + cmd.Flags().BoolVar(&all, "all", false, "Show every release instead of the newest") + cmd.Flags().BoolVar(&preReleases, "pre-releases", false, "Include pre-releases") + cmd.Flags().BoolVar(&byDate, "by-date", false, "Order by publish date instead of by version") + // --recents is deprecated. pflag's MarkDeprecated would print its + // warning through cobra's output writer, which is stdout once SetOut is + // called, so the flag is hidden and RunE prints the warning to stderr. + cmd.Flags().BoolVar(&recents, "recents", false, "Show only the 10 most recent versions") + _ = cmd.Flags().MarkHidden("recents") + cmd.MarkFlagsMutuallyExclusive("all", "recent") return cmd } diff --git a/pkg/cmd/list_remote_test.go b/pkg/cmd/list_remote_test.go index 061e49f..ddd4588 100644 --- a/pkg/cmd/list_remote_test.go +++ b/pkg/cmd/list_remote_test.go @@ -1,7 +1,11 @@ package cmd_test import ( + "fmt" + "net/http" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -10,17 +14,376 @@ import ( "github.com/educates/educatesenv/internal/fakegithub" ) -func TestListRemoteListsStableReleasesFromGitHub(t *testing.T) { - env := clitest.New(t) +// published returns noon UTC on the given day of 2026. +func published(month time.Month, day int) time.Time { + return time.Date(2026, month, day, 12, 0, 0, 0, time.UTC) +} + +// addReleaseHistory adds twelve stable releases, 3.11.0 down to 3.0.0, +// published on the first of each month from December back to January, +// and two pre-releases of 4.0.0 published since. +func addReleaseHistory(env *clitest.Env) { env.GitHub.AddReleases( - fakegithub.Release{Tag: "4.0.0-alpha.1", Prerelease: true}, - fakegithub.Release{Tag: "3.8.0"}, - fakegithub.Release{Tag: "3.7.0"}, + fakegithub.Release{Tag: "4.0.0-alpha.13", PublishedAt: published(time.December, 20), Prerelease: true}, + fakegithub.Release{Tag: "4.0.0-alpha.2", PublishedAt: published(time.December, 10), Prerelease: true}, + fakegithub.Release{Tag: "3.11.0", PublishedAt: published(time.December, 1)}, + fakegithub.Release{Tag: "3.10.0", PublishedAt: published(time.November, 1)}, + fakegithub.Release{Tag: "3.9.0", PublishedAt: published(time.October, 1)}, + fakegithub.Release{Tag: "3.8.0", PublishedAt: published(time.September, 1)}, + fakegithub.Release{Tag: "3.7.0", PublishedAt: published(time.August, 1)}, + fakegithub.Release{Tag: "3.6.0", PublishedAt: published(time.July, 1)}, + fakegithub.Release{Tag: "3.5.0", PublishedAt: published(time.June, 1)}, + fakegithub.Release{Tag: "3.4.0", PublishedAt: published(time.May, 1)}, + fakegithub.Release{Tag: "3.3.0", PublishedAt: published(time.April, 1)}, + fakegithub.Release{Tag: "3.2.0", PublishedAt: published(time.March, 1)}, + fakegithub.Release{Tag: "3.1.0", PublishedAt: published(time.February, 1)}, + fakegithub.Release{Tag: "3.0.0", PublishedAt: published(time.January, 1)}, ) +} + +func TestListRemoteShowsTheTenNewestStableReleasesBySemver(t *testing.T) { + env := clitest.New(t) + addReleaseHistory(env) res := env.Run("list-remote") require.NoError(t, res.Err) - assert.Equal(t, "Available versions:\n- 3.8.0\n- 3.7.0\n", res.Stdout) + assert.Equal(t, ""+ + "VERSION RELEASED\n"+ + "3.11.0 2026-12-01\n"+ + "3.10.0 2026-11-01\n"+ + "3.9.0 2026-10-01\n"+ + "3.8.0 2026-09-01\n"+ + "3.7.0 2026-08-01\n"+ + "3.6.0 2026-07-01\n"+ + "3.5.0 2026-06-01\n"+ + "3.4.0 2026-05-01\n"+ + "3.3.0 2026-04-01\n"+ + "3.2.0 2026-03-01\n", + res.Stdout) + assert.Equal(t, "Showing 10 of 12 stable releases. Use --all to see all, --pre-releases to include pre-releases.\n", res.Stderr) +} + +func TestListRemoteRecentSetsHowManyReleasesAreShown(t *testing.T) { + forms := [][]string{ + {"--recent", "3"}, + {"--recent=3"}, + {"-n", "3"}, + {"-n3"}, + } + + for _, form := range forms { + t.Run(form[0], func(t *testing.T) { + env := clitest.New(t) + addReleaseHistory(env) + + res := env.Run(append([]string{"list-remote"}, form...)...) + + require.NoError(t, res.Err) + assert.Equal(t, ""+ + "VERSION RELEASED\n"+ + "3.11.0 2026-12-01\n"+ + "3.10.0 2026-11-01\n"+ + "3.9.0 2026-10-01\n", + res.Stdout) + assert.Equal(t, "Showing 3 of 12 stable releases. Use --all to see all, --pre-releases to include pre-releases.\n", res.Stderr) + }) + } +} + +func TestListRemoteAllShowsEveryStableRelease(t *testing.T) { + env := clitest.New(t) + addReleaseHistory(env) + + res := env.Run("list-remote", "--all") + + require.NoError(t, res.Err) + assert.Equal(t, ""+ + "VERSION RELEASED\n"+ + "3.11.0 2026-12-01\n"+ + "3.10.0 2026-11-01\n"+ + "3.9.0 2026-10-01\n"+ + "3.8.0 2026-09-01\n"+ + "3.7.0 2026-08-01\n"+ + "3.6.0 2026-07-01\n"+ + "3.5.0 2026-06-01\n"+ + "3.4.0 2026-05-01\n"+ + "3.3.0 2026-04-01\n"+ + "3.2.0 2026-03-01\n"+ + "3.1.0 2026-02-01\n"+ + "3.0.0 2026-01-01\n", + res.Stdout) assert.Empty(t, res.Stderr) } + +func TestListRemotePrereleasesAddsPrereleases(t *testing.T) { + tests := []struct { + name string + args []string + stdout string + stderr string + }{ + { + name: "the ten newest", + args: []string{"--pre-releases"}, + stdout: "" + + "VERSION RELEASED\n" + + "4.0.0-alpha.13 2026-12-20\n" + + "4.0.0-alpha.2 2026-12-10\n" + + "3.11.0 2026-12-01\n" + + "3.10.0 2026-11-01\n" + + "3.9.0 2026-10-01\n" + + "3.8.0 2026-09-01\n" + + "3.7.0 2026-08-01\n" + + "3.6.0 2026-07-01\n" + + "3.5.0 2026-06-01\n" + + "3.4.0 2026-05-01\n", + stderr: "Showing 10 of 14 releases. Use --all to see all.\n", + }, + { + name: "every release", + args: []string{"--pre-releases", "--all"}, + stdout: "" + + "VERSION RELEASED\n" + + "4.0.0-alpha.13 2026-12-20\n" + + "4.0.0-alpha.2 2026-12-10\n" + + "3.11.0 2026-12-01\n" + + "3.10.0 2026-11-01\n" + + "3.9.0 2026-10-01\n" + + "3.8.0 2026-09-01\n" + + "3.7.0 2026-08-01\n" + + "3.6.0 2026-07-01\n" + + "3.5.0 2026-06-01\n" + + "3.4.0 2026-05-01\n" + + "3.3.0 2026-04-01\n" + + "3.2.0 2026-03-01\n" + + "3.1.0 2026-02-01\n" + + "3.0.0 2026-01-01\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := clitest.New(t) + addReleaseHistory(env) + + res := env.Run(append([]string{"list-remote"}, tt.args...)...) + + require.NoError(t, res.Err) + assert.Equal(t, tt.stdout, res.Stdout) + assert.Equal(t, tt.stderr, res.Stderr) + }) + } +} + +func TestListRemoteByDateOrdersByPublishTime(t *testing.T) { + at := func(month time.Month, day, hour int) time.Time { + return time.Date(2026, month, day, hour, 0, 0, 0, time.UTC) + } + + tests := []struct { + name string + args []string + stdout string + stderr string + }{ + { + name: "with pre-releases", + args: []string{"--by-date", "--pre-releases"}, + stdout: "" + + "VERSION RELEASED\n" + + "4.0.0-alpha.13 2026-09-03\n" + + "3.8.1-rc.2 2026-09-01\n" + + "4.0.0-alpha.12 2026-08-27\n" + + "3.8.0 2026-08-26\n" + + "3.8.0-rc.13 2026-08-25\n" + + "3.8.0-rc.12 2026-08-25\n" + + "4.0.0-alpha.11 2026-08-20\n", + }, + { + name: "with pre-releases and a limit", + args: []string{"--by-date", "--pre-releases", "-n", "3"}, + stdout: "" + + "VERSION RELEASED\n" + + "4.0.0-alpha.13 2026-09-03\n" + + "3.8.1-rc.2 2026-09-01\n" + + "4.0.0-alpha.12 2026-08-27\n", + stderr: "Showing 3 of 7 releases. Use --all to see all.\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases( + fakegithub.Release{Tag: "4.0.0-alpha.13", PublishedAt: at(time.September, 3, 9), Prerelease: true}, + fakegithub.Release{Tag: "3.8.1-rc.2", PublishedAt: at(time.September, 1, 9), Prerelease: true}, + fakegithub.Release{Tag: "4.0.0-alpha.12", PublishedAt: at(time.August, 27, 9), Prerelease: true}, + fakegithub.Release{Tag: "3.8.0", PublishedAt: at(time.August, 26, 18)}, + fakegithub.Release{Tag: "3.8.0-rc.12", PublishedAt: at(time.August, 25, 9), Prerelease: true}, + fakegithub.Release{Tag: "3.8.0-rc.13", PublishedAt: at(time.August, 25, 17), Prerelease: true}, + fakegithub.Release{Tag: "4.0.0-alpha.11", PublishedAt: at(time.August, 20, 9), Prerelease: true}, + ) + + res := env.Run(append([]string{"list-remote"}, tt.args...)...) + + require.NoError(t, res.Err) + assert.Equal(t, tt.stdout, res.Stdout) + assert.Equal(t, tt.stderr, res.Stderr) + }) + } +} + +func TestListRemoteRecentsIsDeprecated(t *testing.T) { + env := clitest.New(t) + addReleaseHistory(env) + + res := env.Run("list-remote", "--recents") + + require.NoError(t, res.Err) + assert.Equal(t, ""+ + "VERSION RELEASED\n"+ + "3.11.0 2026-12-01\n"+ + "3.10.0 2026-11-01\n"+ + "3.9.0 2026-10-01\n"+ + "3.8.0 2026-09-01\n"+ + "3.7.0 2026-08-01\n"+ + "3.6.0 2026-07-01\n"+ + "3.5.0 2026-06-01\n"+ + "3.4.0 2026-05-01\n"+ + "3.3.0 2026-04-01\n"+ + "3.2.0 2026-03-01\n", + res.Stdout) + assert.Equal(t, ""+ + "Flag --recents has been deprecated, the 10 most recent releases are now shown by default\n"+ + "Showing 10 of 12 stable releases. Use --all to see all, --pre-releases to include pre-releases.\n", + res.Stderr) +} + +func TestListRemoteHelpLeavesOutRecents(t *testing.T) { + env := clitest.New(t) + + res := env.Run("list-remote", "--help") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "--recent int") + assert.NotContains(t, res.Stdout, "--recents") +} + +func TestListRemoteConsidersReleasesOnEveryPage(t *testing.T) { + env := clitest.New(t) + // 250 releases, 2.249.0 down to 2.0.0, span three pages of 100. + first := time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC) + for i := 249; i >= 0; i-- { + env.GitHub.AddReleases(fakegithub.Release{ + Tag: fmt.Sprintf("2.%d.0", i), + PublishedAt: first.AddDate(0, 0, i), + }) + } + + res := env.Run("list-remote", "--all") + + require.NoError(t, res.Err) + lines := strings.Split(strings.TrimSuffix(res.Stdout, "\n"), "\n") + require.Len(t, lines, 251) + assert.Equal(t, "VERSION RELEASED", lines[0]) + assert.Equal(t, "2.249.0 2020-09-06", lines[1]) + assert.Equal(t, "2.0.0 2020-01-01", lines[250]) + + res = env.Run("list-remote") + + require.NoError(t, res.Err) + assert.Equal(t, "Showing 10 of 250 stable releases. Use --all to see all, --pre-releases to include pre-releases.\n", res.Stderr) + + for _, req := range env.GitHub.Requests() { + assert.Equal(t, "100", req.Query.Get("per_page"), "per_page of %s?%s", req.Path, req.Query.Encode()) + } +} + +func TestListRemoteShowsPublishDatesInUTC(t *testing.T) { + env := clitest.New(t) + env.GitHub.Handle("GET /repos/educates/educates-training-platform/releases", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `[{"id":1,"tag_name":"3.8.0","draft":false,"prerelease":false,"published_at":"2026-08-27T01:30:00+02:00"}]`) + }) + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + assert.Equal(t, "VERSION RELEASED\n3.8.0 2026-08-26\n", res.Stdout) +} + +func TestListRemoteSaysWhenNoVersionsAreFound(t *testing.T) { + tests := []struct { + name string + releases []fakegithub.Release + }{ + {name: "no releases"}, + { + name: "only drafts and pre-releases", + releases: []fakegithub.Release{ + {Tag: "3.9.0", Draft: true}, + {Tag: "3.9.0-rc.1", PublishedAt: published(time.August, 1), Prerelease: true}, + {Tag: "2.6.0-alpha.30", PublishedAt: published(time.July, 1)}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(tt.releases...) + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + assert.Equal(t, "No versions found\n", res.Stdout) + assert.Empty(t, res.Stderr) + }) + } +} + +func TestListRemoteRejectsAllTogetherWithRecent(t *testing.T) { + forms := [][]string{ + {"--all", "--recent", "5"}, + {"--recent=3", "--all"}, + {"--all", "-n", "10"}, + } + + for _, form := range forms { + t.Run(form[0]+" "+form[1], func(t *testing.T) { + env := clitest.New(t) + addReleaseHistory(env) + + res := env.Run(append([]string{"list-remote"}, form...)...) + + require.EqualError(t, res.Err, "if any flags in the group [all recent] are set none of the others can be; [all recent] were all set") + assert.Empty(t, res.Stdout) + assert.Empty(t, res.Stderr) + }) + } +} + +func TestListRemoteRejectsRecentBelowOne(t *testing.T) { + tests := []struct { + args []string + want string + }{ + {args: []string{"--recent", "0"}, want: "--recent must be at least 1, got 0"}, + {args: []string{"--recent=-5"}, want: "--recent must be at least 1, got -5"}, + {args: []string{"-n", "-1"}, want: "--recent must be at least 1, got -1"}, + } + + for _, tt := range tests { + t.Run(tt.args[0], func(t *testing.T) { + env := clitest.New(t) + addReleaseHistory(env) + + res := env.Run(append([]string{"list-remote"}, tt.args...)...) + + require.EqualError(t, res.Err, tt.want) + assert.Empty(t, res.Stdout) + assert.Empty(t, res.Stderr) + }) + } +} diff --git a/pkg/github/client.go b/pkg/github/client.go index 48c53f5..fff9c37 100644 --- a/pkg/github/client.go +++ b/pkg/github/client.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/educates/educatesenv/pkg/config" + "github.com/educates/educatesenv/pkg/releases" "github.com/google/go-github/v93/github" ) @@ -95,11 +96,30 @@ func (c *Client) GetReleaseAssetURL(version, assetName string) (string, error) { return "", fmt.Errorf("binary for %s is not available for your platform (%s). Please check supported platforms in the documentation", version, assetName) } -// ListReleases returns all releases from the repository -func (c *Client) ListReleases() ([]*github.RepositoryRelease, error) { - releases, _, err := c.client.Repositories.ListReleases(context.Background(), c.config.Github.Org, c.config.Github.Repository, &github.ListOptions{PerPage: 100}) - if err != nil { - return nil, fmt.Errorf("failed to fetch releases: %w", err) +// ListReleases returns every release of the repository, drafts included, +// in the order GitHub lists them. It reads every page, 100 releases at a +// time. +func (c *Client) ListReleases() ([]releases.Release, error) { + var all []releases.Release + opts := &github.ListOptions{PerPage: 100} + for { + page, resp, err := c.client.Repositories.ListReleases(context.Background(), c.config.Github.Org, c.config.Github.Repository, opts) + if err != nil { + return nil, fmt.Errorf("failed to fetch releases: %w", err) + } + + for _, rel := range page { + all = append(all, releases.Release{ + Tag: rel.GetTagName(), + PublishedAt: rel.GetPublishedAt().Time, + GitHubPrerelease: rel.GetPrerelease(), + Draft: rel.GetDraft(), + }) + } + + if resp.NextPage == 0 { + return all, nil + } + opts.Page = resp.NextPage } - return releases, nil } From 231c3b1c998a5e8ed9f92c72fa202bf323862884 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:08:06 +0100 Subject: [PATCH 41/75] test(config): cover config files that set no values --- pkg/cmd/config_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pkg/cmd/config_test.go b/pkg/cmd/config_test.go index 2958cb9..4c7c083 100644 --- a/pkg/cmd/config_test.go +++ b/pkg/cmd/config_test.go @@ -43,6 +43,24 @@ func TestConfigViewShowsTheDefaultsWithoutAConfigFile(t *testing.T) { assert.Contains(t, out, " binaryLocation: \"\"\n") } +func TestAConfigFileWithoutValuesLeavesTheDefaults(t *testing.T) { + for name, content := range map[string]string{ + "empty": "", + "comments only": "# educatesenv\n", + "an empty section": "github:\n", + } { + t.Run(name, func(t *testing.T) { + env := clitest.New(t) + env.WriteConfig(content) + + out := configView(t, env) + + assert.Contains(t, out, " org: educates\n") + assert.Contains(t, out, " dir: "+env.BinDir()+"\n") + }) + } +} + func TestEachRunReadsTheConfigAfresh(t *testing.T) { env := clitest.New(t) configPath := env.WriteConfig("github:\n org: acme\n") From 2603f0d091d542d6f8883fc9e94b516d61c1616a Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:11:50 +0100 Subject: [PATCH 42/75] feat(releases): order installed version names by semver Semver names come highest first, using the same rule that decides which tags list-remote considers, then the names that are not semver, alphabetically. --- pkg/releases/installed.go | 35 ++++++++++++++ pkg/releases/installed_test.go | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 pkg/releases/installed.go create mode 100644 pkg/releases/installed_test.go diff --git a/pkg/releases/installed.go b/pkg/releases/installed.go new file mode 100644 index 0000000..99dedba --- /dev/null +++ b/pkg/releases/installed.go @@ -0,0 +1,35 @@ +package releases + +import ( + "slices" + "strings" + + "golang.org/x/mod/semver" +) + +// SortInstalled returns the names of installed versions in the order +// list shows them: names that are semver versions, highest first, then +// the names that are not, alphabetically. A name is a semver version by +// the same rule that decides which tags Select considers. Versions that +// semver ranks equal, which differ only in build metadata, are ordered +// alphabetically. +func SortInstalled(names []string) []string { + sorted := slices.Clone(names) + slices.SortFunc(sorted, func(a, b string) int { + aIsSemver, bIsSemver := isSemver(a), isSemver(b) + switch { + case aIsSemver && bIsSemver: + if c := semver.Compare(version(b), version(a)); c != 0 { + return c + } + return strings.Compare(a, b) + case aIsSemver: + return -1 + case bIsSemver: + return 1 + default: + return strings.Compare(a, b) + } + }) + return sorted +} diff --git a/pkg/releases/installed_test.go b/pkg/releases/installed_test.go new file mode 100644 index 0000000..77c6f77 --- /dev/null +++ b/pkg/releases/installed_test.go @@ -0,0 +1,84 @@ +package releases_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/educates/educatesenv/pkg/releases" +) + +func TestSortInstalledOrdersSemverNamesHighestFirst(t *testing.T) { + tests := []struct { + name string + names []string + want []string + }{ + { + name: "numeric minor versions", + names: []string{"3.9.0", "3.10.0", "3.8.1"}, + want: []string{"3.10.0", "3.9.0", "3.8.1"}, + }, + { + name: "numeric pre-release identifiers", + names: []string{"4.0.0-alpha.2", "4.0.0-alpha.13", "4.0.0-alpha.9"}, + want: []string{"4.0.0-alpha.13", "4.0.0-alpha.9", "4.0.0-alpha.2"}, + }, + { + name: "a release above its pre-releases", + names: []string{"3.8.0-rc.13", "3.7.4", "3.8.0", "3.8.0-rc.2"}, + want: []string{"3.8.0", "3.8.0-rc.13", "3.8.0-rc.2", "3.7.4"}, + }, + { + name: "versions that differ only in build metadata, alphabetically", + names: []string{"3.8.0+build.2", "3.9.0", "3.8.0+build.1"}, + want: []string{"3.9.0", "3.8.0+build.1", "3.8.0+build.2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, releases.SortInstalled(tt.names)) + }) + } +} + +func TestSortInstalledPutsNamesThatAreNotSemverLastAlphabetically(t *testing.T) { + tests := []struct { + name string + names []string + want []string + }{ + { + name: "after the semver names", + names: []string{"latest", "3.9.0", "my-build", "3.10.0"}, + want: []string{"3.10.0", "3.9.0", "latest", "my-build"}, + }, + { + name: "shorthands, prefixes and leading zeros are not semver", + names: []string{ + "v3.7.0", "3.8", "3.08.0", "3.7.0", "3", + }, + want: []string{"3.7.0", "3", "3.08.0", "3.8", "v3.7.0"}, + }, + { + name: "only names that are not semver", + names: []string{"zeta", "alpha", "Beta"}, + want: []string{"Beta", "alpha", "zeta"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, releases.SortInstalled(tt.names)) + }) + } +} + +func TestSortInstalledLeavesItsInputAlone(t *testing.T) { + names := []string{"3.7.0", "latest", "3.8.0"} + + _ = releases.SortInstalled(names) + + assert.Equal(t, []string{"3.7.0", "latest", "3.8.0"}, names) +} From b19264a917a57840b5fc85bea1171c038d3e4483 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:12:04 +0100 Subject: [PATCH 43/75] feat(releases): pick latest as the highest stable release by semver Latest takes part in the same selection as list-remote without pre-releases, so drafts, tags that are not semver and pre-releases, flagged by GitHub or by their tag, never count. It looks at every release, so neither creation order nor publish time decides it. --- pkg/releases/latest.go | 13 +++++ pkg/releases/latest_test.go | 94 +++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 pkg/releases/latest.go create mode 100644 pkg/releases/latest_test.go diff --git a/pkg/releases/latest.go b/pkg/releases/latest.go new file mode 100644 index 0000000..dccb3cc --- /dev/null +++ b/pkg/releases/latest.go @@ -0,0 +1,13 @@ +package releases + +// Latest returns the highest stable release in all by semver. It follows +// the rules Select follows without pre-releases: drafts, pre-releases and +// tags that are not semver never count. Neither the order of all nor +// publish times matter. ok is false when all holds no stable release. +func Latest(all []Release) (latest Release, ok bool) { + chosen, _ := Select(all, Options{Limit: 1, Order: BySemver}) + if len(chosen) == 0 { + return Release{}, false + } + return chosen[0], true +} diff --git a/pkg/releases/latest_test.go b/pkg/releases/latest_test.go new file mode 100644 index 0000000..0fca392 --- /dev/null +++ b/pkg/releases/latest_test.go @@ -0,0 +1,94 @@ +package releases_test + +import ( + "fmt" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/educates/educatesenv/pkg/releases" +) + +func TestLatestIsTheHighestStableReleaseBySemver(t *testing.T) { + // Twelve release candidates of 3.8.0, newest first, as GitHub lists + // them during a release-candidate cycle. + var candidates []releases.Release + for rc := 12; rc >= 1; rc-- { + candidates = append(candidates, releases.Release{ + Tag: fmt.Sprintf("3.8.0-rc.%d", rc), + PublishedAt: day(time.August, 14+rc), + GitHubPrerelease: true, + }) + } + + tests := []struct { + name string + all []releases.Release + want string + }{ + { + name: "ordered by semver, not as strings", + all: []releases.Release{stable("3.9.0"), stable("3.10.0"), stable("3.8.0")}, + want: "3.10.0", + }, + { + name: "after more than ten consecutive pre-releases", + all: slices.Concat(candidates, []releases.Release{stable("3.7.3"), stable("3.7.2")}), + want: "3.7.3", + }, + { + name: "a backport published after a higher release", + all: []releases.Release{ + {Tag: "3.7.4", PublishedAt: day(time.September, 10)}, + {Tag: "3.8.0", PublishedAt: day(time.August, 26)}, + {Tag: "3.7.3", PublishedAt: day(time.July, 1)}, + }, + want: "3.8.0", + }, + { + name: "pre-release tags that GitHub never flagged", + all: []releases.Release{stable("3.9.0-rc.1"), stable("2.6.0-alpha.30"), stable("2.5.0")}, + want: "2.5.0", + }, + { + name: "a release GitHub flags as a pre-release", + all: []releases.Release{ + {Tag: "3.9.0", PublishedAt: day(time.September, 1), GitHubPrerelease: true}, + stable("3.8.0"), + }, + want: "3.8.0", + }, + { + name: "drafts and tags that are not semver", + all: []releases.Release{ + {Tag: "3.9.0", Draft: true}, + stable("latest"), + stable("v4.0.0"), + stable("4.0"), + stable("3.8.0"), + }, + want: "3.8.0", + }, + { + name: "no stable release", + all: slices.Concat(candidates, []releases.Release{ + {Tag: "3.7.0", Draft: true}, + stable("3.6.0-beta.1"), + }), + }, + { + name: "no release at all", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + latest, ok := releases.Latest(tt.all) + + assert.Equal(t, tt.want != "", ok) + assert.Equal(t, tt.want, latest.Tag) + }) + } +} From 53ebfc8ff6c930e0e5674d64ec5d19c94a3af59b Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:13:41 +0100 Subject: [PATCH 44/75] feat(list): show installed versions newest first by semver list orders installed versions the way list-remote orders releases, with the develop entry first when development mode is enabled and names that are not semver last, alphabetically. The active marker is unchanged. --- pkg/cmd/list.go | 24 ++++----- pkg/cmd/list_test.go | 113 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 284bb72..83a43ec 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/educates/educatesenv/pkg/config" + "github.com/educates/educatesenv/pkg/releases" "github.com/spf13/cobra" ) @@ -63,24 +64,25 @@ func runList(d *deps) func(cmd *cobra.Command, args []string) error { } } - // Print regular versions - foundVersions := false + // Print regular versions, newest first + var versions []string for _, file := range files { if file.IsDir() || file.Name() == "educates" { continue } - if strings.HasPrefix(file.Name(), "educates-") { - version := strings.TrimPrefix(file.Name(), "educates-") - if version == activeVersion { - _, _ = fmt.Fprintf(out, "* %s (active)\n", version) - } else { - _, _ = fmt.Fprintf(out, " %s\n", version) - } - foundVersions = true + if version, ok := strings.CutPrefix(file.Name(), "educates-"); ok { + versions = append(versions, version) + } + } + for _, version := range releases.SortInstalled(versions) { + if version == activeVersion { + _, _ = fmt.Fprintf(out, "* %s (active)\n", version) + } else { + _, _ = fmt.Fprintf(out, " %s\n", version) } } - if !foundVersions && !cfg.Development.Enabled { + if len(versions) == 0 && !cfg.Development.Enabled { _, _ = fmt.Fprintln(out, "No versions installed") } diff --git a/pkg/cmd/list_test.go b/pkg/cmd/list_test.go index 5d10dc3..38b1f08 100644 --- a/pkg/cmd/list_test.go +++ b/pkg/cmd/list_test.go @@ -22,6 +22,119 @@ func TestListMarksTheActiveVersion(t *testing.T) { assert.Empty(t, res.Stderr) } +func TestListShowsInstalledVersionsNewestFirstBySemver(t *testing.T) { + tests := []struct { + name string + active string + want string + }{ + { + name: "no version active", + want: "Installed versions:\n" + + " 4.0.0-alpha.13\n" + + " 4.0.0-alpha.2\n" + + " 3.10.0\n" + + " 3.9.0\n" + + " 3.8\n" + + " my-build\n", + }, + { + name: "a semver version active", + active: "3.9.0", + want: "Installed versions:\n" + + " 4.0.0-alpha.13\n" + + " 4.0.0-alpha.2\n" + + " 3.10.0\n" + + "* 3.9.0 (active)\n" + + " 3.8\n" + + " my-build\n", + }, + { + name: "a name that is not semver active", + active: "my-build", + want: "Installed versions:\n" + + " 4.0.0-alpha.13\n" + + " 4.0.0-alpha.2\n" + + " 3.10.0\n" + + " 3.9.0\n" + + " 3.8\n" + + "* my-build (active)\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := clitest.New(t) + for _, v := range []string{"3.9.0", "my-build", "4.0.0-alpha.2", "3.8", "3.10.0", "4.0.0-alpha.13"} { + env.WriteBinary(v) + } + if tt.active != "" { + require.NoError(t, os.Symlink("educates-"+tt.active, env.ActiveLink())) + } + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, tt.want, res.Stdout) + assert.Empty(t, res.Stderr) + }) + } +} + +func TestListShowsDevelopFirstWhenDevelopmentModeIsEnabled(t *testing.T) { + tests := []struct { + name string + activeVersion string + want func(devBinary string) string + }{ + { + name: "develop active", + want: func(devBinary string) string { + return "Installed versions:\n" + + "* develop (active) -> " + devBinary + "\n" + + " 4.0.0-alpha.2\n" + + " 3.10.0\n" + + " 3.9.0\n" + + " my-build\n" + }, + }, + { + name: "an installed version active", + activeVersion: "3.9.0", + want: func(devBinary string) string { + return "Installed versions:\n" + + " develop -> " + devBinary + "\n" + + " 4.0.0-alpha.2\n" + + " 3.10.0\n" + + "* 3.9.0 (active)\n" + + " my-build\n" + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := clitest.New(t) + devBinary := writeDevelopmentBinary(t, env) + env.WriteConfig("development:\n enabled: true\n binaryLocation: " + devBinary + "\n") + for _, v := range []string{"3.9.0", "my-build", "4.0.0-alpha.2", "3.10.0"} { + env.WriteBinary(v) + } + target := devBinary + if tt.activeVersion != "" { + target = "educates-" + tt.activeVersion + } + require.NoError(t, os.Symlink(target, env.ActiveLink())) + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, tt.want(devBinary), res.Stdout) + assert.Empty(t, res.Stderr) + }) + } +} + func TestListSaysWhenNoVersionIsInstalled(t *testing.T) { env := clitest.New(t) From 07b31d202c0b3c46e12373a1c43f52c67b7757e6 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:14:42 +0100 Subject: [PATCH 45/75] feat(install): install latest as the highest stable release install latest resolves to the highest stable release by semver across every release, prints "Resolved latest to X" and then installs X the way a named version is installed, with --use and --overwrite. When no stable release exists, the error points at list-remote --pre-releases. --- pkg/cmd/install.go | 7 ++- pkg/cmd/install_test.go | 94 +++++++++++++++++++++++++++++++++++++++++ pkg/version/latest.go | 26 ++++++++++++ pkg/version/manager.go | 12 +++++- 4 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 pkg/version/latest.go diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index b4fce42..192beb7 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -16,8 +16,11 @@ func newInstallCommand(d *deps) *cobra.Command { ) cmd := &cobra.Command{ - Use: "install ", - Short: "Install a specific version of educates", + Use: "install ", + Short: "Install a specific version of educates, or the latest stable one", + Long: `Install a version of educates into the bin directory. The version +latest installs the highest stable release by semver, and says which +version that is.`, Args: cobra.ExactArgs(1), SilenceErrors: true, SilenceUsage: true, diff --git a/pkg/cmd/install_test.go b/pkg/cmd/install_test.go index 5d0c8a6..6e1d800 100644 --- a/pkg/cmd/install_test.go +++ b/pkg/cmd/install_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -204,3 +205,96 @@ func TestInstallWarnsAndContinuesWhenTheReleaseHasNoChecksumsTxt(t *testing.T) { assert.Equal(t, "educates 3.8.0 binary", string(content)) assert.Equal(t, []string{"educates-3.8.0"}, binDirEntries(t, env)) } + +func TestInstallLatestInstallsTheHighestStableRelease(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.9.0-rc.1", []byte("educates 3.9.0-rc.1 binary")) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + addRelease(env, "3.7.0", []byte("educates 3.7.0 binary")) + + res := env.Run("install", "latest") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Resolved latest to 3.8.0\nInstalling version 3.8.0...\n") + assert.Contains(t, res.Stdout, "educates 3.8.0 installed successfully.\n") + assert.Empty(t, res.Stderr) + content, err := os.ReadFile(filepath.Join(env.BinDir(), "educates-3.8.0")) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0 binary", string(content)) + assert.Equal(t, []string{"educates-3.8.0"}, binDirEntries(t, env)) +} + +func TestInstallLatestWithUseActivatesTheResolvedVersion(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + addRelease(env, "3.7.0", []byte("educates 3.7.0 binary")) + + res := env.Run("install", "latest", "--use") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Resolved latest to 3.8.0\n") + assert.Contains(t, res.Stdout, "educates 3.8.0 is now active.\n") + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestInstallLatestKeepsAnInstalledVersionUnlessOverwritten(t *testing.T) { + env := clitest.New(t) + installed := env.WriteBinary("3.8.0") + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + + res := env.Run("install", "latest") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Resolved latest to 3.8.0\nVersion 3.8.0 is already installed.\n") + content, err := os.ReadFile(installed) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0", string(content)) + + res = env.Run("install", "latest", "--overwrite") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Resolved latest to 3.8.0\nReinstalling version 3.8.0...\n") + assert.Contains(t, res.Stdout, "educates 3.8.0 installed successfully.\n") + content, err = os.ReadFile(installed) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0 binary", string(content)) +} + +// addPublishedRelease adds a release published at the given time, with +// the platform binary and a checksums.txt that lists it. +func addPublishedRelease(env *clitest.Env, tag string, at time.Time, prerelease bool) { + asset := clitest.PlatformBinary([]byte("educates " + tag + " binary")) + env.GitHub.AddReleases(fakegithub.Release{ + Tag: tag, + PublishedAt: at, + Prerelease: prerelease, + Assets: []fakegithub.Asset{asset, fakegithub.Checksums(asset)}, + }) +} + +func TestInstallLatestNeverPicksABackportPublishedAfterAHigherRelease(t *testing.T) { + env := clitest.New(t) + addPublishedRelease(env, "3.7.4", published(time.September, 10), false) + addPublishedRelease(env, "3.8.0", published(time.August, 26), false) + addPublishedRelease(env, "3.7.3", published(time.July, 1), false) + + res := env.Run("install", "latest") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Resolved latest to 3.8.0\n") + assert.Equal(t, []string{"educates-3.8.0"}, binDirEntries(t, env)) +} + +func TestInstallLatestFailsWhenThereIsNoStableRelease(t *testing.T) { + env := clitest.New(t) + addPublishedRelease(env, "3.8.0-rc.2", published(time.August, 15), true) + addPublishedRelease(env, "3.8.0-rc.1", published(time.August, 14), true) + + res := env.Run("install", "latest") + + require.EqualError(t, res.Err, "failed to install version latest: no stable releases found in educates/educates-training-platform. Run 'educatesenv list-remote --pre-releases' to see pre-releases") + assert.NotContains(t, res.Stdout, "Resolved latest") + assert.Empty(t, binDirEntries(t, env)) +} diff --git a/pkg/version/latest.go b/pkg/version/latest.go new file mode 100644 index 0000000..e9fc1b3 --- /dev/null +++ b/pkg/version/latest.go @@ -0,0 +1,26 @@ +package version + +import ( + "fmt" + + "github.com/educates/educatesenv/pkg/releases" +) + +// Latest is the version name that InstallVersion resolves to the highest +// stable release. +const Latest = "latest" + +// ResolveLatest returns the tag of the highest stable release by semver, +// among every release of the repository. +func (m *Manager) ResolveLatest() (string, error) { + published, err := m.github.ListReleases() + if err != nil { + return "", err + } + + latest, ok := releases.Latest(published) + if !ok { + return "", fmt.Errorf("no stable releases found in %s/%s. Run 'educatesenv list-remote --pre-releases' to see pre-releases", m.config.Github.Org, m.config.Github.Repository) + } + return latest.Tag, nil +} diff --git a/pkg/version/manager.go b/pkg/version/manager.go index 0385080..f5ee06c 100644 --- a/pkg/version/manager.go +++ b/pkg/version/manager.go @@ -149,8 +149,18 @@ func (m *Manager) GetPlatformBinaryName() (string, error) { return platform.GetPlatformBinaryName(os, arch), nil } -// InstallVersion installs a specific version of educates +// InstallVersion installs a specific version of educates. Latest installs +// the highest stable release, and says which version that is. func (m *Manager) InstallVersion(version string, force bool, activate bool) error { + if version == Latest { + resolved, err := m.ResolveLatest() + if err != nil { + return err + } + _, _ = fmt.Fprintf(m.out, "Resolved latest to %s\n", resolved) + version = resolved + } + binDir := m.config.Local.Dir if err := os.MkdirAll(binDir, 0o755); err != nil { return fmt.Errorf("failed to create bin directory %s: %w", binDir, err) From ec8d2bab6c3323762020912e5f6e195169a709d3 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:16:58 +0100 Subject: [PATCH 46/75] fix(init): download the highest stable release by semver init --download resolves latest the way install latest does, from every release by semver. It used to take the first stable release among the 10 most recently created ones, which failed during a long run of release candidates and preferred a backport published after a higher release. GetLatestReleaseVersion and its 10-release window are removed. Listing releases keeps its message for a missing repository, which names it. --- pkg/cmd/init.go | 9 ++------- pkg/cmd/init_test.go | 35 +++++++++++++++++++++++++++++++++++ pkg/cmd/install_test.go | 15 ++++++++++++++- pkg/github/client.go | 25 +++---------------------- 4 files changed, 54 insertions(+), 30 deletions(-) diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index f56ecc8..e047603 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -8,6 +8,7 @@ import ( "github.com/educates/educatesenv/pkg/config" "github.com/educates/educatesenv/pkg/platform" + "github.com/educates/educatesenv/pkg/version" ) func newInitCommand(d *deps) *cobra.Command { @@ -55,13 +56,7 @@ func newInitCommand(d *deps) *cobra.Command { if downloadLatest { _, _ = fmt.Fprintln(out, "\nFetching latest educates version...") - latest, err := d.gh.GetLatestReleaseVersion() - if err != nil { - return fmt.Errorf("failed to get latest release version: %w", err) - } - _, _ = fmt.Fprintf(out, "Latest version: %s\n", latest) - - if err := d.manager.InstallVersion(latest, overwrite, true); err != nil { + if err := d.manager.InstallVersion(version.Latest, overwrite, true); err != nil { return err } } else { diff --git a/pkg/cmd/init_test.go b/pkg/cmd/init_test.go index 8a9c74a..8e5c0b1 100644 --- a/pkg/cmd/init_test.go +++ b/pkg/cmd/init_test.go @@ -1,9 +1,11 @@ package cmd_test import ( + "fmt" "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -38,3 +40,36 @@ func TestInitKeepsAnExistingConfig(t *testing.T) { require.NoError(t, err) assert.Equal(t, "github:\n org: acme\n", string(content)) } + +func TestInitDownloadInstallsTheHighestStableReleaseAfterARunOfPreReleases(t *testing.T) { + env := clitest.New(t) + // The twelve newest releases are release candidates of 3.8.0, as + // during its release-candidate cycle. + for rc := 12; rc >= 1; rc-- { + addPublishedRelease(env, fmt.Sprintf("3.8.0-rc.%d", rc), published(time.August, 14+rc), true) + } + addPublishedRelease(env, "3.7.3", published(time.August, 1), false) + addPublishedRelease(env, "3.7.2", published(time.July, 1), false) + + res := env.Run("init", "--download") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Resolved latest to 3.7.3\nInstalling version 3.7.3...\n") + assert.Contains(t, res.Stdout, "educates 3.7.3 is now active.\n") + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.7.3", target) + content, err := os.ReadFile(filepath.Join(env.BinDir(), "educates-3.7.3")) + require.NoError(t, err) + assert.Equal(t, "educates 3.7.3 binary", string(content)) +} + +func TestInitDownloadFailsWhenThereIsNoStableRelease(t *testing.T) { + env := clitest.New(t) + addPublishedRelease(env, "3.8.0-rc.1", published(time.August, 14), true) + + res := env.Run("init", "--download") + + require.EqualError(t, res.Err, noStableRelease) + assert.NoFileExists(t, env.ActiveLink()) +} diff --git a/pkg/cmd/install_test.go b/pkg/cmd/install_test.go index 6e1d800..5a8bcdc 100644 --- a/pkg/cmd/install_test.go +++ b/pkg/cmd/install_test.go @@ -287,6 +287,10 @@ func TestInstallLatestNeverPicksABackportPublishedAfterAHigherRelease(t *testing assert.Equal(t, []string{"educates-3.8.0"}, binDirEntries(t, env)) } +// noStableRelease is the error resolving latest gives when the repository +// has no stable release. +const noStableRelease = "no stable releases found in educates/educates-training-platform. Run 'educatesenv list-remote --pre-releases' to see pre-releases" + func TestInstallLatestFailsWhenThereIsNoStableRelease(t *testing.T) { env := clitest.New(t) addPublishedRelease(env, "3.8.0-rc.2", published(time.August, 15), true) @@ -294,7 +298,16 @@ func TestInstallLatestFailsWhenThereIsNoStableRelease(t *testing.T) { res := env.Run("install", "latest") - require.EqualError(t, res.Err, "failed to install version latest: no stable releases found in educates/educates-training-platform. Run 'educatesenv list-remote --pre-releases' to see pre-releases") + require.EqualError(t, res.Err, "failed to install version latest: "+noStableRelease) assert.NotContains(t, res.Stdout, "Resolved latest") assert.Empty(t, binDirEntries(t, env)) } + +func TestInstallLatestNamesARepositoryThatIsNotFound(t *testing.T) { + env := clitest.New(t) + env.GitHub.SetRepository("acme", "elsewhere") + + res := env.Run("install", "latest") + + require.EqualError(t, res.Err, "failed to install version latest: repository educates/educates-training-platform not found") +} diff --git a/pkg/github/client.go b/pkg/github/client.go index 268a6da..d824a00 100644 --- a/pkg/github/client.go +++ b/pkg/github/client.go @@ -56,28 +56,6 @@ func New(cfg *config.Config, opts ...Option) (*Client, error) { }, nil } -// GetLatestReleaseVersion returns the latest stable release version -func (c *Client) GetLatestReleaseVersion() (string, error) { - releases, resp, err := c.client.Repositories.ListReleases(context.Background(), c.config.Github.Org, c.config.Github.Repository, &github.ListOptions{PerPage: 10}) - if err != nil { - if resp != nil && resp.StatusCode == 404 { - return "", fmt.Errorf("repository %s/%s not found", c.config.Github.Org, c.config.Github.Repository) - } - return "", fmt.Errorf("failed to fetch releases: %w", err) - } - - if len(releases) == 0 { - return "", fmt.Errorf("no releases found in %s/%s", c.config.Github.Org, c.config.Github.Repository) - } - - for _, rel := range releases { - if rel.GetTagName() != "" && !rel.GetPrerelease() { - return rel.GetTagName(), nil - } - } - return "", fmt.Errorf("no stable releases found in %s/%s. Try 'educatesenv list-remote --all' to see pre-releases", c.config.Github.Org, c.config.Github.Repository) -} - // ChecksumsAssetName is the name of the release asset that lists the // SHA-256 checksum of each of the release's other assets. const ChecksumsAssetName = "checksums.txt" @@ -128,6 +106,9 @@ func (c *Client) ListReleases() ([]releases.Release, error) { for { page, resp, err := c.client.Repositories.ListReleases(context.Background(), c.config.Github.Org, c.config.Github.Repository, opts) if err != nil { + if resp != nil && resp.StatusCode == 404 { + return nil, fmt.Errorf("repository %s/%s not found", c.config.Github.Org, c.config.Github.Repository) + } return nil, fmt.Errorf("failed to fetch releases: %w", err) } From c26faab6db34c86cd747d968561825d3b844645a Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:18:19 +0100 Subject: [PATCH 47/75] feat(token): resolve the GitHub token from the environment, gh and the config file The token comes from the first of EDUCATES_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN, gh auth token --hostname github.com and github.token in the config file that has one. The config no longer reads EDUCATES_GITHUB_TOKEN, and the GitHub client takes the resolved token instead of reading it from the config. --- pkg/cmd/config_test.go | 37 ------------ pkg/cmd/root.go | 4 +- pkg/cmd/token_test.go | 99 ++++++++++++++++++++++++++++++++ pkg/config/config.go | 30 ++++++++-- pkg/github/client.go | 18 +++++- pkg/github/client_test.go | 51 ----------------- pkg/token/token.go | 117 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 258 insertions(+), 98 deletions(-) create mode 100644 pkg/cmd/token_test.go delete mode 100644 pkg/github/client_test.go create mode 100644 pkg/token/token.go diff --git a/pkg/cmd/config_test.go b/pkg/cmd/config_test.go index 4c7c083..c1c97e5 100644 --- a/pkg/cmd/config_test.go +++ b/pkg/cmd/config_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" "github.com/educates/educatesenv/internal/clitest" - "github.com/educates/educatesenv/internal/fakegithub" ) // configView runs `config view` and returns what it printed. @@ -281,42 +280,6 @@ func TestAMalformedValueIsAnError(t *testing.T) { }) } -func TestRequestsCarryTheTokenFromTheEnvironmentOrTheConfigFile(t *testing.T) { - tests := []struct { - name string - envVar string - config string - want string - }{ - {name: "EDUCATES_GITHUB_TOKEN", envVar: "envtoken", want: "Bearer envtoken"}, - {name: "github.token", config: "github:\n token: filetoken\n", want: "Bearer filetoken"}, - {name: "EDUCATES_GITHUB_TOKEN over github.token", envVar: "envtoken", config: "github:\n token: filetoken\n", want: "Bearer envtoken"}, - {name: "neither", want: ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - env := clitest.New(t) - env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0"}) - if tt.config != "" { - env.WriteConfig(tt.config) - } - if tt.envVar != "" { - env.Setenv("EDUCATES_GITHUB_TOKEN", tt.envVar) - } - - res := env.Run("list-remote") - - require.NoError(t, res.Err) - requests := env.GitHub.Requests() - require.NotEmpty(t, requests) - for _, r := range requests { - assert.Equal(t, tt.want, r.Header.Get("Authorization"), r.Path) - } - }) - } -} - func TestConfigInitCreatesTheSameConfigAsInit(t *testing.T) { env := clitest.New(t) configPath := filepath.Join(env.ConfigDir(), "config.yaml") diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index c35ae87..6077dfa 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -8,6 +8,7 @@ import ( "github.com/educates/educatesenv/pkg/config" "github.com/educates/educatesenv/pkg/github" + "github.com/educates/educatesenv/pkg/token" "github.com/educates/educatesenv/pkg/version" ) @@ -45,7 +46,8 @@ func (d *deps) load(out, errOut io.Writer) error { return err } - d.gh, err = github.New(d.cfg, github.WithBaseURL(d.opts.githubBaseURL)) + tok := token.Resolve(d.cfg.File(), d.cfg.Github.Token, errOut) + d.gh, err = github.New(d.cfg, github.WithBaseURL(d.opts.githubBaseURL), github.WithToken(tok)) if err != nil { return err } diff --git a/pkg/cmd/token_test.go b/pkg/cmd/token_test.go new file mode 100644 index 0000000..95d0d7f --- /dev/null +++ b/pkg/cmd/token_test.go @@ -0,0 +1,99 @@ +package cmd_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" + "github.com/educates/educatesenv/internal/fakegithub" +) + +// The tokens each source holds in these tests. +const ( + educatesEnvToken = "educates-env-token" + ghEnvToken = "gh-env-token" + githubEnvToken = "github-env-token" + ghCLIToken = "gh-cli-token" + fileToken = "file-token" +) + +// tokenSource sets up one source of the GitHub token in env. +type tokenSource struct { + name string + token string + set func(env *clitest.Env) +} + +// tokenSources are the sources of the GitHub token, in the order +// educatesenv checks them. +var tokenSources = []tokenSource{ + {name: "EDUCATES_GITHUB_TOKEN", token: educatesEnvToken, set: func(env *clitest.Env) { + env.Setenv("EDUCATES_GITHUB_TOKEN", educatesEnvToken) + }}, + {name: "GH_TOKEN", token: ghEnvToken, set: func(env *clitest.Env) { + env.Setenv("GH_TOKEN", ghEnvToken) + }}, + {name: "GITHUB_TOKEN", token: githubEnvToken, set: func(env *clitest.Env) { + env.Setenv("GITHUB_TOKEN", githubEnvToken) + }}, + {name: "gh auth token", token: ghCLIToken, set: func(env *clitest.Env) { + fakeGHWithToken(env, ghCLIToken) + }}, + {name: "config file", token: fileToken, set: func(env *clitest.Env) { + env.WriteConfig("github:\n token: " + fileToken + "\n") + }}, +} + +// fakeGHWithToken puts a gh on the PATH that prints token for +// `gh auth token --hostname github.com` and fails for anything else. +func fakeGHWithToken(env *clitest.Env, token string) { + env.FakeGH(`if [ "$*" = "auth token --hostname github.com" ]; then printf '%s\n' '` + token + `'; exit 0; fi +exit 1`) +} + +// authorizations returns the Authorization header of every request the +// fake GitHub server received, failing the test when it received none. +func authorizations(t *testing.T, env *clitest.Env) []string { + t.Helper() + requests := env.GitHub.Requests() + require.NotEmpty(t, requests) + var headers []string + for _, r := range requests { + headers = append(headers, r.Header.Get("Authorization")) + } + return headers +} + +func TestTheTokenComesFromTheFirstSourceThatHasOne(t *testing.T) { + for i, winner := range tokenSources { + t.Run(winner.name, func(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0"}) + for _, source := range tokenSources[i:] { + source.set(env) + } + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + for _, header := range authorizations(t, env) { + assert.Equal(t, "Bearer "+winner.token, header) + } + }) + } +} + +func TestWithoutATokenRequestsAreUnauthenticated(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0"}) + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + assert.Empty(t, res.Stderr) + for _, header := range authorizations(t, env) { + assert.Empty(t, header) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index e118e00..d116b5c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -27,12 +27,15 @@ const ( ConfigFileName = "config.yaml" ) -// GithubConfig holds GitHub-related configuration. An empty token is left -// out of the YAML, so the default config file holds no token key. +// GithubConfig holds GitHub-related configuration. type GithubConfig struct { Org string `yaml:"org"` Repository string `yaml:"repository"` - Token string `yaml:"token,omitempty"` + // Token is github.token from the config file, the last and deprecated + // source of the GitHub token. The token package resolves the token + // from every source. An empty token is left out of the YAML, so the + // default config file holds no token key. + Token string `yaml:"token,omitempty"` } // LocalConfig holds local directory configuration @@ -51,6 +54,15 @@ type Config struct { Github GithubConfig `yaml:"github"` Local LocalConfig `yaml:"local"` Development DevelopmentConfig `yaml:"development"` + + file string +} + +// File returns the path of the config file Load read the configuration +// from, whether or not the file exists. It is empty when Load could not +// determine the home directory. +func (c *Config) File() string { + return c.file } // Paths are the places educatesenv keeps its files in. @@ -102,7 +114,8 @@ func defaults(paths Paths) *Config { // Load returns the configuration: the defaults, overridden by the config // file in the user's educatesenv directory, overridden in turn by the // EDUCATES_* environment variables. An environment variable set to the -// empty string counts as not set. +// empty string counts as not set. The GitHub token is not among them: the +// token package reads EDUCATES_GITHUB_TOKEN. // // Without a home directory, Load writes a warning to out, reads no config // file, and puts the bin directory under the current directory. @@ -118,6 +131,7 @@ func Load(out io.Writer) (*Config, error) { } cfg := defaults(paths) + cfg.file = paths.File if err := cfg.applyFile(paths.File); err != nil { return nil, err } @@ -132,7 +146,8 @@ type setting struct { // key is the value's key in the config file, as section.key. Keys // match regardless of case. key string - // env is the environment variable that overrides the config file. + // env is the environment variable that overrides the config file, or + // "" for a value only the config file sets. env string // Exactly one of str and boolean points at the field the value sets. str *string @@ -143,7 +158,7 @@ func (c *Config) settings() []setting { return []setting{ {key: "github.org", env: "EDUCATES_GITHUB_ORG", str: &c.Github.Org}, {key: "github.repository", env: "EDUCATES_GITHUB_REPOSITORY", str: &c.Github.Repository}, - {key: "github.token", env: "EDUCATES_GITHUB_TOKEN", str: &c.Github.Token}, + {key: "github.token", str: &c.Github.Token}, {key: "local.dir", env: "EDUCATES_LOCAL_DIR", str: &c.Local.Dir}, {key: "development.enabled", env: "EDUCATES_DEVELOPMENT_ENABLED", boolean: &c.Development.Enabled}, {key: "development.binaryLocation", env: "EDUCATES_DEVELOPMENT_BINARY_LOCATION", str: &c.Development.BinaryLocation}, @@ -154,6 +169,9 @@ func (c *Config) settings() []setting { // empty. func (c *Config) applyEnv() error { for _, s := range c.settings() { + if s.env == "" { + continue + } value := os.Getenv(s.env) if value == "" { continue diff --git a/pkg/github/client.go b/pkg/github/client.go index 268a6da..d85794d 100644 --- a/pkg/github/client.go +++ b/pkg/github/client.go @@ -6,6 +6,7 @@ import ( "github.com/educates/educatesenv/pkg/config" "github.com/educates/educatesenv/pkg/releases" + "github.com/educates/educatesenv/pkg/token" "github.com/google/go-github/v93/github" ) @@ -13,11 +14,13 @@ import ( type Client struct { client *github.Client config *config.Config + token token.Token } // options holds the settings that Option values change. type options struct { baseURL string + token token.Token } // Option changes how New builds a Client. @@ -30,7 +33,15 @@ func WithBaseURL(baseURL string) Option { } } -// New creates a new GitHub client, authenticated when the config holds a token +// WithToken authenticates the client's requests with tok. Without a set +// token, the client calls the API unauthenticated. +func WithToken(tok token.Token) Option { + return func(o *options) { + o.token = tok + } +} + +// New creates a new GitHub client for the repository cfg names func New(cfg *config.Config, opts ...Option) (*Client, error) { var o options for _, opt := range opts { @@ -38,8 +49,8 @@ func New(cfg *config.Config, opts ...Option) (*Client, error) { } var clientOpts []github.ClientOptionsFunc - if cfg.Github.Token != "" { - clientOpts = append(clientOpts, github.WithAuthToken(cfg.Github.Token)) + if o.token.IsSet() { + clientOpts = append(clientOpts, github.WithAuthToken(o.token.Value())) } if o.baseURL != "" { clientOpts = append(clientOpts, github.WithURLs(&o.baseURL, nil)) @@ -53,6 +64,7 @@ func New(cfg *config.Config, opts ...Option) (*Client, error) { return &Client{ client: client, config: cfg, + token: o.token, }, nil } diff --git a/pkg/github/client_test.go b/pkg/github/client_test.go deleted file mode 100644 index b258813..0000000 --- a/pkg/github/client_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package github_test - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/educates/educatesenv/pkg/config" - "github.com/educates/educatesenv/pkg/github" -) - -func TestRequestsCarryTheConfiguredToken(t *testing.T) { - tests := []struct { - name string - token string - authorization string - }{ - {name: "with a token", token: "testtoken", authorization: "Bearer testtoken"}, - {name: "without a token", token: "", authorization: ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var received []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - received = append(received, r.Header.Get("Authorization")) - if r.URL.Path != "/repos/testorg/testrepo/releases" { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[]`)) - })) - defer server.Close() - - cfg := &config.Config{ - Github: config.GithubConfig{Org: "testorg", Repository: "testrepo", Token: tt.token}, - } - client, err := github.New(cfg, github.WithBaseURL(server.URL)) - require.NoError(t, err) - - _, err = client.ListReleases() - require.NoError(t, err) - - assert.Equal(t, []string{tt.authorization}, received) - }) - } -} diff --git a/pkg/token/token.go b/pkg/token/token.go new file mode 100644 index 0000000..3b23164 --- /dev/null +++ b/pkg/token/token.go @@ -0,0 +1,117 @@ +// Package token finds the GitHub token educatesenv authenticates with. It +// reads the token from the environment, from the gh CLI or from the config +// file, and never stores one. +package token + +import ( + "io" + "os" + "os/exec" + "strings" +) + +// source is where a token came from. +type source int + +const ( + notSet source = iota + educatesGitHubToken + ghToken + githubToken + ghAuthToken + configFile +) + +// labels name each source in config view and in error messages. +var labels = map[source]string{ + notSet: "not set", + educatesGitHubToken: "EDUCATES_GITHUB_TOKEN", + ghToken: "GH_TOKEN", + githubToken: "GITHUB_TOKEN", + ghAuthToken: "gh auth token", + configFile: "config file (deprecated)", +} + +// environment holds the environment variables that hold a token, in the +// order they are checked. +var environment = []struct { + variable string + source source +}{ + {"EDUCATES_GITHUB_TOKEN", educatesGitHubToken}, + {"GH_TOKEN", ghToken}, + {"GITHUB_TOKEN", githubToken}, +} + +// Token is a GitHub token and the source it came from. Formatting a Token +// with %s or %v shows its source, never any character of the token. +type Token struct { + value string + source source +} + +// Value returns the token, or "" when no source has one. +func (t Token) Value() string { + return t.value +} + +// IsSet reports whether a source had a token. +func (t Token) IsSet() bool { + return t.value != "" +} + +// Source returns the label of the token's source: EDUCATES_GITHUB_TOKEN, +// GH_TOKEN, GITHUB_TOKEN, gh auth token, config file (deprecated), or not +// set. +func (t Token) Source() string { + return labels[t.source] +} + +// String returns how config view shows the token: and +// the like, or . +func (t Token) String() string { + switch t.source { + case notSet: + return "" + case configFile: + return "" + default: + return "" + } +} + +// Resolve returns the token from the first of these sources that has one: +// +// 1. EDUCATES_GITHUB_TOKEN +// 2. GH_TOKEN +// 3. GITHUB_TOKEN +// 4. the output of `gh auth token --hostname github.com`, when gh is on +// the PATH +// 5. fileToken, the github.token value of the config file at file +// +// A source whose token is empty has none. +func Resolve(file, fileToken string, warnings io.Writer) Token { + for _, e := range environment { + if value := os.Getenv(e.variable); value != "" { + return Token{value: value, source: e.source} + } + } + if value := fromGH(); value != "" { + return Token{value: value, source: ghAuthToken} + } + if fileToken != "" { + return Token{value: fileToken, source: configFile} + } + return Token{} +} + +// fromGH returns the token gh holds for github.com, or "" when gh is not on +// the PATH, fails, or prints nothing. gh's own output on stderr is +// discarded. +func fromGH() string { + out, err := exec.Command("gh", "auth", "token", "--hostname", "github.com").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} From 948f52caf9a0b47197a60b6c0a53175fad18b363 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:19:24 +0100 Subject: [PATCH 48/75] test(token): cover a gh that fails, prints nothing or is missing --- pkg/cmd/token_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pkg/cmd/token_test.go b/pkg/cmd/token_test.go index 95d0d7f..2ee43a8 100644 --- a/pkg/cmd/token_test.go +++ b/pkg/cmd/token_test.go @@ -85,6 +85,34 @@ func TestTheTokenComesFromTheFirstSourceThatHasOne(t *testing.T) { } } +func TestAGHThatFailsPrintsNothingOrIsMissingGivesNoTokenSilently(t *testing.T) { + scripts := map[string]string{ + "exits non-zero": "printf 'gho_partial\\n'\nprintf 'gh: not logged in\\n' >&2\nexit 1", + "prints nothing": "exit 0", + "prints only whitespace": "printf ' \\n'", + "is not on the PATH": "", + } + + for name, script := range scripts { + t.Run(name, func(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0"}) + if script != "" { + env.FakeGH(script) + } + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + assert.Empty(t, res.Stderr) + assert.NotContains(t, res.Stdout, "gho_partial") + for _, header := range authorizations(t, env) { + assert.Empty(t, header) + } + }) + } +} + func TestWithoutATokenRequestsAreUnauthenticated(t *testing.T) { env := clitest.New(t) env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0"}) From c7bf10ca376c77b32ea4b096ebf0a2c5be2ac7ca Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:19:47 +0100 Subject: [PATCH 49/75] feat(token): warn that a token from the config file is deprecated The warning goes to stderr, names the config file and points at GH_TOKEN or gh auth login. A token in the file that a higher source overrides is not used and does not warn. --- pkg/cmd/token_test.go | 28 ++++++++++++++++++++++++++++ pkg/token/token.go | 6 +++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/token_test.go b/pkg/cmd/token_test.go index 2ee43a8..960b440 100644 --- a/pkg/cmd/token_test.go +++ b/pkg/cmd/token_test.go @@ -113,6 +113,34 @@ func TestAGHThatFailsPrintsNothingOrIsMissingGivesNoTokenSilently(t *testing.T) } } +func TestATokenFromTheConfigFileWarnsThatItIsDeprecated(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0"}) + configPath := env.WriteConfig("github:\n token: " + fileToken + "\n") + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + assert.Equal(t, "Warning: github.token in "+configPath+" is deprecated, and a later release will stop reading it. "+ + "Set GH_TOKEN or run 'gh auth login' instead, and remove the token from that file.\n", res.Stderr) + assert.NotContains(t, res.Stdout, fileToken) + for _, header := range authorizations(t, env) { + assert.Equal(t, "Bearer "+fileToken, header) + } +} + +func TestATokenInTheConfigFileThatAnotherSourceOverridesDoesNotWarn(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0"}) + env.WriteConfig("github:\n token: " + fileToken + "\n") + env.Setenv("GH_TOKEN", ghEnvToken) + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + assert.Empty(t, res.Stderr) +} + func TestWithoutATokenRequestsAreUnauthenticated(t *testing.T) { env := clitest.New(t) env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0"}) diff --git a/pkg/token/token.go b/pkg/token/token.go index 3b23164..6fe65a8 100644 --- a/pkg/token/token.go +++ b/pkg/token/token.go @@ -4,6 +4,7 @@ package token import ( + "fmt" "io" "os" "os/exec" @@ -89,7 +90,9 @@ func (t Token) String() string { // the PATH // 5. fileToken, the github.token value of the config file at file // -// A source whose token is empty has none. +// A source whose token is empty has none. A token from the config file is +// deprecated, so Resolve writes a warning that names the file to warnings +// when it returns one. func Resolve(file, fileToken string, warnings io.Writer) Token { for _, e := range environment { if value := os.Getenv(e.variable); value != "" { @@ -100,6 +103,7 @@ func Resolve(file, fileToken string, warnings io.Writer) Token { return Token{value: value, source: ghAuthToken} } if fileToken != "" { + _, _ = fmt.Fprintf(warnings, "Warning: github.token in %s is deprecated, and a later release will stop reading it. Set GH_TOKEN or run 'gh auth login' instead, and remove the token from that file.\n", file) return Token{value: fileToken, source: configFile} } return Token{} From fc30fc6cad1d1bd45df046df44b329b3b30db359 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:20:54 +0100 Subject: [PATCH 50/75] feat(cmd): create the GitHub client only in commands that call GitHub The root no longer creates the client for every command. list-remote, install and init --download create it on first use, and only then is the token resolved, so list, use, version and the other commands never run gh. --- pkg/cmd/list_remote.go | 6 +++- pkg/cmd/root.go | 36 ++++++++++++++------ pkg/cmd/token_test.go | 76 ++++++++++++++++++++++++++++++++++++++++++ pkg/version/latest.go | 6 +++- pkg/version/manager.go | 13 +++++--- 5 files changed, 120 insertions(+), 17 deletions(-) diff --git a/pkg/cmd/list_remote.go b/pkg/cmd/list_remote.go index fc31f5a..a4f33ee 100644 --- a/pkg/cmd/list_remote.go +++ b/pkg/cmd/list_remote.go @@ -36,7 +36,11 @@ highest version first.`, return fmt.Errorf("--recent must be at least 1, got %d", recent) } - published, err := d.gh.ListReleases() + gh, err := d.githubClient() + if err != nil { + return err + } + published, err := gh.ListReleases() if err != nil { return err } diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 6077dfa..d46f59c 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -32,13 +32,17 @@ func WithGitHubBaseURL(baseURL string) Option { type deps struct { opts options cfg *config.Config - gh *github.Client manager *version.Manager + errOut io.Writer + + // gh is created on first use, by githubClient. + gh *github.Client } -// load reads the configuration and creates the GitHub client and the -// version manager. Config warnings and the manager's progress go to out, -// and the manager's warnings to errOut. +// load reads the configuration and creates the version manager. Config +// warnings and the manager's progress go to out, and the manager's +// warnings and token warnings to errOut. It neither resolves the GitHub +// token nor creates the GitHub client. func (d *deps) load(out, errOut io.Writer) error { var err error d.cfg, err = config.Load(out) @@ -46,16 +50,26 @@ func (d *deps) load(out, errOut io.Writer) error { return err } - tok := token.Resolve(d.cfg.File(), d.cfg.Github.Token, errOut) - d.gh, err = github.New(d.cfg, github.WithBaseURL(d.opts.githubBaseURL), github.WithToken(tok)) - if err != nil { - return err - } - - d.manager = version.New(d.cfg, d.gh, out, errOut) + d.errOut = errOut + d.manager = version.New(d.cfg, d.githubClient, out, errOut) return nil } +// githubClient returns the GitHub client, resolving the token and creating +// the client on first use. Only the commands that call GitHub use it, so +// the others never look the token up. +func (d *deps) githubClient() (*github.Client, error) { + if d.gh == nil { + tok := token.Resolve(d.cfg.File(), d.cfg.Github.Token, d.errOut) + gh, err := github.New(d.cfg, github.WithBaseURL(d.opts.githubBaseURL), github.WithToken(tok)) + if err != nil { + return nil, err + } + d.gh = gh + } + return d.gh, nil +} + // cleanUpDevelopmentSymlink removes the educates symlink when it points // at a development binary while development mode is disabled, and warns // about it. Only the commands that deal with the active binary run it, diff --git a/pkg/cmd/token_test.go b/pkg/cmd/token_test.go index 960b440..332892e 100644 --- a/pkg/cmd/token_test.go +++ b/pkg/cmd/token_test.go @@ -1,6 +1,11 @@ package cmd_test import ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -141,6 +146,77 @@ func TestATokenInTheConfigFileThatAnotherSourceOverridesDoesNotWarn(t *testing.T assert.Empty(t, res.Stderr) } +// recordingFakeGH puts a gh on the PATH that records the arguments of each +// call, and prints ghCLIToken for `gh auth token --hostname github.com`. +// It returns a function that returns the recorded calls. +func recordingFakeGH(t *testing.T, env *clitest.Env) func() []string { + t.Helper() + calls := filepath.Join(env.Home, "gh-calls") + env.FakeGH(`printf '%s\n' "$*" >> '` + calls + `' +if [ "$*" = "auth token --hostname github.com" ]; then printf '%s\n' '` + ghCLIToken + `'; exit 0; fi +exit 1`) + return func() []string { + content, err := os.ReadFile(calls) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + require.NoError(t, err) + return strings.Split(strings.TrimSuffix(string(content), "\n"), "\n") + } +} + +func TestCommandsThatDoNotCallGitHubNeverRunGH(t *testing.T) { + commands := [][]string{ + {"list"}, + {"use", "3.8.0"}, + {"version"}, + {"uninstall", "3.7.0"}, + {"init"}, + {"config", "init"}, + } + + for _, command := range commands { + t.Run(strings.Join(command, " "), func(t *testing.T) { + env := clitest.New(t) + env.WriteBinary("3.8.0") + env.WriteBinary("3.7.0") + ghCalls := recordingFakeGH(t, env) + + res := env.Run(command...) + + require.NoError(t, res.Err) + assert.Empty(t, ghCalls()) + }) + } +} + +func TestCommandsThatCallGitHubRunGHOnce(t *testing.T) { + commands := [][]string{ + {"list-remote"}, + {"install", "3.8.0"}, + {"install", "latest"}, + {"init", "--download"}, + } + + for _, command := range commands { + t.Run(strings.Join(command, " "), func(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + ghCalls := recordingFakeGH(t, env) + + res := env.Run(command...) + + require.NoError(t, res.Err) + assert.Equal(t, []string{"auth token --hostname github.com"}, ghCalls()) + for _, r := range env.GitHub.Requests() { + if strings.HasPrefix(r.Path, "/repos/") { + assert.Equal(t, "Bearer "+ghCLIToken, r.Header.Get("Authorization"), r.Path) + } + } + }) + } +} + func TestWithoutATokenRequestsAreUnauthenticated(t *testing.T) { env := clitest.New(t) env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0"}) diff --git a/pkg/version/latest.go b/pkg/version/latest.go index e9fc1b3..ec990fa 100644 --- a/pkg/version/latest.go +++ b/pkg/version/latest.go @@ -13,7 +13,11 @@ const Latest = "latest" // ResolveLatest returns the tag of the highest stable release by semver, // among every release of the repository. func (m *Manager) ResolveLatest() (string, error) { - published, err := m.github.ListReleases() + gh, err := m.github() + if err != nil { + return "", err + } + published, err := gh.ListReleases() if err != nil { return "", err } diff --git a/pkg/version/manager.go b/pkg/version/manager.go index f5ee06c..c925120 100644 --- a/pkg/version/manager.go +++ b/pkg/version/manager.go @@ -33,15 +33,16 @@ const ( // Manager handles version-related operations type Manager struct { config *config.Config - github *github.Client + github func() (*github.Client, error) http *http.Client out io.Writer errOut io.Writer } // New creates a new version manager that reports progress to out and -// warnings to errOut -func New(cfg *config.Config, gh *github.Client, out, errOut io.Writer) *Manager { +// warnings to errOut. gh returns the GitHub client, and the manager calls +// it only when an operation needs GitHub. +func New(cfg *config.Config, gh func() (*github.Client, error), out, errOut io.Writer) *Manager { return &Manager{ config: cfg, github: gh, @@ -186,7 +187,11 @@ func (m *Manager) InstallVersion(version string, force bool, activate bool) erro return fmt.Errorf("failed to determine platform binary name: %w", err) } - assets, err := m.github.GetReleaseAssets(version, assetName) + gh, err := m.github() + if err != nil { + return err + } + assets, err := gh.GetReleaseAssets(version, assetName) if err != nil { return err // Pass through the user-friendly error from GitHub client } From 15e31210600fba655acbe3f2c82bd1221ece5580 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:21:33 +0100 Subject: [PATCH 51/75] fix(config): show where the token comes from in config view, never the token config view printed the token in full, whether it came from the config file or from EDUCATES_GITHUB_TOKEN. It now shows the token's source, such as , or . --- pkg/cmd/config.go | 6 ++++-- pkg/cmd/root.go | 24 +++++++++++++++++------- pkg/cmd/token_test.go | 42 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/config.go b/pkg/cmd/config.go index 334f5e2..d14e240 100644 --- a/pkg/cmd/config.go +++ b/pkg/cmd/config.go @@ -51,8 +51,10 @@ func newConfigViewCommand(d *deps) *cobra.Command { SilenceErrors: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - // Convert config struct to YAML - yamlBytes, err := yaml.Marshal(d.cfg) + // The token is shown as its source, never as the token. + view := *d.cfg + view.Github.Token = d.githubToken().String() + yamlBytes, err := yaml.Marshal(view) if err != nil { return fmt.Errorf("failed to marshal config to YAML: %w", err) } diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index d46f59c..1874c6a 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -35,8 +35,10 @@ type deps struct { manager *version.Manager errOut io.Writer - // gh is created on first use, by githubClient. - gh *github.Client + // token is resolved on first use, by githubToken, and gh is created on + // first use, by githubClient. + token *token.Token + gh *github.Client } // load reads the configuration and creates the version manager. Config @@ -55,13 +57,21 @@ func (d *deps) load(out, errOut io.Writer) error { return nil } -// githubClient returns the GitHub client, resolving the token and creating -// the client on first use. Only the commands that call GitHub use it, so -// the others never look the token up. +// githubToken returns the GitHub token, resolving it on first use. Only +// config view and the commands that call GitHub use it, so the others +// never look the token up. +func (d *deps) githubToken() token.Token { + if d.token == nil { + tok := token.Resolve(d.cfg.File(), d.cfg.Github.Token, d.errOut) + d.token = &tok + } + return *d.token +} + +// githubClient returns the GitHub client, creating it on first use. func (d *deps) githubClient() (*github.Client, error) { if d.gh == nil { - tok := token.Resolve(d.cfg.File(), d.cfg.Github.Token, d.errOut) - gh, err := github.New(d.cfg, github.WithBaseURL(d.opts.githubBaseURL), github.WithToken(tok)) + gh, err := github.New(d.cfg, github.WithBaseURL(d.opts.githubBaseURL), github.WithToken(d.githubToken())) if err != nil { return nil, err } diff --git a/pkg/cmd/token_test.go b/pkg/cmd/token_test.go index 332892e..f0adc0a 100644 --- a/pkg/cmd/token_test.go +++ b/pkg/cmd/token_test.go @@ -28,25 +28,27 @@ const ( type tokenSource struct { name string token string + // label is how config view shows a token from this source. + label string set func(env *clitest.Env) } // tokenSources are the sources of the GitHub token, in the order // educatesenv checks them. var tokenSources = []tokenSource{ - {name: "EDUCATES_GITHUB_TOKEN", token: educatesEnvToken, set: func(env *clitest.Env) { + {name: "EDUCATES_GITHUB_TOKEN", token: educatesEnvToken, label: "", set: func(env *clitest.Env) { env.Setenv("EDUCATES_GITHUB_TOKEN", educatesEnvToken) }}, - {name: "GH_TOKEN", token: ghEnvToken, set: func(env *clitest.Env) { + {name: "GH_TOKEN", token: ghEnvToken, label: "", set: func(env *clitest.Env) { env.Setenv("GH_TOKEN", ghEnvToken) }}, - {name: "GITHUB_TOKEN", token: githubEnvToken, set: func(env *clitest.Env) { + {name: "GITHUB_TOKEN", token: githubEnvToken, label: "", set: func(env *clitest.Env) { env.Setenv("GITHUB_TOKEN", githubEnvToken) }}, - {name: "gh auth token", token: ghCLIToken, set: func(env *clitest.Env) { + {name: "gh auth token", token: ghCLIToken, label: "", set: func(env *clitest.Env) { fakeGHWithToken(env, ghCLIToken) }}, - {name: "config file", token: fileToken, set: func(env *clitest.Env) { + {name: "config file", token: fileToken, label: "", set: func(env *clitest.Env) { env.WriteConfig("github:\n token: " + fileToken + "\n") }}, } @@ -217,6 +219,36 @@ func TestCommandsThatCallGitHubRunGHOnce(t *testing.T) { } } +func TestConfigViewShowsWhereTheTokenComesFromAndNoneOfIt(t *testing.T) { + for i, winner := range tokenSources { + t.Run(winner.name, func(t *testing.T) { + env := clitest.New(t) + for _, source := range tokenSources[i:] { + source.set(env) + } + + res := env.Run("config", "view") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "github:\n org: educates\n repository: educates-training-platform\n token: "+winner.label+"\n") + for _, source := range tokenSources { + assert.NotContains(t, res.Stdout, source.token) + assert.NotContains(t, res.Stderr, source.token) + } + }) + } + + t.Run("not set", func(t *testing.T) { + env := clitest.New(t) + + res := env.Run("config", "view") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, " repository: educates-training-platform\n token: \n") + assert.Empty(t, res.Stderr) + }) +} + func TestWithoutATokenRequestsAreUnauthenticated(t *testing.T) { env := clitest.New(t) env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0"}) From bf556035da0f1870894b42a784780d341715f420 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:23:31 +0100 Subject: [PATCH 52/75] feat(github): say when a rate limit resets and which token GitHub rejected A rate limit gives the time it resets, and without a token points at GH_TOKEN or gh auth login. A 401 names the source of the rejected token. The 404 messages for a missing repository or version are kept. --- pkg/cmd/github_errors_test.go | 106 ++++++++++++++++++++++++++++++++++ pkg/cmd/token_test.go | 3 +- pkg/github/client.go | 50 +++++++++++++--- 3 files changed, 149 insertions(+), 10 deletions(-) create mode 100644 pkg/cmd/github_errors_test.go diff --git a/pkg/cmd/github_errors_test.go b/pkg/cmd/github_errors_test.go new file mode 100644 index 0000000..7789bec --- /dev/null +++ b/pkg/cmd/github_errors_test.go @@ -0,0 +1,106 @@ +package cmd_test + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +// The API routes the commands call first. +const ( + releasesRoute = "GET /repos/educates/educates-training-platform/releases" + releaseByTagRoute = "GET /repos/educates/educates-training-platform/releases/tags/3.8.0" +) + +// githubCall is a command and the API route it calls first. +type githubCall struct { + args []string + route string +} + +// githubCalls are the commands that call GitHub, each with the route a +// failing response is served on. +var githubCalls = []githubCall{ + {args: []string{"list-remote"}, route: releasesRoute}, + {args: []string{"install", "3.8.0"}, route: releaseByTagRoute}, + {args: []string{"install", "latest"}, route: releasesRoute}, + {args: []string{"init", "--download"}, route: releasesRoute}, +} + +// rateLimitReset is when the rate limit the fake server reports resets. +var rateLimitReset = time.Date(2030, time.January, 2, 15, 4, 5, 0, time.UTC) + +// rateLimited answers as GitHub does once the rate limit is used up. Only +// this response carries rate limit headers, since a client that saw no +// remaining requests refuses to make more. +func rateLimited(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-RateLimit-Limit", "60") + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(rateLimitReset.Unix(), 10)) + w.WriteHeader(http.StatusForbidden) + _, _ = fmt.Fprint(w, `{"message":"API rate limit exceeded for 127.0.0.1."}`) +} + +// badCredentials answers as GitHub does when it rejects the token. +func badCredentials(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprint(w, `{"message":"Bad credentials"}`) +} + +func TestARateLimitSaysWhenItResetsAndHowToAuthenticate(t *testing.T) { + for _, call := range githubCalls { + t.Run(strings.Join(call.args, " "), func(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + env.GitHub.Handle(call.route, rateLimited) + + res := env.Run(call.args...) + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "GitHub API rate limit exceeded; it resets at 2030-01-02 15:04:05 UTC. "+ + "Set GH_TOKEN or run 'gh auth login' to authenticate and get a higher limit") + }) + } +} + +func TestARateLimitOnATokenNamesTheTokensSource(t *testing.T) { + env := clitest.New(t) + env.Setenv("GH_TOKEN", ghEnvToken) + env.GitHub.Handle(releasesRoute, rateLimited) + + res := env.Run("list-remote") + + require.EqualError(t, res.Err, "GitHub API rate limit exceeded for the token from GH_TOKEN; it resets at 2030-01-02 15:04:05 UTC") +} + +func TestARejectedTokenNamesItsSource(t *testing.T) { + for _, source := range tokenSources { + for _, call := range githubCalls { + t.Run(source.name+"/"+strings.Join(call.args, " "), func(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + source.set(env) + env.GitHub.Handle(call.route, badCredentials) + + res := env.Run(call.args...) + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "GitHub rejected the token from "+source.name+": 401 Bad credentials. "+ + "Replace or remove that token and try again") + assert.NotContains(t, res.Err.Error(), source.token) + assert.NotContains(t, res.Stdout, source.token) + assert.NotContains(t, res.Stderr, source.token) + }) + } + } +} diff --git a/pkg/cmd/token_test.go b/pkg/cmd/token_test.go index f0adc0a..2b93ecc 100644 --- a/pkg/cmd/token_test.go +++ b/pkg/cmd/token_test.go @@ -26,6 +26,7 @@ const ( // tokenSource sets up one source of the GitHub token in env. type tokenSource struct { + // name is how error messages name the source. name string token string // label is how config view shows a token from this source. @@ -48,7 +49,7 @@ var tokenSources = []tokenSource{ {name: "gh auth token", token: ghCLIToken, label: "", set: func(env *clitest.Env) { fakeGHWithToken(env, ghCLIToken) }}, - {name: "config file", token: fileToken, label: "", set: func(env *clitest.Env) { + {name: "config file (deprecated)", token: fileToken, label: "", set: func(env *clitest.Env) { env.WriteConfig("github:\n token: " + fileToken + "\n") }}, } diff --git a/pkg/github/client.go b/pkg/github/client.go index e39d1d7..8b8624e 100644 --- a/pkg/github/client.go +++ b/pkg/github/client.go @@ -2,7 +2,10 @@ package github import ( "context" + "errors" "fmt" + "net/http" + "time" "github.com/educates/educatesenv/pkg/config" "github.com/educates/educatesenv/pkg/releases" @@ -86,12 +89,10 @@ type ReleaseAssets struct { // returns the download URLs of its binaryName asset and of its // checksums.txt. func (c *Client) GetReleaseAssets(version, binaryName string) (ReleaseAssets, error) { - release, resp, err := c.client.Repositories.GetReleaseByTag(context.Background(), c.config.Github.Org, c.config.Github.Repository, version) + release, _, err := c.client.Repositories.GetReleaseByTag(context.Background(), c.config.Github.Org, c.config.Github.Repository, version) if err != nil { - if resp != nil && resp.StatusCode == 404 { - return ReleaseAssets{}, fmt.Errorf("version %s not found. Run 'educatesenv list-remote' to see available versions", version) - } - return ReleaseAssets{}, fmt.Errorf("failed to fetch release info: %w", err) + notFound := fmt.Errorf("version %s not found. Run 'educatesenv list-remote' to see available versions", version) + return ReleaseAssets{}, c.apiError(err, notFound, "failed to fetch release info") } var assets ReleaseAssets @@ -118,10 +119,8 @@ func (c *Client) ListReleases() ([]releases.Release, error) { for { page, resp, err := c.client.Repositories.ListReleases(context.Background(), c.config.Github.Org, c.config.Github.Repository, opts) if err != nil { - if resp != nil && resp.StatusCode == 404 { - return nil, fmt.Errorf("repository %s/%s not found", c.config.Github.Org, c.config.Github.Repository) - } - return nil, fmt.Errorf("failed to fetch releases: %w", err) + notFound := fmt.Errorf("repository %s/%s not found", c.config.Github.Org, c.config.Github.Repository) + return nil, c.apiError(err, notFound, "failed to fetch releases") } for _, rel := range page { @@ -139,3 +138,36 @@ func (c *Client) ListReleases() ([]releases.Release, error) { opts.Page = resp.NextPage } } + +// apiError returns the error the user sees for err, the error of a GitHub +// API call. A rate limit says when it resets, and a token GitHub rejects +// with a 401 is named by its source. A 404 gives notFound. Any other error +// is wrapped, with failure saying what failed. +func (c *Client) apiError(err, notFound error, failure string) error { + if rateLimit, ok := errors.AsType[*github.RateLimitError](err); ok { + return c.rateLimitError(rateLimit.Rate.Reset.Time) + } + if response, ok := errors.AsType[*github.ErrorResponse](err); ok && response.Response != nil { + switch status := response.Response.StatusCode; { + case status == http.StatusUnauthorized && c.token.IsSet(): + message := response.Message + if message == "" { + message = http.StatusText(status) + } + return fmt.Errorf("GitHub rejected the token from %s: %d %s. Replace or remove that token and try again", c.token.Source(), status, message) + case status == http.StatusNotFound: + return notFound + } + } + return fmt.Errorf("%s: %w", failure, err) +} + +// rateLimitError returns the error for a rate limit that resets at reset. +// Without a token, it says how to authenticate, which raises the limit. +func (c *Client) rateLimitError(reset time.Time) error { + resets := reset.UTC().Format(time.DateTime) + " UTC" + if c.token.IsSet() { + return fmt.Errorf("GitHub API rate limit exceeded for the token from %s; it resets at %s", c.token.Source(), resets) + } + return fmt.Errorf("GitHub API rate limit exceeded; it resets at %s. Set GH_TOKEN or run 'gh auth login' to authenticate and get a higher limit", resets) +} From c566c99f5278f6c3611975f5a7d1c2b1ebcd21da Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:23:31 +0100 Subject: [PATCH 53/75] refactor(token): keep the config-file warning in a constant --- pkg/token/token.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/token/token.go b/pkg/token/token.go index 6fe65a8..079ec11 100644 --- a/pkg/token/token.go +++ b/pkg/token/token.go @@ -44,6 +44,11 @@ var environment = []struct { {"GITHUB_TOKEN", githubToken}, } +// configFileWarning is the warning for a token from the config file. Its +// verb is the file's path. +const configFileWarning = "Warning: github.token in %s is deprecated, and a later release will stop reading it. " + + "Set GH_TOKEN or run 'gh auth login' instead, and remove the token from that file.\n" + // Token is a GitHub token and the source it came from. Formatting a Token // with %s or %v shows its source, never any character of the token. type Token struct { @@ -103,7 +108,7 @@ func Resolve(file, fileToken string, warnings io.Writer) Token { return Token{value: value, source: ghAuthToken} } if fileToken != "" { - _, _ = fmt.Fprintf(warnings, "Warning: github.token in %s is deprecated, and a later release will stop reading it. Set GH_TOKEN or run 'gh auth login' instead, and remove the token from that file.\n", file) + _, _ = fmt.Fprintf(warnings, configFileWarning, file) return Token{value: fileToken, source: configFile} } return Token{} From ca5c40a9d92b9f2148843656ddf899855125c81a Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 22:32:44 +0100 Subject: [PATCH 54/75] docs: rewrite the README to match the commands Document only the commands and flags that exist, with one installation section and one usage section: list-remote's selection flags and output, install latest and checksum verification, uninstall, the config file and its environment overrides, and development mode. Document the token sources in the order they are checked, gh support, and the deprecated config-file token. Match the Go prerequisite and the golangci-lint version to go.mod and CI, and list the Makefile's targets. Add a release section on the signing certificate, notarization, drafts and releasing unsigned with skip-signing, and a macOS note on clearing the quarantine attribute after a browser download. --- README.md | 621 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 441 insertions(+), 180 deletions(-) diff --git a/README.md b/README.md index 6cde62f..e531177 100644 --- a/README.md +++ b/README.md @@ -1,252 +1,513 @@ # educatesenv -A version manager for the `educates` binary, inspired by [tfenv](https://github.com/tfutils/tfenv). Easily install, switch, and manage multiple versions of the educates training platform binary. +A version manager for the `educates` CLI of the +[Educates training platform](https://github.com/educates/educates-training-platform), +inspired by [tfenv](https://github.com/tfutils/tfenv). It installs +`educates` binaries from the platform's GitHub releases, keeps several +versions side by side, and switches between them. -## Features +educatesenv keeps everything under `~/.educatesenv`. Each installed +version is a binary named `educates-` in the bin directory, and +an `educates` symlink in the same directory points at the active one: -- Version management for educates binary -- Cross-platform support -- Development mode for local testing -- Automated releases with goreleaser +``` +~/.educatesenv/ +├── config.yaml +└── bin/ + ├── educates -> educates-3.8.0 + ├── educates-3.7.3 + └── educates-3.8.0 +``` + +With the bin directory on your `PATH`, `educates` runs whichever version +you selected last. + +educatesenv runs on macOS and Linux, on amd64 and arm64. ## Installation -### Using go install +### From a release -```bash -go install github.com/educates/educatesenv/cmd/educatesenv@latest +Each release publishes one binary per platform, named +`educatesenv--`, and a `checksums.txt` with their SHA-256 +checksums. Download the binary for your platform into the bin directory, +for example on macOS on Apple silicon: + +```sh +mkdir -p ~/.educatesenv/bin +cd ~/.educatesenv/bin +curl -fLO https://github.com/educates/educatesenv/releases/latest/download/educatesenv-darwin-arm64 +curl -fLO https://github.com/educates/educatesenv/releases/latest/download/checksums.txt +shasum -a 256 --ignore-missing -c checksums.txt +mv educatesenv-darwin-arm64 educatesenv +chmod +x educatesenv +rm checksums.txt ``` -### From releases +The other binaries are `educatesenv-darwin-amd64`, +`educatesenv-linux-amd64` and `educatesenv-linux-arm64`. On Linux, +`sha256sum --ignore-missing -c checksums.txt` checks the download. -Download the latest release from the [releases page](https://github.com/educates/educatesenv/releases). +Any directory on your `PATH` works. `~/.educatesenv/bin` has the +advantage that `educatesenv` and `educates` share one `PATH` entry. -## Usage +#### macOS and the quarantine attribute -The `educatesenv` command provides several subcommands: +The macOS binaries are signed and notarized, but a release can ship them +unsigned when signing is unavailable. macOS refuses to run an unsigned +binary that a browser downloaded, because the browser marks the file with +the quarantine attribute. Clear the attribute to run it: -```bash -educatesenv version # Display version information -educatesenv install # Install a specific version -educatesenv use # Switch to a specific version +```sh +xattr -d com.apple.quarantine ~/.educatesenv/bin/educatesenv ``` -## Development +Downloads made with `curl`, as above, are not quarantined and need no +such step, and neither do the `educates` binaries that educatesenv +downloads. -### Prerequisites +### With go install + +With Go 1.26 or later: + +```sh +go install github.com/educates/educatesenv/cmd/educatesenv@latest +``` -- Go 1.21 or later -- Make -- [golangci-lint](https://golangci-lint.run/) for linting -- [goreleaser](https://goreleaser.com/) for releases +This puts `educatesenv` in `$(go env GOPATH)/bin`, and +`educatesenv version` reports the module version you installed. -### Building and Testing +### Setting up -```bash -# Build the binary -make build +Run `init` once. It creates `~/.educatesenv/config.yaml`, readable only +by you, and the bin directory, and prints how to add the bin directory to +your `PATH` in your shell: -# Run tests -make test +```sh +~/.educatesenv/bin/educatesenv init +``` -# Run linter -make lint +Run it as plain `educatesenv init` when `educatesenv` is on your `PATH` +already, as it is after `go install`. -# Local install -make install +The instructions follow `$SHELL`: zsh users are told to edit `~/.zshrc`, +bash users `~/.bashrc`, and fish users get a `fish_user_paths` command. +For zsh, they read: -# Test release build -make release-test +```sh +echo 'export PATH="/Users/you/.educatesenv/bin:$PATH"' >> ~/.zshrc +source ~/.zshrc ``` -### Code Quality +`init` never replaces a config file that exists. With `--download`, it +also installs the latest stable release of `educates` and makes it +active, and `--overwrite` makes it reinstall that release when it is +already installed: + +```sh +educatesenv init --download +``` -We use golangci-lint for strict code quality enforcement. The configuration is in `.golangci.yml` and includes: +## Usage -- Advanced linters enabled -- Strict formatting rules -- Comprehensive static analysis +``` +educatesenv init [--download] [--overwrite] +educatesenv list-remote [--recent N | --all] [--pre-releases] [--by-date] +educatesenv install [--use] [--overwrite] +educatesenv use +educatesenv list +educatesenv uninstall ... [--force] +educatesenv config init +educatesenv config view +educatesenv version +educatesenv completion +``` -Run the linter: -```bash -make lint +Every command takes `--help`. Commands that take no arguments fail when +given one, so a mistyped flag never does something else silently. + +### Listing available versions + +`list-remote` shows the 10 newest stable releases, highest version first, +with the date each was published, in UTC: + +```console +$ educatesenv list-remote +VERSION RELEASED +3.8.0 2026-08-26 +3.7.3 2026-07-17 +3.7.2 2026-06-30 +3.7.1 2026-04-12 +3.7.0 2026-03-30 +3.6.1 2026-03-19 +3.6.0 2026-03-04 +3.5.1 2025-12-12 +3.5.0 2025-12-01 +3.4.0 2025-09-29 +Showing 10 of 66 stable releases. Use --all to see all, --pre-releases to include pre-releases. ``` -### GitHub Actions +Two independent choices shape the list. Which releases: stable ones by +default, or stable ones and pre-releases with `--pre-releases`. How +many: 10 by default, `--recent N` (or `-n N`) for the N newest, or +`--all` for every one. `--all` and `--recent` conflict, and `--recent` +takes 1 or more. `--by-date` orders the list by publish date instead of +by version, and combines with every other flag: -The project uses GitHub Actions for automated workflows: +```sh +# The 20 newest stable releases +educatesenv list-remote --recent 20 -#### Build and Test Pipeline (`build.yml`) -- Triggered on pull requests and pushes to main/develop branches -- Matrix builds: - - Ubuntu and macOS - - amd64 and arm64 architectures (arm64 currently only on macOS) -- Steps: - - Go 1.24 setup with dependency caching - - Unit tests execution - - Platform-specific binary builds - - Binary artifacts upload +# Every stable release +educatesenv list-remote --all -#### Linting Pipeline (`golangci-lint.yaml`) -- Triggered on pushes to main/develop branches -- Runs golangci-lint v2.1.5 -- Performs comprehensive code quality checks -- Runs on Ubuntu latest +# The 10 highest versions, pre-releases included +educatesenv list-remote --pre-releases -#### Release Pipeline (`release.yml`) -- Triggered on version tags (v*) or manual dispatch -- Uses goreleaser for automated releases -- Steps: - - Go 1.24 setup - - Creates GitHub releases - - Builds for all supported platforms - - Generates release artifacts +# Every release +educatesenv list-remote --pre-releases --all -The workflow configurations are in `.github/workflows/`: -- `build.yml` - Build and test automation -- `golangci-lint.yaml` - Code quality checks -- `release.yml` - Release automation +# The 5 most recently published releases, pre-releases included +educatesenv list-remote --pre-releases --by-date -n 5 +``` -### Releases +The table goes to stdout. The line that says how many releases are shown +out of how many goes to stderr, so piping `list-remote` into another tool +passes only the table. When the list holds every release that matches, +there is no such line. -Releases are automated using goreleaser. The configuration is in `.goreleaser.yml` and handles: +educatesenv reads every release of the repository. A release counts as a +pre-release when its tag has a semver pre-release part, such as `-rc.1` +or `-alpha.13`, or when GitHub flags it as one. Draft releases are never +listed, and neither are tags that are not full semver versions without a +`v` prefix, the form Educates tags take. -- Cross-platform builds -- Checksums and signatures -- GitHub release automation -- Version information injection +The `--recents` flag is deprecated. It still parses, prints a +deprecation warning on stderr and changes nothing, since the 10 newest +releases are the default. A later release removes it. -### Project Structure +### Installing a version +```sh +educatesenv install 3.8.0 +educatesenv install latest --use ``` -. -├── cmd/ # Command line interface -├── pkg/ # Core packages -│ ├── config/ # Configuration management -│ ├── github/ # GitHub API integration -│ ├── platform/ # Platform-specific code -│ └── version/ # Version management -├── .golangci.yml # Linter configuration -└── .goreleaser.yml # Release configuration + +`install` downloads the release's `educates` binary for your platform and +saves it in the bin directory as `educates-`. `latest` is the +highest stable release by semver, the same release `init --download` +picks, and `install` prints which version it resolved to. `--use` makes +the version active once it is installed, and `--overwrite` downloads it +again when it is already installed. + +Every download is checked against the SHA-256 checksum the release lists +in its `checksums.txt`: + +- A mismatch fails the install. +- A `checksums.txt` that does not list the binary fails the install too, + since the binary cannot be verified. +- A release without a `checksums.txt` prints a warning on stderr and + installs the binary unverified. + +The binary downloads into a temporary file in the bin directory and is +renamed into place only once it is complete and verified. A failed or +interrupted download leaves nothing behind, and a version that was +already installed stays as it was. A download times out when the server +takes more than 30 seconds to accept the connection, 10 seconds to +complete the TLS handshake or 30 seconds to start its response. Nothing +caps the transfer itself, so a slow download that keeps making progress +finishes. + +### Switching versions + +```sh +educatesenv use 3.7.3 ``` -### Contributing +`use` points the `educates` symlink at an installed version. When the +version is not installed, it says so and how to install it; any other +failure, such as a bin directory it cannot write to, is reported with its +cause. `use develop` selects the development binary, as described under +[Development mode](#development-mode). -Contributions are welcome! Please ensure you: +### Listing installed versions -1. Follow the code style enforced by golangci-lint -2. Include tests for new functionality -3. Update documentation as needed -4. Run `make lint` and `make test` before submitting +```console +$ educatesenv list +Installed versions: +* 3.8.0 (active) + 3.7.3 +``` -## License +`list` shows the installed versions newest first by semver, and marks +the active one with `*`. Installed binaries whose names are not semver +versions come last, in alphabetical order. With development mode +enabled, the `develop` entry comes first. -[Apache License 2.0](LICENSE) +### Uninstalling versions ---- +```sh +educatesenv uninstall 3.7.0 3.7.1 +``` -## Supported OSes +`uninstall` removes one or more installed versions. It checks every +version before it removes any, so a typo in one argument removes +nothing. It refuses: -- macOS (amd64, arm64) -- Linux (amd64, arm64) -- Windows (amd64) - *Support in progress* +- a version that is not installed; +- `develop`, since educatesenv does not manage the development binary; +- the active version, unless you pass `--force`. With `--force`, the + `educates` symlink is removed with the binary, and no version is active + until you run `educatesenv use` again. ---- +### Other commands -## Installation +`config init` creates the default config file, as `init` does, and fails +when one exists. `config view` prints the configuration in effect, with +the GitHub token shown as where it comes from, never as the token. -### Automatic (Recommended) - -1. **Clone the repository:** - ```sh - git clone educatesenv - cd educatesenv - make build - ``` -2. **Initialize configuration and folders:** - ```sh - # Basic initialization - ./educatesenv init - - # Initialize and download latest version - ./educatesenv init --download - - # Initialize, download latest version and force reinstall if exists - ./educatesenv init --download --overwrite - ``` -3. **Add the bin directory to your PATH:** - - On Linux/macOS: - ```sh - echo 'export PATH="$HOME/.educatesenv/bin:$PATH"' >> ~/.bashrc - source ~/.bashrc - # or for zsh: - echo 'export PATH="$HOME/.educatesenv/bin:$PATH"' >> ~/.zprofile - source ~/.zprofile - # or for fish: - set -U fish_user_paths $HOME/.educatesenv/bin $fish_user_paths - ``` - - On Windows (PowerShell): - ```powershell - [Environment]::SetEnvironmentVariable("Path", "$HOME\.educatesenv\bin;" + $Env:Path, [EnvironmentVariableTarget]::User) - ``` - -### Manual - -1. **Download or build the binary** -2. **Create the config and bin folders:** - ```sh - mkdir -p $HOME/.educatesenv/bin - cp educatesenv $HOME/.educatesenv/bin/ - ``` -3. **Add `$HOME/.educatesenv/bin` to your PATH** (see above) - ---- +`version` prints the version of educatesenv itself, and `completion` +generates a shell completion script. -## Usage +## Configuration -All commands are run via the `educatesenv` binary: +educatesenv reads its configuration from `~/.educatesenv/config.yaml` +and nowhere else: a `config.yaml` in the working directory is ignored. +The default file holds: -### Install a version -```sh -# Install a specific version -educatesenv install +```yaml +github: + org: educates + repository: educates-training-platform +local: + dir: /Users/you/.educatesenv/bin +development: + enabled: false + binaryLocation: "" +``` + +- `github.org` and `github.repository` name the repository whose releases + educatesenv lists and installs. +- `local.dir` is the bin directory, where installed versions and the + `educates` symlink live. `init` always creates `~/.educatesenv/bin` and + prints `PATH` instructions for it, so when you set `local.dir` + elsewhere, put that directory on your `PATH` instead. +- `development.enabled` and `development.binaryLocation` set up + [development mode](#development-mode). + +Keys match regardless of case, so `binarylocation` works as well as +`binaryLocation`. These environment variables override the file, and an +empty one counts as not set: + +| Variable | Overrides | +|---|---| +| `EDUCATES_GITHUB_ORG` | `github.org` | +| `EDUCATES_GITHUB_REPOSITORY` | `github.repository` | +| `EDUCATES_LOCAL_DIR` | `local.dir` | +| `EDUCATES_DEVELOPMENT_ENABLED` | `development.enabled` | +| `EDUCATES_DEVELOPMENT_BINARY_LOCATION` | `development.binaryLocation` | + +Booleans take `true` or `false`, and the other forms Go's +`strconv.ParseBool` accepts, such as `1` and `0`. A value that is not a +boolean is an error that names the variable or key. + +### GitHub token + +educatesenv works without a token, but GitHub allows unauthenticated +clients only 60 API requests an hour, so a shared network address can +run out. With a token, the limit is far higher. educatesenv takes the +token from the first of these sources that has one: + +1. `EDUCATES_GITHUB_TOKEN`, to give educatesenv a token of its own; +2. `GH_TOKEN`; +3. `GITHUB_TOKEN`; +4. the GitHub CLI: when `gh` is on your `PATH`, the output of + `gh auth token --hostname github.com`; +5. `github.token` in the config file, which is deprecated. + +If you use the GitHub CLI, `gh auth login` is all the setup educatesenv +needs. It always asks `gh` for its github.com token, so a `GH_HOST` set +for another GitHub server does not change which token it gets. A `gh` +that is not logged in, fails or prints nothing counts as no token. + +educatesenv never writes a token to disk. Only the commands that call +GitHub, `list-remote`, `install` and `init --download`, look the token +up, and `config view`, which looks it up to show where it comes from. +The other commands never run `gh`. `config view` shows the source in +place of the token, as one of ``, +``, ``, +``, `` or +``. + +When GitHub rate-limits a request, the error says when the limit resets, +and without a token, how to authenticate. When GitHub rejects a token, +the error names the source it came from, so you know which one to fix. + +#### The config-file token is deprecated + +A token in `github.token` still works, but only as the last source, and +using it prints a warning on stderr that names the file. A later release +stops reading it. To move off it, set `GH_TOKEN` or run `gh auth login`, +then delete the `token` line from `~/.educatesenv/config.yaml`. +educatesenv no longer writes a `token` key into the files it creates. + +### Development mode + +Development mode lets the `educates` symlink point at a binary you built +yourself: + +```yaml +development: + enabled: true + binaryLocation: /path/to/your/build/educates +``` -# Install and set as active version -educatesenv install --use +With it enabled, `educatesenv use develop` selects that binary, and +`list` shows it first as `develop -> `. When you disable +development mode while the symlink still points at a development binary, +the next `list`, `use`, `install` or `init` removes the symlink and warns +you to select a version with `educatesenv use `. Other commands +never touch the symlink. -# Force reinstall even if version exists -educatesenv install --force +## Development -# Install latest version -educatesenv install latest +### Prerequisites -# Install latest version and set as active -educatesenv install latest --use +- Go 1.26 or later. go.mod sets the language version to Go 1.26.0 and + the toolchain to go1.26.10, which CI and release builds use. A local Go + older than 1.26.10 downloads that toolchain, and a newer one builds + with itself. +- Make. +- [golangci-lint](https://golangci-lint.run/) v2.15.0, the version CI + runs. +- [GoReleaser](https://goreleaser.com/) v2.19.0, only to try the release + build locally. -# Force reinstall latest version -educatesenv install latest --force -``` -Downloads and installs the specified version (or latest version) of `educates` into the bin directory. Use `--use` to automatically set it as the active version after installation. Use `--force` to reinstall even if the version already exists. +### Building and testing -### List installed versions ```sh -educatesenv list +make build # build bin/educatesenv, with the version from git describe +make test # run the tests +make lint # run golangci-lint +make install # build and copy the binary to ~/.educatesenv/bin +make clean # remove bin/ +make help # list the targets ``` -Lists all installed `educates` binaries. The active version is marked with `*`. -### Use a version -```sh -educatesenv use +The tests need no network access, no real `gh` and no real home +directory. They run each command in-process against a fake GitHub +server, `internal/fakegithub`, with a temporary home directory and a +`PATH` that holds at most a fake `gh` script, through the harness in +`internal/clitest`. + +### Project structure + +``` +. +├── cmd/educatesenv/ # main: builds the command tree and runs it +├── internal/ +│ ├── clitest/ # runs commands in-process for tests +│ └── fakegithub/ # fake GitHub API and release downloads for tests +├── pkg/ +│ ├── cmd/ # the cobra commands +│ ├── config/ # config file, environment overrides and paths +│ ├── github/ # GitHub API client and error messages +│ ├── platform/ # supported platforms and binary names +│ ├── releases/ # release selection and ordering rules, no I/O +│ ├── token/ # GitHub token resolution +│ └── version/ # install, use, uninstall and build version +├── .golangci.yml # linter configuration +└── .goreleaser.yml # release configuration ``` -Switches the active `educates` binary by updating the `educates` symlink in the bin directory. -### List remote versions +### CI + +The workflows in `.github/workflows` take the Go version from go.mod: + +- `build.yml` runs the tests and builds the binary on Ubuntu and macOS, + on pushes and pull requests to `main` and `develop`. +- `golangci-lint.yaml` runs golangci-lint v2.15.0 on the same pushes and + pull requests. +- `release.yml` builds and publishes releases, as described below. + +Every action is pinned by the commit SHA of a release, with the release +tag in a comment, and Dependabot updates the pins weekly. The +golangci-lint and GoReleaser versions are action inputs, which Dependabot +does not update, so they change by hand. + +## Releasing + +Pushing a tag such as `v1.1.0` runs the release workflow: + ```sh -educatesenv list-remote [--skip-pre-releases] +git tag v1.1.0 +git push origin v1.1.0 ``` -Lists all available versions from the [educates GitHub releases](https://github.com/educates/educates-training-platform/releases). Use `--skip-pre-releases` to hide alpha, beta, and rc versions. -### Uninstall a version +GoReleaser v2.19.0 builds the binaries for macOS and Linux on amd64 and +arm64, names them `educatesenv--`, writes `checksums.txt`, +signs and notarizes the macOS binaries, and creates a draft release. + +### Signing and notarization + +The macOS binaries are signed with the "Developer ID Application: Jorge +Morales Pou (XNSN5MMC5C)" certificate, which was issued by Apple's +Developer ID Certification Authority G2 intermediate and expires on +2030-05-16. Renew it before then. Apple's retirement of the original +Developer ID intermediate on 2027-02-01 does not affect it. + +Signing a binary needs a valid certificate, and notarizing it needs an +active Apple Developer Program membership: when the membership lapses, +Apple's notary service stops accepting submissions. Binaries signed +before the certificate or the membership expired keep working; a revoked +certificate stops them from launching. + +The workflow reads the certificate and the notary key from these +repository secrets: + +| Secret | Holds | +|---|---| +| `MACOS_SIGN_P12` | the base64 of the `.p12` certificate | +| `MACOS_SIGN_PASSWORD` | the password of the `.p12` certificate | +| `MACOS_NOTARY_KEY` | the base64 of the App Store Connect `.p8` key | +| `MACOS_NOTARY_KEY_ID` | the ID of the `.p8` key | +| `MACOS_NOTARY_ISSUER_ID` | the issuer UUID of the `.p8` key | + +GoReleaser signs only when `MACOS_SIGN_P12` is set, so keep that secret +in place. With it set, a release from a tag push always signs, and the +run fails before anything is published when signing or the notarization +submission fails. + +### Publishing the draft + +The workflow submits the macOS binaries for notarization without waiting +for Apple's verdict, so a release stays a draft until Apple's +notarization email arrives. Once the email confirms that notarization +succeeded, edit the draft's notes and publish it. + +### Releasing unsigned + +When the membership or the certificate is unavailable, release without +signing from the workflow's manual trigger. In the Actions tab, run the +Release workflow with `release_tag` set to the tag and `skip-signing` +ticked, or with the GitHub CLI: + ```sh -educatesenv uninstall -``` \ No newline at end of file +gh workflow run release.yml -f release_tag=v1.1.0 -f skip-signing=true +``` + +GoReleaser then runs with `--skip=notarize`, and every platform ships, +the macOS binaries unsigned. No secret needs to change. There is no +notarization email to wait for, so publish the draft once its notes are +ready, and point macOS users who download in a browser to the quarantine +note under [Installation](#macos-and-the-quarantine-attribute). Only the +manual trigger offers `skip-signing`. + +## Contributing + +Contributions are welcome. Before you open a pull request, run +`make lint` and `make test`, and add tests for new behavior. + +## License + +[Apache License 2.0](LICENSE) From 3316577fd18e53225570063be2a27dd762f7b6c1 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 23:00:01 +0100 Subject: [PATCH 55/75] fix(release): fail a release that cannot sign the macOS binaries GoReleaser enabled signing only when MACOS_SIGN_P12 was set, and a missing secret expands to an empty value in Actions, so a tag push without the certificate skipped signing and released unsigned binaries. A release now always signs, so it fails when the certificate is empty or missing. Only --skip=notarize, which the manual trigger's skip-signing input passes, releases unsigned. Snapshot builds still sign only when MACOS_SIGN_P12 is set. --- .github/workflows/release.yml | 5 +++-- .goreleaser.yml | 8 +++++++- README.md | 12 ++++++++---- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 788cc48..821fd27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,8 +42,9 @@ jobs: uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 with: version: v2.19.0 - # Only a manual run can skip signing; releases from tag pushes always - # sign, and fail when signing fails. + # Only a manual run can skip signing. Every other release signs, and + # .goreleaser.yml makes it fail when MACOS_SIGN_P12 is empty or + # signing fails. args: release --clean${{ github.event_name == 'workflow_dispatch' && inputs.skip-signing && ' --skip=notarize' || '' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml index 7de5a08..949a5e3 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -106,10 +106,16 @@ changelog: notarize: macos: - # Whether this configuration is enabled or not. + # + # A release always signs, so it fails when MACOS_SIGN_P12 is empty or + # missing, as well as when signing fails. Only --skip=notarize, which + # the release workflow's skip-signing input passes, releases unsigned. + # A snapshot build signs only when MACOS_SIGN_P12 is set, so that + # snapshots build without the certificate. # # Default: false. # Templates: allowed. - enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}' + enabled: '{{ or (not .IsSnapshot) (isEnvSet "MACOS_SIGN_P12") }}' # Before notarizing, we need to sign the binary. # This block defines the configuration for doing so. diff --git a/README.md b/README.md index e531177..6b93944 100644 --- a/README.md +++ b/README.md @@ -473,10 +473,14 @@ repository secrets: | `MACOS_NOTARY_KEY_ID` | the ID of the `.p8` key | | `MACOS_NOTARY_ISSUER_ID` | the issuer UUID of the `.p8` key | -GoReleaser signs only when `MACOS_SIGN_P12` is set, so keep that secret -in place. With it set, a release from a tag push always signs, and the -run fails before anything is published when signing or the notarization -submission fails. +A release from a tag push always signs the macOS binaries. The run fails +before anything is published when `MACOS_SIGN_P12` is empty or missing, +when signing fails, or when the notarization submission fails. When any +of the three `MACOS_NOTARY_*` secrets is empty, GoReleaser signs the +binaries without submitting them for notarization. Only the manual +trigger's `skip-signing` input, described below, releases unsigned. A +snapshot build, such as `goreleaser release --snapshot --clean`, signs +only when `MACOS_SIGN_P12` is set, so it builds without the certificate. ### Publishing the draft From 8848d2193df4d21670c778b682c9a794989c14bd Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 23:00:47 +0100 Subject: [PATCH 56/75] fix(config): reject stray arguments config had no run function, so cobra printed its help for any arguments and exited 0. config now takes no arguments and prints its help when run alone, so 'educatesenv config stray' fails like the other commands that take no arguments. --- pkg/cmd/args_test.go | 1 + pkg/cmd/config.go | 6 ++++++ pkg/cmd/config_test.go | 12 ++++++++++++ 3 files changed, 19 insertions(+) diff --git a/pkg/cmd/args_test.go b/pkg/cmd/args_test.go index 27579fe..5373316 100644 --- a/pkg/cmd/args_test.go +++ b/pkg/cmd/args_test.go @@ -16,6 +16,7 @@ func TestCommandsWithoutArgumentsRejectStrayOnes(t *testing.T) { {"list"}, {"list-local"}, {"init"}, + {"config"}, {"config", "init"}, {"config", "view"}, {"version"}, diff --git a/pkg/cmd/config.go b/pkg/cmd/config.go index d14e240..daa7291 100644 --- a/pkg/cmd/config.go +++ b/pkg/cmd/config.go @@ -13,8 +13,14 @@ func newConfigCommand(d *deps) *cobra.Command { cmd := &cobra.Command{ Use: "config", Short: "Manage educatesenv configuration", + Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, + // cobra checks the arguments only of a command that can run, so + // config runs and prints its help. + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, } cmd.AddCommand(newConfigInitCommand(), newConfigViewCommand(d)) return cmd diff --git a/pkg/cmd/config_test.go b/pkg/cmd/config_test.go index c1c97e5..9ee9d2c 100644 --- a/pkg/cmd/config_test.go +++ b/pkg/cmd/config_test.go @@ -19,6 +19,18 @@ func configView(t *testing.T, env *clitest.Env) string { return res.Stdout } +func TestConfigWithoutASubcommandPrintsItsHelp(t *testing.T) { + env := clitest.New(t) + + res := env.Run("config") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Available Commands:\n") + assert.Contains(t, res.Stdout, " init ") + assert.Contains(t, res.Stdout, " view ") + assert.NoDirExists(t, env.ConfigDir()) +} + func TestConfigViewPrintsTheConfiguration(t *testing.T) { env := clitest.New(t) env.WriteConfig("github:\n org: acme\n") From b73062ebc9992a0a2a47da30a447adfa3a9a682d Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 23:03:36 +0100 Subject: [PATCH 57/75] fix(config): read a boolean in another form as false, with a warning educatesenv 1.0 read config values through viper's GetBool, which read a value such as 'enabled: yes' or EDUCATES_DEVELOPMENT_ENABLED=yes as false. The YAML loader made such a value an error, so every command failed, version included. A boolean is now read as GetBool read it, and one in a form it did not accept prints a warning on stderr that names the key and file, or the variable. YAML that does not parse is still an error. --- README.md | 6 ++- pkg/cmd/config_test.go | 79 +++++++++++++++++++++++----- pkg/cmd/root.go | 11 ++-- pkg/config/config.go | 117 +++++++++++++++++++++++++---------------- 4 files changed, 148 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 6b93944..c1bc7e5 100644 --- a/README.md +++ b/README.md @@ -308,8 +308,10 @@ empty one counts as not set: | `EDUCATES_DEVELOPMENT_BINARY_LOCATION` | `development.binaryLocation` | Booleans take `true` or `false`, and the other forms Go's -`strconv.ParseBool` accepts, such as `1` and `0`. A value that is not a -boolean is an error that names the variable or key. +`strconv.ParseBool` accepts, such as `1` and `0`. In the config file, a +number other than `0` is true as well. A value in any other form, such +as `yes`, reads as false, and educatesenv prints a warning on stderr that +names the key and file, or the variable. ### GitHub token diff --git a/pkg/cmd/config_test.go b/pkg/cmd/config_test.go index 9ee9d2c..f449421 100644 --- a/pkg/cmd/config_test.go +++ b/pkg/cmd/config_test.go @@ -253,25 +253,78 @@ func TestBooleansAcceptTheirUsualForms(t *testing.T) { } } -func TestAMalformedValueIsAnError(t *testing.T) { - t.Run("a boolean in the environment", func(t *testing.T) { - env := clitest.New(t) - env.Setenv("EDUCATES_DEVELOPMENT_ENABLED", "yes") +func TestBooleansInTheirUsualFormsGiveNoWarning(t *testing.T) { + env := clitest.New(t) + env.WriteConfig("development:\n enabled: True\n") + env.Setenv("EDUCATES_DEVELOPMENT_ENABLED", "1") - res := env.Run("config", "view") + res := env.Run("config", "view") - require.EqualError(t, res.Err, `EDUCATES_DEVELOPMENT_ENABLED: "yes" is not a boolean; use true or false`) - }) + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, " enabled: true\n") + assert.Empty(t, res.Stderr) +} - t.Run("a boolean in the config file", func(t *testing.T) { - env := clitest.New(t) - configPath := env.WriteConfig("development:\n enabled: yes\n") +func TestABooleanInTheEnvironmentInAnotherFormIsReadAsFalseWithAWarning(t *testing.T) { + for _, value := range []string{"yes", "on", "y", "2", " true"} { + t.Run(value, func(t *testing.T) { + env := clitest.New(t) + env.WriteConfig("development:\n enabled: true\n") + env.Setenv("EDUCATES_DEVELOPMENT_ENABLED", value) + warning := `Warning: EDUCATES_DEVELOPMENT_ENABLED is "` + value + `", which is not a boolean, so it is read as false. Set it to true or false.` + "\n" - res := env.Run("config", "view") + res := env.Run("version") - require.EqualError(t, res.Err, `development.enabled in `+configPath+`: "yes" is not a boolean; use true or false`) - }) + require.NoError(t, res.Err) + assert.Equal(t, "develop\n", res.Stdout) + assert.Equal(t, warning, res.Stderr) + res = env.Run("config", "view") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, " enabled: false\n") + assert.Equal(t, warning, res.Stderr) + }) + } +} + +func TestABooleanInTheConfigFileInAnotherFormIsReadAsFalseWithAWarning(t *testing.T) { + tests := []struct { + value string + shown string + }{ + {"yes", `is "yes", which is not a boolean`}, + {"on", `is "on", which is not a boolean`}, + {"Y", `is "Y", which is not a boolean`}, + {`"yes"`, `is "yes", which is not a boolean`}, + {`""`, `is "", which is not a boolean`}, + {"2026-10-26", `is "2026-10-26", which is not a boolean`}, + {"[true]", "is not a single value"}, + {"{on: true}", "is not a single value"}, + } + + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + env := clitest.New(t) + configPath := env.WriteConfig("development:\n enabled: " + tt.value + "\n") + warning := "Warning: development.enabled in " + configPath + " " + tt.shown + ", so it is read as false. Set it to true or false.\n" + + res := env.Run("version") + + require.NoError(t, res.Err) + assert.Equal(t, "develop\n", res.Stdout) + assert.Equal(t, warning, res.Stderr) + + res = env.Run("config", "view") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, " enabled: false\n") + assert.Equal(t, warning, res.Stderr) + }) + } +} + +func TestAMalformedValueIsAnError(t *testing.T) { t.Run("a list in the config file", func(t *testing.T) { env := clitest.New(t) configPath := env.WriteConfig("github:\n org: [acme, widgets]\n") diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 1874c6a..f5878fe 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -41,13 +41,14 @@ type deps struct { gh *github.Client } -// load reads the configuration and creates the version manager. Config -// warnings and the manager's progress go to out, and the manager's -// warnings and token warnings to errOut. It neither resolves the GitHub -// token nor creates the GitHub client. +// load reads the configuration and creates the version manager. The +// warning about a missing home directory and the manager's progress go to +// out, and warnings about config values, the manager's warnings and token +// warnings to errOut. It neither resolves the GitHub token nor creates the +// GitHub client. func (d *deps) load(out, errOut io.Writer) error { var err error - d.cfg, err = config.Load(out) + d.cfg, err = config.Load(out, errOut) if err != nil { return err } diff --git a/pkg/config/config.go b/pkg/config/config.go index d116b5c..efdcb18 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -117,27 +117,27 @@ func defaults(paths Paths) *Config { // empty string counts as not set. The GitHub token is not among them: the // token package reads EDUCATES_GITHUB_TOKEN. // +// A boolean value in none of the forms boolOf accepts reads as false, and +// Load writes a warning that names the value's key and file, or its +// environment variable, to errOut, instead of failing. +// // Without a home directory, Load writes a warning to out, reads no config // file, and puts the bin directory under the current directory. -func Load(out io.Writer) (*Config, error) { +func Load(out, errOut io.Writer) (*Config, error) { paths, err := homePaths() if err != nil { _, _ = fmt.Fprintln(out, "Warning: could not determine home directory, using current directory for bin folder.") cfg := defaults(pathsIn(".")) - if err := cfg.applyEnv(); err != nil { - return nil, err - } + cfg.applyEnv(errOut) return cfg, nil } cfg := defaults(paths) cfg.file = paths.File - if err := cfg.applyFile(paths.File); err != nil { - return nil, err - } - if err := cfg.applyEnv(); err != nil { + if err := cfg.applyFile(paths.File, errOut); err != nil { return nil, err } + cfg.applyEnv(errOut) return cfg, nil } @@ -165,9 +165,20 @@ func (c *Config) settings() []setting { } } +// boolWarning is the warning for a boolean value that reads as false +// because boolOf does not accept it. Its verbs are where the value is set +// and what is wrong with it. +const boolWarning = "Warning: %s %s, so it is read as false. Set it to true or false.\n" + +// notABoolean says that value is not a boolean, for boolWarning. +func notABoolean(value string) string { + return fmt.Sprintf("is %q, which is not a boolean", value) +} + // applyEnv sets every value whose environment variable is set and not -// empty. -func (c *Config) applyEnv() error { +// empty, and writes a warning to warnings for each boolean that reads as +// false because boolOf does not accept it. +func (c *Config) applyEnv(warnings io.Writer) { for _, s := range c.settings() { if s.env == "" { continue @@ -180,18 +191,19 @@ func (c *Config) applyEnv() error { *s.str = value continue } - b, err := parseBool(value) - if err != nil { - return fmt.Errorf("%s: %w", s.env, err) + b, ok := boolOf(value) + if !ok { + _, _ = fmt.Fprintf(warnings, boolWarning, s.env, notABoolean(value)) } *s.boolean = b } - return nil } -// applyFile sets every value the config file at path sets. A missing file -// sets nothing, and so does a key whose value is null. -func (c *Config) applyFile(path string) error { +// applyFile sets every value the config file at path sets, and writes a +// warning to warnings for each boolean that reads as false because boolOf +// does not accept it. A missing file sets nothing, and so does a key whose +// value is null. +func (c *Config) applyFile(path string, warnings io.Writer) error { values, err := readFile(path) if err != nil { return err @@ -202,18 +214,22 @@ func (c *Config) applyFile(path string) error { if !ok || n.ShortTag() == "!!null" { continue } - if n.Kind != yaml.ScalarNode { - return fmt.Errorf("%s in %s: expected a single value", s.key, path) - } - if s.str != nil { - *s.str = n.Value + if s.boolean != nil { + b, ok := fileBool(n) + if !ok { + problem := "is not a single value" + if n.Kind == yaml.ScalarNode { + problem = notABoolean(n.Value) + } + _, _ = fmt.Fprintf(warnings, boolWarning, s.key+" in "+path, problem) + } + *s.boolean = b continue } - b, err := fileBool(n) - if err != nil { - return fmt.Errorf("%s in %s: %w", s.key, path, err) + if n.Kind != yaml.ScalarNode { + return fmt.Errorf("%s in %s: expected a single value", s.key, path) } - *s.boolean = b + *s.str = n.Value } return nil } @@ -268,29 +284,40 @@ func resolve(n *yaml.Node) *yaml.Node { return n } -// parseBool parses the boolean forms strconv.ParseBool accepts: 1, t, T, -// TRUE, true, True, 0, f, F, FALSE, false and False. -func parseBool(value string) (bool, error) { - b, err := strconv.ParseBool(value) - if err != nil { - return false, fmt.Errorf("%q is not a boolean; use true or false", value) +// boolOf reads value as a boolean: a bool as itself, a number as true when +// it is not zero, and a string in the forms strconv.ParseBool accepts: 1, +// t, T, TRUE, true, True, 0, f, F, FALSE, false and False. Any other value +// reads as false, and ok is false. These are the forms and the readings of +// viper's GetBool, so a config written for educatesenv 1.0 reads the same. +func boolOf(value any) (b, ok bool) { + switch v := value.(type) { + case bool: + return v, true + case int: + return v != 0, true + case int64: + return v != 0, true + case uint64: + return v != 0, true + case float64: + return v != 0, true + case string: + b, err := strconv.ParseBool(v) + return b, err == nil + default: + return false, false } - return b, nil } -// fileBool parses a boolean from the config file. A number is true when it -// is not zero, and anything else takes the forms parseBool accepts. -func fileBool(n *yaml.Node) (bool, error) { - switch n.ShortTag() { - case "!!int", "!!float": - var f float64 - if err := n.Decode(&f); err != nil { - return false, fmt.Errorf("%q is not a boolean; use true or false", n.Value) - } - return f != 0, nil - default: - return parseBool(n.Value) +// fileBool reads a boolean from the config file by boolOf, from the value +// YAML decodes n to: a bool for true or false, a number for an integer or +// a float, and a string for any other single value, such as yes or on. +func fileBool(n *yaml.Node) (b, ok bool) { + var value any + if err := n.Decode(&value); err != nil { + return false, false } + return boolOf(value) } // CreateDefault creates the educatesenv directory in the user's home From 46ae00d9420fb07c7c8be84814ae408a3ec3f488 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 23:04:35 +0100 Subject: [PATCH 58/75] fix(use): point use latest at install latest --use use latest failed with a hint to run 'educatesenv install latest', after which use latest still failed, since install resolves latest to a version. It now says that use takes an installed version and suggests 'educatesenv install latest --use', which installs the latest stable release and switches to it. --- README.md | 4 +++- pkg/cmd/use.go | 24 ++++++++++++++++-------- pkg/cmd/use_test.go | 15 +++++++++++++++ 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c1bc7e5..58ca79d 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,9 @@ educatesenv use 3.7.3 `use` points the `educates` symlink at an installed version. When the version is not installed, it says so and how to install it; any other failure, such as a bin directory it cannot write to, is reported with its -cause. `use develop` selects the development binary, as described under +cause. `use` does not resolve `latest`: to install the latest stable +release and switch to it, run `educatesenv install latest --use`. +`use develop` selects the development binary, as described under [Development mode](#development-mode). ### Listing installed versions diff --git a/pkg/cmd/use.go b/pkg/cmd/use.go index 5c61d37..c96da5d 100644 --- a/pkg/cmd/use.go +++ b/pkg/cmd/use.go @@ -7,6 +7,8 @@ import ( "os" "github.com/spf13/cobra" + + "github.com/educates/educatesenv/pkg/version" ) func newUseCommand(d *deps) *cobra.Command { @@ -18,10 +20,10 @@ func newUseCommand(d *deps) *cobra.Command { SilenceUsage: true, PreRun: d.cleanUpDevelopmentSymlink, RunE: func(cmd *cobra.Command, args []string) error { - version := args[0] + name := args[0] // Handle development version - if version == "develop" { + if name == "develop" { if !d.cfg.Development.Enabled { return fmt.Errorf("development mode is not enabled. Enable it in the config file by setting development.enabled to true") } @@ -37,7 +39,7 @@ func newUseCommand(d *deps) *cobra.Command { return fmt.Errorf("failed to check development binary: %w", err) } - if err := d.manager.UseVersion(version); err != nil { + if err := d.manager.UseVersion(name); err != nil { return fmt.Errorf("failed to switch to development version: %w", err) } @@ -46,14 +48,20 @@ func newUseCommand(d *deps) *cobra.Command { } // Handle regular version - if err := d.manager.UseVersion(version); err != nil { - if errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("version %s is not installed. You should install it first with `educatesenv install %s`", version, version) + if err := d.manager.UseVersion(name); err != nil { + switch { + // install resolves latest to a version, while use only + // switches to a version that is installed. + case errors.Is(err, fs.ErrNotExist) && name == version.Latest: + return fmt.Errorf("use takes an installed version, not %[1]s. "+ + "Run 'educatesenv install %[1]s --use' to install the latest stable release and switch to it", version.Latest) + case errors.Is(err, fs.ErrNotExist): + return fmt.Errorf("version %s is not installed. You should install it first with `educatesenv install %s`", name, name) } - return fmt.Errorf("failed to switch to version %s: %w", version, err) + return fmt.Errorf("failed to switch to version %s: %w", name, err) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Now using educates version %s\n", version) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Now using educates version %s\n", name) return nil }, } diff --git a/pkg/cmd/use_test.go b/pkg/cmd/use_test.go index fcce914..c90cfd2 100644 --- a/pkg/cmd/use_test.go +++ b/pkg/cmd/use_test.go @@ -42,6 +42,21 @@ func TestUseOfAVersionThatIsNotInstalledSaysHowToInstallIt(t *testing.T) { assert.Equal(t, "educates-3.8.0", target) } +func TestUseLatestSaysHowToInstallAndUseTheLatestRelease(t *testing.T) { + env := clitest.New(t) + env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("use", "latest") + + require.EqualError(t, res.Err, "use takes an installed version, not latest. Run 'educatesenv install latest --use' to install the latest stable release and switch to it") + assert.Empty(t, res.Stdout) + assert.Empty(t, env.GitHub.Requests()) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + func TestUseWithAnUnwritableBinDirectoryReportsTheCause(t *testing.T) { if os.Geteuid() == 0 { t.Skip("directory permissions do not apply to root") From fc5f3d76806100619afe6f171e5174f80df62ade Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 23:05:01 +0100 Subject: [PATCH 59/75] fix(use): show the required argument and quote commands like the others use's usage line showed its argument as optional, [version|develop], though use requires it, and its not-installed message quoted the install command in backticks where every other message uses single quotes. --- pkg/cmd/use.go | 4 ++-- pkg/cmd/use_test.go | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/use.go b/pkg/cmd/use.go index c96da5d..7a61183 100644 --- a/pkg/cmd/use.go +++ b/pkg/cmd/use.go @@ -13,7 +13,7 @@ import ( func newUseCommand(d *deps) *cobra.Command { return &cobra.Command{ - Use: "use [version|develop]", + Use: "use ", Short: "Switch to a specific educates version", Args: cobra.ExactArgs(1), SilenceErrors: true, @@ -56,7 +56,7 @@ func newUseCommand(d *deps) *cobra.Command { return fmt.Errorf("use takes an installed version, not %[1]s. "+ "Run 'educatesenv install %[1]s --use' to install the latest stable release and switch to it", version.Latest) case errors.Is(err, fs.ErrNotExist): - return fmt.Errorf("version %s is not installed. You should install it first with `educatesenv install %s`", name, name) + return fmt.Errorf("version %s is not installed. You should install it first with 'educatesenv install %s'", name, name) } return fmt.Errorf("failed to switch to version %s: %w", name, err) } diff --git a/pkg/cmd/use_test.go b/pkg/cmd/use_test.go index c90cfd2..a68228c 100644 --- a/pkg/cmd/use_test.go +++ b/pkg/cmd/use_test.go @@ -12,6 +12,15 @@ import ( "github.com/educates/educatesenv/internal/clitest" ) +func TestUseShowsItsRequiredArgumentInItsUsage(t *testing.T) { + env := clitest.New(t) + + res := env.Run("use", "--help") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Usage:\n educatesenv use [flags]\n") +} + func TestUseActivatesAnInstalledVersion(t *testing.T) { env := clitest.New(t) env.WriteBinary("3.7.0") @@ -35,7 +44,7 @@ func TestUseOfAVersionThatIsNotInstalledSaysHowToInstallIt(t *testing.T) { res := env.Run("use", "3.9.0") - require.EqualError(t, res.Err, "version 3.9.0 is not installed. You should install it first with `educatesenv install 3.9.0`") + require.EqualError(t, res.Err, "version 3.9.0 is not installed. You should install it first with 'educatesenv install 3.9.0'") assert.Empty(t, res.Stdout) target, err := os.Readlink(env.ActiveLink()) require.NoError(t, err) From 4407992e20681467f3471dfa6f72a3a178afd1d5 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 23:05:41 +0100 Subject: [PATCH 60/75] refactor(config): name the config paths ConfigDir and ConfigFile Paths.Dir and Paths.File sat beside BinDir without saying which directory and file they were. --- pkg/cmd/config.go | 4 ++-- pkg/cmd/init.go | 4 ++-- pkg/config/config.go | 26 +++++++++++++------------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/pkg/cmd/config.go b/pkg/cmd/config.go index daa7291..9a7d7b1 100644 --- a/pkg/cmd/config.go +++ b/pkg/cmd/config.go @@ -41,9 +41,9 @@ func newConfigInitCommand() *cobra.Command { return err } if !created { - return fmt.Errorf("config file already exists at %s", paths.File) + return fmt.Errorf("config file already exists at %s", paths.ConfigFile) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config file created at %s\n", paths.File) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config file created at %s\n", paths.ConfigFile) return nil }, } diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index 7d594d9..d780ae9 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -37,9 +37,9 @@ func newInitCommand(d *deps) *cobra.Command { defaultBin := paths.BinDir if configCreated { - _, _ = fmt.Fprintf(out, "Config file created at %s\n", paths.File) + _, _ = fmt.Fprintf(out, "Config file created at %s\n", paths.ConfigFile) } else { - _, _ = fmt.Fprintf(out, "Config file already exists at %s\n", paths.File) + _, _ = fmt.Fprintf(out, "Config file already exists at %s\n", paths.ConfigFile) } _, _ = fmt.Fprintf(out, "Bin directory ensured at %s\n", defaultBin) diff --git a/pkg/config/config.go b/pkg/config/config.go index efdcb18..bf89400 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -69,12 +69,12 @@ func (c *Config) File() string { type Paths struct { // Home is the user's home directory. Home string - // Dir is the educatesenv directory in Home. - Dir string + // ConfigDir is the educatesenv directory in Home. + ConfigDir string // BinDir is the default directory installed versions go in. BinDir string - // File is the config file in Dir. - File string + // ConfigFile is the config file in ConfigDir. + ConfigFile string } // homePaths returns the paths in the user's home directory. @@ -88,12 +88,12 @@ func homePaths() (Paths, error) { // pathsIn returns the paths in home. func pathsIn(home string) Paths { - dir := filepath.Join(home, ConfigDirName) + configDir := filepath.Join(home, ConfigDirName) return Paths{ - Home: home, - Dir: dir, - BinDir: filepath.Join(dir, "bin"), - File: filepath.Join(dir, ConfigFileName), + Home: home, + ConfigDir: configDir, + BinDir: filepath.Join(configDir, "bin"), + ConfigFile: filepath.Join(configDir, ConfigFileName), } } @@ -133,8 +133,8 @@ func Load(out, errOut io.Writer) (*Config, error) { } cfg := defaults(paths) - cfg.file = paths.File - if err := cfg.applyFile(paths.File, errOut); err != nil { + cfg.file = paths.ConfigFile + if err := cfg.applyFile(paths.ConfigFile, errOut); err != nil { return nil, err } cfg.applyEnv(errOut) @@ -340,7 +340,7 @@ func CreateDefault() (Paths, bool, error) { return paths, false, fmt.Errorf("failed to marshal config to YAML: %w", err) } - f, err := os.OpenFile(paths.File, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + f, err := os.OpenFile(paths.ConfigFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if errors.Is(err, fs.ErrExist) { return paths, false, nil } @@ -352,7 +352,7 @@ func CreateDefault() (Paths, bool, error) { err = closeErr } if err != nil { - _ = os.Remove(paths.File) + _ = os.Remove(paths.ConfigFile) return paths, false, fmt.Errorf("failed to write config file: %w", err) } return paths, true, nil From 79c2d8925c71e390998f92902ceeb488206633b0 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 23:06:02 +0100 Subject: [PATCH 61/75] refactor(token): give the token sources self-describing names The bare source, labels and environment become tokenSource, sourceLabels and tokenVariables. --- pkg/token/token.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/token/token.go b/pkg/token/token.go index 079ec11..0ae349a 100644 --- a/pkg/token/token.go +++ b/pkg/token/token.go @@ -11,11 +11,11 @@ import ( "strings" ) -// source is where a token came from. -type source int +// tokenSource is where a token came from. +type tokenSource int const ( - notSet source = iota + notSet tokenSource = iota educatesGitHubToken ghToken githubToken @@ -23,8 +23,8 @@ const ( configFile ) -// labels name each source in config view and in error messages. -var labels = map[source]string{ +// sourceLabels name each source in config view and in error messages. +var sourceLabels = map[tokenSource]string{ notSet: "not set", educatesGitHubToken: "EDUCATES_GITHUB_TOKEN", ghToken: "GH_TOKEN", @@ -33,11 +33,11 @@ var labels = map[source]string{ configFile: "config file (deprecated)", } -// environment holds the environment variables that hold a token, in the +// tokenVariables are the environment variables that hold a token, in the // order they are checked. -var environment = []struct { +var tokenVariables = []struct { variable string - source source + source tokenSource }{ {"EDUCATES_GITHUB_TOKEN", educatesGitHubToken}, {"GH_TOKEN", ghToken}, @@ -53,7 +53,7 @@ const configFileWarning = "Warning: github.token in %s is deprecated, and a late // with %s or %v shows its source, never any character of the token. type Token struct { value string - source source + source tokenSource } // Value returns the token, or "" when no source has one. @@ -70,7 +70,7 @@ func (t Token) IsSet() bool { // GH_TOKEN, GITHUB_TOKEN, gh auth token, config file (deprecated), or not // set. func (t Token) Source() string { - return labels[t.source] + return sourceLabels[t.source] } // String returns how config view shows the token: and @@ -99,7 +99,7 @@ func (t Token) String() string { // deprecated, so Resolve writes a warning that names the file to warnings // when it returns one. func Resolve(file, fileToken string, warnings io.Writer) Token { - for _, e := range environment { + for _, e := range tokenVariables { if value := os.Getenv(e.variable); value != "" { return Token{value: value, source: e.source} } From 2e6214e708426f05f4fb20446bb2ef361c1d125f Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 23:06:23 +0100 Subject: [PATCH 62/75] refactor(token): keep the GH_TOKEN and gh auth login hint in one constant The config-file token warning and the rate-limit error each spelled out "Set GH_TOKEN or run 'gh auth login'". --- pkg/github/client.go | 2 +- pkg/token/token.go | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/github/client.go b/pkg/github/client.go index 8b8624e..33b4b5a 100644 --- a/pkg/github/client.go +++ b/pkg/github/client.go @@ -169,5 +169,5 @@ func (c *Client) rateLimitError(reset time.Time) error { if c.token.IsSet() { return fmt.Errorf("GitHub API rate limit exceeded for the token from %s; it resets at %s", c.token.Source(), resets) } - return fmt.Errorf("GitHub API rate limit exceeded; it resets at %s. Set GH_TOKEN or run 'gh auth login' to authenticate and get a higher limit", resets) + return fmt.Errorf("GitHub API rate limit exceeded; it resets at %s. %s to authenticate and get a higher limit", resets, token.AuthenticationHint) } diff --git a/pkg/token/token.go b/pkg/token/token.go index 0ae349a..72d5ddf 100644 --- a/pkg/token/token.go +++ b/pkg/token/token.go @@ -44,10 +44,14 @@ var tokenVariables = []struct { {"GITHUB_TOKEN", githubToken}, } +// AuthenticationHint tells the user how to give educatesenv a token. It +// starts a sentence that each message finishes in its own way. +const AuthenticationHint = "Set GH_TOKEN or run 'gh auth login'" + // configFileWarning is the warning for a token from the config file. Its // verb is the file's path. const configFileWarning = "Warning: github.token in %s is deprecated, and a later release will stop reading it. " + - "Set GH_TOKEN or run 'gh auth login' instead, and remove the token from that file.\n" + AuthenticationHint + " instead, and remove the token from that file.\n" // Token is a GitHub token and the source it came from. Formatting a Token // with %s or %v shows its source, never any character of the token. From 3c1e61a028617564170306dacf644342fb8ba69b Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 23:06:57 +0100 Subject: [PATCH 63/75] refactor(releases): name the selection types SelectOptions and SortOrder The bare Options and Order did not say what they choose or order. --- pkg/cmd/list_remote.go | 2 +- pkg/releases/latest.go | 2 +- pkg/releases/releases.go | 14 +++++++------- pkg/releases/releases_test.go | 34 +++++++++++++++++----------------- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/pkg/cmd/list_remote.go b/pkg/cmd/list_remote.go index a4f33ee..4067653 100644 --- a/pkg/cmd/list_remote.go +++ b/pkg/cmd/list_remote.go @@ -45,7 +45,7 @@ highest version first.`, return err } - opts := releases.Options{IncludePrereleases: preReleases, Limit: recent} + opts := releases.SelectOptions{IncludePrereleases: preReleases, Limit: recent} if all { opts.Limit = releases.All } diff --git a/pkg/releases/latest.go b/pkg/releases/latest.go index dccb3cc..efc917b 100644 --- a/pkg/releases/latest.go +++ b/pkg/releases/latest.go @@ -5,7 +5,7 @@ package releases // tags that are not semver never count. Neither the order of all nor // publish times matter. ok is false when all holds no stable release. func Latest(all []Release) (latest Release, ok bool) { - chosen, _ := Select(all, Options{Limit: 1, Order: BySemver}) + chosen, _ := Select(all, SelectOptions{Limit: 1, Order: BySemver}) if len(chosen) == 0 { return Release{}, false } diff --git a/pkg/releases/releases.go b/pkg/releases/releases.go index 7bd6657..7c4aa21 100644 --- a/pkg/releases/releases.go +++ b/pkg/releases/releases.go @@ -23,12 +23,12 @@ type Release struct { Draft bool } -// Order is the order Select returns releases in. -type Order int +// SortOrder is the order Select returns releases in. +type SortOrder int const ( // BySemver orders releases by version, highest first. - BySemver Order = iota + BySemver SortOrder = iota // ByPublishTime orders releases by publish time, newest first. ByPublishTime ) @@ -36,21 +36,21 @@ const ( // All is the Limit that chooses every candidate. const All = 0 -// Options are the choices Select makes. -type Options struct { +// SelectOptions are the choices Select makes. +type SelectOptions struct { // IncludePrereleases adds pre-releases to the candidates. IncludePrereleases bool // Limit is the most releases to choose. All, or any value below 1, // chooses every candidate. Limit int // Order is the order of the chosen releases. - Order Order + Order SortOrder } // Select chooses releases from all. It returns the chosen releases, in // the order opts names, and the number of candidates they were chosen // from. -func Select(all []Release, opts Options) (chosen []Release, candidates int) { +func Select(all []Release, opts SelectOptions) (chosen []Release, candidates int) { for _, r := range all { if r.Draft || !isSemver(r.Tag) { continue diff --git a/pkg/releases/releases_test.go b/pkg/releases/releases_test.go index 48f8801..1e78ad1 100644 --- a/pkg/releases/releases_test.go +++ b/pkg/releases/releases_test.go @@ -59,7 +59,7 @@ func TestSelectLeavesOutPrereleasesUnlessIncluded(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - chosen, candidates := releases.Select(all, releases.Options{ + chosen, candidates := releases.Select(all, releases.SelectOptions{ IncludePrereleases: tt.includePrereleases, Limit: releases.All, }) @@ -84,7 +84,7 @@ func TestSelectNeverChoosesDraftsOrTagsThatAreNotSemver(t *testing.T) { stable("3.6.0"), } - chosen, candidates := releases.Select(all, releases.Options{ + chosen, candidates := releases.Select(all, releases.SelectOptions{ IncludePrereleases: true, Limit: releases.All, }) @@ -106,7 +106,7 @@ func TestSelectByPublishTimeOrdersNewestFirst(t *testing.T) { {Tag: "4.0.0-alpha.13", PublishedAt: at(27, 9)}, } - chosen, _ := releases.Select(all, releases.Options{ + chosen, _ := releases.Select(all, releases.SelectOptions{ IncludePrereleases: true, Limit: releases.All, Order: releases.ByPublishTime, @@ -137,43 +137,43 @@ func TestSelectCombinesPrereleasesLimitAndOrder(t *testing.T) { tests := []struct { name string - opts releases.Options + opts releases.SelectOptions want []string candidates int }{ { name: "stable, 3, by semver", - opts: releases.Options{Limit: 3, Order: releases.BySemver}, + opts: releases.SelectOptions{Limit: 3, Order: releases.BySemver}, want: []string{"3.8.0", "3.7.4", "3.7.3"}, candidates: 4, }, { name: "stable, all, by semver", - opts: releases.Options{Limit: releases.All, Order: releases.BySemver}, + opts: releases.SelectOptions{Limit: releases.All, Order: releases.BySemver}, want: []string{"3.8.0", "3.7.4", "3.7.3", "3.6.0"}, candidates: 4, }, { name: "stable, 3, by publish time", - opts: releases.Options{Limit: 3, Order: releases.ByPublishTime}, + opts: releases.SelectOptions{Limit: 3, Order: releases.ByPublishTime}, want: []string{"3.7.4", "3.8.0", "3.7.3"}, candidates: 4, }, { name: "stable, all, by publish time", - opts: releases.Options{Limit: releases.All, Order: releases.ByPublishTime}, + opts: releases.SelectOptions{Limit: releases.All, Order: releases.ByPublishTime}, want: []string{"3.7.4", "3.8.0", "3.7.3", "3.6.0"}, candidates: 4, }, { name: "with pre-releases, 3, by semver", - opts: releases.Options{IncludePrereleases: true, Limit: 3, Order: releases.BySemver}, + opts: releases.SelectOptions{IncludePrereleases: true, Limit: 3, Order: releases.BySemver}, want: []string{"4.0.0-alpha.13", "4.0.0-alpha.2", "3.8.1-rc.2"}, candidates: 8, }, { name: "with pre-releases, all, by semver", - opts: releases.Options{IncludePrereleases: true, Limit: releases.All, Order: releases.BySemver}, + opts: releases.SelectOptions{IncludePrereleases: true, Limit: releases.All, Order: releases.BySemver}, want: []string{ "4.0.0-alpha.13", "4.0.0-alpha.2", "3.8.1-rc.2", "3.8.0", "3.8.0-rc.13", "3.7.4", "3.7.3", "3.6.0", @@ -182,13 +182,13 @@ func TestSelectCombinesPrereleasesLimitAndOrder(t *testing.T) { }, { name: "with pre-releases, 3, by publish time", - opts: releases.Options{IncludePrereleases: true, Limit: 3, Order: releases.ByPublishTime}, + opts: releases.SelectOptions{IncludePrereleases: true, Limit: 3, Order: releases.ByPublishTime}, want: []string{"3.7.4", "3.8.1-rc.2", "4.0.0-alpha.13"}, candidates: 8, }, { name: "with pre-releases, all, by publish time", - opts: releases.Options{IncludePrereleases: true, Limit: releases.All, Order: releases.ByPublishTime}, + opts: releases.SelectOptions{IncludePrereleases: true, Limit: releases.All, Order: releases.ByPublishTime}, want: []string{ "3.7.4", "3.8.1-rc.2", "4.0.0-alpha.13", "3.8.0", "3.8.0-rc.13", "3.7.3", "4.0.0-alpha.2", "3.6.0", @@ -197,19 +197,19 @@ func TestSelectCombinesPrereleasesLimitAndOrder(t *testing.T) { }, { name: "a limit above the number of candidates", - opts: releases.Options{Limit: 10}, + opts: releases.SelectOptions{Limit: 10}, want: []string{"3.8.0", "3.7.4", "3.7.3", "3.6.0"}, candidates: 4, }, { name: "a limit equal to the number of candidates", - opts: releases.Options{Limit: 4}, + opts: releases.SelectOptions{Limit: 4}, want: []string{"3.8.0", "3.7.4", "3.7.3", "3.6.0"}, candidates: 4, }, { name: "a negative limit", - opts: releases.Options{Limit: -1}, + opts: releases.SelectOptions{Limit: -1}, want: []string{"3.8.0", "3.7.4", "3.7.3", "3.6.0"}, candidates: 4, }, @@ -228,7 +228,7 @@ func TestSelectCombinesPrereleasesLimitAndOrder(t *testing.T) { func TestSelectLeavesItsInputAlone(t *testing.T) { all := []releases.Release{stable("3.7.0"), stable("3.9.0-rc.1"), stable("3.8.0")} - _, _ = releases.Select(all, releases.Options{Limit: 1}) + _, _ = releases.Select(all, releases.SelectOptions{Limit: 1}) assert.Equal(t, []string{"3.7.0", "3.9.0-rc.1", "3.8.0"}, tags(all)) } @@ -263,7 +263,7 @@ func TestSelectOrdersBySemverHighestFirst(t *testing.T) { all = append(all, stable(tag)) } - chosen, _ := releases.Select(all, releases.Options{ + chosen, _ := releases.Select(all, releases.SelectOptions{ IncludePrereleases: true, Limit: releases.All, Order: releases.BySemver, From 076c7dd5b8363f0d95f4aa075042e1f3206d61f5 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 23:07:13 +0100 Subject: [PATCH 64/75] refactor(releases): rename version to semverForm The helper only adds the v prefix that golang.org/x/mod/semver needs, which the name version did not say. --- pkg/releases/installed.go | 2 +- pkg/releases/releases.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/releases/installed.go b/pkg/releases/installed.go index 99dedba..d790e85 100644 --- a/pkg/releases/installed.go +++ b/pkg/releases/installed.go @@ -19,7 +19,7 @@ func SortInstalled(names []string) []string { aIsSemver, bIsSemver := isSemver(a), isSemver(b) switch { case aIsSemver && bIsSemver: - if c := semver.Compare(version(b), version(a)); c != 0 { + if c := semver.Compare(semverForm(b), semverForm(a)); c != 0 { return c } return strings.Compare(a, b) diff --git a/pkg/releases/releases.go b/pkg/releases/releases.go index 7c4aa21..7bef490 100644 --- a/pkg/releases/releases.go +++ b/pkg/releases/releases.go @@ -64,7 +64,7 @@ func Select(all []Release, opts SelectOptions) (chosen []Release, candidates int if opts.Order == ByPublishTime { return b.PublishedAt.Compare(a.PublishedAt) } - return semver.Compare(version(b.Tag), version(a.Tag)) + return semver.Compare(semverForm(b.Tag), semverForm(a.Tag)) }) candidates = len(chosen) @@ -77,12 +77,12 @@ func Select(all []Release, opts SelectOptions) (chosen []Release, candidates int // isPrerelease reports whether r is a pre-release: its tag has a semver // pre-release part, or GitHub flags it. func (r Release) isPrerelease() bool { - return r.GitHubPrerelease || semver.Prerelease(version(r.Tag)) != "" + return r.GitHubPrerelease || semver.Prerelease(semverForm(r.Tag)) != "" } -// version returns tag in the form golang.org/x/mod/semver reads, which +// semverForm returns tag in the form golang.org/x/mod/semver reads, which // needs a "v" prefix that Educates tags do not have. -func version(tag string) string { +func semverForm(tag string) string { return "v" + tag } @@ -90,6 +90,6 @@ func version(tag string) string { // or "4.0.0-alpha.13". The shorthands golang.org/x/mod/semver accepts, // such as "3.8", are not. func isSemver(tag string) bool { - v := version(tag) + v := semverForm(tag) return semver.Canonical(v) == strings.TrimSuffix(v, semver.Build(v)) } From 0913e82e87e670791d01d0f9d4d1f37b52f970c8 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 23:09:16 +0100 Subject: [PATCH 65/75] fix(list): find installed versions and the active one as uninstall does list and uninstall each scanned the bin directory, and found the active version in two ways: list compared the symlink's target with the development binary's path as a string, and uninstall compared files. The symlink that 'use develop' writes is relative, so list never marked develop active after it. The version manager's Installed now lists the installed versions and finds the active one, by comparing files, for both commands. --- pkg/cmd/list.go | 34 +++--------------------- pkg/cmd/list_test.go | 13 +++++++++ pkg/version/installed.go | 57 ++++++++++++++++++++++++++++++++++++++++ pkg/version/uninstall.go | 46 +++++++------------------------- 4 files changed, 84 insertions(+), 66 deletions(-) create mode 100644 pkg/version/installed.go diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 83a43ec..9489abf 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -2,9 +2,6 @@ package cmd import ( "fmt" - "os" - "path/filepath" - "strings" "github.com/educates/educatesenv/pkg/config" "github.com/educates/educatesenv/pkg/releases" @@ -29,35 +26,21 @@ func runList(d *deps) func(cmd *cobra.Command, args []string) error { cfg := d.cfg out := cmd.OutOrStdout() - // Get all files in the bin directory - files, err := os.ReadDir(cfg.Local.Dir) - // If the bin directory doesn't exist call init + versions, activeVersion, err := d.manager.Installed() + // If the bin directory cannot be read, create the default config + // and bin directory, as init does if err != nil { if _, _, err := config.CreateDefault(); err != nil { return fmt.Errorf("you should run `educatesenv init` first") } } - // Get the active version by reading the symlink - activeVersion := "" - isDevelopmentActive := false - symlink := filepath.Join(cfg.Local.Dir, "educates") - if target, err := os.Readlink(symlink); err == nil { - // Check if the active version is a development version - if cfg.Development.Enabled && target == cfg.Development.BinaryLocation { - isDevelopmentActive = true - } else { - activeVersion = filepath.Base(target) - activeVersion = strings.TrimPrefix(activeVersion, "educates-") - } - } - // Print installed versions _, _ = fmt.Fprintln(out, "Installed versions:") // Show development version if enabled if cfg.Development.Enabled { - if isDevelopmentActive { + if activeVersion == "develop" { _, _ = fmt.Fprintf(out, "* develop (active) -> %s\n", cfg.Development.BinaryLocation) } else { _, _ = fmt.Fprintf(out, " develop -> %s\n", cfg.Development.BinaryLocation) @@ -65,15 +48,6 @@ func runList(d *deps) func(cmd *cobra.Command, args []string) error { } // Print regular versions, newest first - var versions []string - for _, file := range files { - if file.IsDir() || file.Name() == "educates" { - continue - } - if version, ok := strings.CutPrefix(file.Name(), "educates-"); ok { - versions = append(versions, version) - } - } for _, version := range releases.SortInstalled(versions) { if version == activeVersion { _, _ = fmt.Fprintf(out, "* %s (active)\n", version) diff --git a/pkg/cmd/list_test.go b/pkg/cmd/list_test.go index 38b1f08..0b57ac4 100644 --- a/pkg/cmd/list_test.go +++ b/pkg/cmd/list_test.go @@ -135,6 +135,19 @@ func TestListShowsDevelopFirstWhenDevelopmentModeIsEnabled(t *testing.T) { } } +func TestListMarksDevelopActiveAfterUseDevelop(t *testing.T) { + env := clitest.New(t) + devBinary := writeDevelopmentBinary(t, env) + env.WriteConfig("development:\n enabled: true\n binaryLocation: " + devBinary + "\n") + env.WriteBinary("3.8.0") + require.NoError(t, env.Run("use", "develop").Err) + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, "Installed versions:\n* develop (active) -> "+devBinary+"\n 3.8.0\n", res.Stdout) +} + func TestListSaysWhenNoVersionIsInstalled(t *testing.T) { env := clitest.New(t) diff --git a/pkg/version/installed.go b/pkg/version/installed.go new file mode 100644 index 0000000..fa75873 --- /dev/null +++ b/pkg/version/installed.go @@ -0,0 +1,57 @@ +package version + +import ( + "os" + "path/filepath" + "strings" + + "github.com/educates/educatesenv/pkg/platform" +) + +// Installed returns the versions installed in the bin directory, in no +// particular order, and the active one, the version the educates symlink +// points at. The active version is one of versions, the development +// binary's name develop when development mode is enabled and the symlink +// points at that binary, or "" when no version is active. Installed fails +// when the bin directory cannot be read; a missing bin directory gives an +// error that wraps fs.ErrNotExist. +func (m *Manager) Installed() (versions []string, active string, err error) { + entries, err := os.ReadDir(m.config.Local.Dir) + if err != nil { + return nil, "", err + } + for _, entry := range entries { + if entry.IsDir() || entry.Name() == "educates" { + continue + } + if version, ok := strings.CutPrefix(entry.Name(), platform.BinaryPrefix); ok { + versions = append(versions, version) + } + } + return versions, m.activeVersion(versions), nil +} + +// activeVersion returns the version among installed whose binary the +// educates symlink resolves to, or develop when development mode is +// enabled and the symlink resolves to the development binary. It returns +// "" when the symlink resolves to none of them, or does not resolve. +func (m *Manager) activeVersion(installed []string) string { + active, err := os.Stat(filepath.Join(m.config.Local.Dir, "educates")) + if err != nil { + return "" + } + isActive := func(path string) bool { + binary, err := os.Stat(path) + return err == nil && os.SameFile(active, binary) + } + + for _, version := range installed { + if isActive(filepath.Join(m.config.Local.Dir, platform.BinaryPrefix+version)) { + return version + } + } + if m.config.Development.Enabled && m.config.Development.BinaryLocation != "" && isActive(m.config.Development.BinaryLocation) { + return "develop" + } + return "" +} diff --git a/pkg/version/uninstall.go b/pkg/version/uninstall.go index 85d72c5..ec4078d 100644 --- a/pkg/version/uninstall.go +++ b/pkg/version/uninstall.go @@ -3,8 +3,10 @@ package version import ( "errors" "fmt" + "io/fs" "os" "path/filepath" + "slices" "github.com/educates/educatesenv/pkg/platform" ) @@ -22,8 +24,9 @@ func (m *Manager) UninstallVersions(versions []string, force bool) error { return filepath.Join(binDir, platform.BinaryPrefix+version) } - installed, err := fileNamesIn(binDir) - if err != nil { + // A missing bin directory holds no versions. + installed, active, err := m.Installed() + if err != nil && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("failed to read bin directory %s: %w", binDir, err) } @@ -43,13 +46,14 @@ func (m *Manager) UninstallVersions(versions []string, force bool) error { problems = append(problems, errors.New("cannot uninstall develop: educatesenv does not manage the development binary")) continue } - // Looking the binary up by name keeps an argument that holds a - // path from reaching outside the bin directory. - if !installed[platform.BinaryPrefix+version] { + // Looking the version up among the installed ones keeps an + // argument that holds a path from reaching outside the bin + // directory. + if !slices.Contains(installed, version) { problems = append(problems, fmt.Errorf("version %s is not installed. Run 'educatesenv list' to see installed versions", version)) continue } - if linksTo(activeLink, binaryPath(version)) { + if version == active { if !force { problems = append(problems, fmt.Errorf("version %s is active. Switch to another version with 'educatesenv use ' first, or pass --force to uninstall it", version)) continue @@ -81,33 +85,3 @@ func (m *Manager) UninstallVersions(versions []string, force bool) error { } return nil } - -// fileNamesIn returns the names of the entries in dir that are not -// directories. A missing dir holds none. -func fileNamesIn(dir string) (map[string]bool, error) { - entries, err := os.ReadDir(dir) - if err != nil && !os.IsNotExist(err) { - return nil, err - } - - names := make(map[string]bool, len(entries)) - for _, entry := range entries { - if !entry.IsDir() { - names[entry.Name()] = true - } - } - return names, nil -} - -// linksTo reports whether link resolves to the same file as path. -func linksTo(link, path string) bool { - linked, err := os.Stat(link) - if err != nil { - return false - } - target, err := os.Stat(path) - if err != nil { - return false - } - return os.SameFile(linked, target) -} From 43576a4e5654faea6abb408aeecebd05253e7d8a Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Mon, 26 Oct 2026 23:10:27 +0100 Subject: [PATCH 66/75] refactor(version): name the develop version and the educates symlink once The literals "develop" and "educates" and the binary prefix were spelled out across list, use, uninstall and the version manager. The version manager now has a Develop constant, the name of the educates symlink, and one place each that builds the symlink's path and a version's binary path from platform.BinaryPrefix. --- pkg/cmd/list.go | 11 ++++++----- pkg/cmd/use.go | 2 +- pkg/version/installed.go | 17 ++++++++--------- pkg/version/manager.go | 27 ++++++++++++++++++++++----- pkg/version/uninstall.go | 17 ++++------------- 5 files changed, 41 insertions(+), 33 deletions(-) diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 9489abf..c0b3764 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -5,6 +5,7 @@ import ( "github.com/educates/educatesenv/pkg/config" "github.com/educates/educatesenv/pkg/releases" + "github.com/educates/educatesenv/pkg/version" "github.com/spf13/cobra" ) @@ -40,7 +41,7 @@ func runList(d *deps) func(cmd *cobra.Command, args []string) error { // Show development version if enabled if cfg.Development.Enabled { - if activeVersion == "develop" { + if activeVersion == version.Develop { _, _ = fmt.Fprintf(out, "* develop (active) -> %s\n", cfg.Development.BinaryLocation) } else { _, _ = fmt.Fprintf(out, " develop -> %s\n", cfg.Development.BinaryLocation) @@ -48,11 +49,11 @@ func runList(d *deps) func(cmd *cobra.Command, args []string) error { } // Print regular versions, newest first - for _, version := range releases.SortInstalled(versions) { - if version == activeVersion { - _, _ = fmt.Fprintf(out, "* %s (active)\n", version) + for _, name := range releases.SortInstalled(versions) { + if name == activeVersion { + _, _ = fmt.Fprintf(out, "* %s (active)\n", name) } else { - _, _ = fmt.Fprintf(out, " %s\n", version) + _, _ = fmt.Fprintf(out, " %s\n", name) } } diff --git a/pkg/cmd/use.go b/pkg/cmd/use.go index 7a61183..5e66f7a 100644 --- a/pkg/cmd/use.go +++ b/pkg/cmd/use.go @@ -23,7 +23,7 @@ func newUseCommand(d *deps) *cobra.Command { name := args[0] // Handle development version - if name == "develop" { + if name == version.Develop { if !d.cfg.Development.Enabled { return fmt.Errorf("development mode is not enabled. Enable it in the config file by setting development.enabled to true") } diff --git a/pkg/version/installed.go b/pkg/version/installed.go index fa75873..5e3b227 100644 --- a/pkg/version/installed.go +++ b/pkg/version/installed.go @@ -2,7 +2,6 @@ package version import ( "os" - "path/filepath" "strings" "github.com/educates/educatesenv/pkg/platform" @@ -10,9 +9,9 @@ import ( // Installed returns the versions installed in the bin directory, in no // particular order, and the active one, the version the educates symlink -// points at. The active version is one of versions, the development -// binary's name develop when development mode is enabled and the symlink -// points at that binary, or "" when no version is active. Installed fails +// points at. The active version is one of versions, Develop when +// development mode is enabled and the symlink points at the development +// binary, or "" when no version is active. Installed fails // when the bin directory cannot be read; a missing bin directory gives an // error that wraps fs.ErrNotExist. func (m *Manager) Installed() (versions []string, active string, err error) { @@ -21,7 +20,7 @@ func (m *Manager) Installed() (versions []string, active string, err error) { return nil, "", err } for _, entry := range entries { - if entry.IsDir() || entry.Name() == "educates" { + if entry.IsDir() || entry.Name() == activeLinkName { continue } if version, ok := strings.CutPrefix(entry.Name(), platform.BinaryPrefix); ok { @@ -32,11 +31,11 @@ func (m *Manager) Installed() (versions []string, active string, err error) { } // activeVersion returns the version among installed whose binary the -// educates symlink resolves to, or develop when development mode is +// educates symlink resolves to, or Develop when development mode is // enabled and the symlink resolves to the development binary. It returns // "" when the symlink resolves to none of them, or does not resolve. func (m *Manager) activeVersion(installed []string) string { - active, err := os.Stat(filepath.Join(m.config.Local.Dir, "educates")) + active, err := os.Stat(m.activeLink()) if err != nil { return "" } @@ -46,12 +45,12 @@ func (m *Manager) activeVersion(installed []string) string { } for _, version := range installed { - if isActive(filepath.Join(m.config.Local.Dir, platform.BinaryPrefix+version)) { + if isActive(m.binaryPath(version)) { return version } } if m.config.Development.Enabled && m.config.Development.BinaryLocation != "" && isActive(m.config.Development.BinaryLocation) { - return "develop" + return Develop } return "" } diff --git a/pkg/version/manager.go b/pkg/version/manager.go index c925120..c207da9 100644 --- a/pkg/version/manager.go +++ b/pkg/version/manager.go @@ -52,6 +52,23 @@ func New(cfg *config.Config, gh func() (*github.Client, error), out, errOut io.W } } +// Develop is the version name that stands for the development binary. +const Develop = "develop" + +// activeLinkName is the name of the symlink in the bin directory that +// points at the active version's binary, which then runs as educates. +const activeLinkName = "educates" + +// activeLink returns the path of the educates symlink. +func (m *Manager) activeLink() string { + return filepath.Join(m.config.Local.Dir, activeLinkName) +} + +// binaryPath returns the path of the binary of the installed version. +func (m *Manager) binaryPath(version string) string { + return filepath.Join(m.config.Local.Dir, platform.BinaryPrefix+version) +} + // newDownloadClient returns the HTTP client that downloads release assets. // It times out on connecting, on the TLS handshake and on waiting for the // response headers, and sets no limit on the whole transfer. @@ -73,7 +90,7 @@ func (m *Manager) ValidateDevelopmentMode() error { return nil } - symlinkPath := filepath.Join(m.config.Local.Dir, "educates") + symlinkPath := m.activeLink() isDev, err := m.isDevSymlink(symlinkPath) if err != nil { return fmt.Errorf("failed to validate development mode: %w", err) @@ -116,10 +133,10 @@ func (m *Manager) isDevSymlink(symlinkPath string) (bool, error) { // UseVersion sets a version as active. When the version's binary is // missing, the error wraps fs.ErrNotExist. func (m *Manager) UseVersion(version string) error { - symlinkPath := filepath.Join(m.config.Local.Dir, "educates") + symlinkPath := m.activeLink() // Handle development version - if version == "develop" { + if version == Develop { if !m.config.Development.Enabled { return fmt.Errorf("development mode is not enabled. Enable it in the config file by setting development.enabled to true") } @@ -130,7 +147,7 @@ func (m *Manager) UseVersion(version string) error { } // Handle regular version - binaryPath := filepath.Join(m.config.Local.Dir, fmt.Sprintf("%s%s", platform.BinaryPrefix, version)) + binaryPath := m.binaryPath(version) err := m.createSymlink(binaryPath, symlinkPath) if err != nil { return fmt.Errorf("%w", err) @@ -168,7 +185,7 @@ func (m *Manager) InstallVersion(version string, force bool, activate bool) erro } // Check if version already exists - binaryPath := filepath.Join(binDir, fmt.Sprintf("%s%s", platform.BinaryPrefix, version)) + binaryPath := m.binaryPath(version) _, err := os.Stat(binaryPath) versionExists := err == nil diff --git a/pkg/version/uninstall.go b/pkg/version/uninstall.go index ec4078d..cc1516a 100644 --- a/pkg/version/uninstall.go +++ b/pkg/version/uninstall.go @@ -5,10 +5,7 @@ import ( "fmt" "io/fs" "os" - "path/filepath" "slices" - - "github.com/educates/educatesenv/pkg/platform" ) // UninstallVersions removes the installed versions given, reporting each @@ -18,16 +15,10 @@ import ( // is set; with force, the educates symlink goes too, and no version is // active afterwards. func (m *Manager) UninstallVersions(versions []string, force bool) error { - binDir := m.config.Local.Dir - activeLink := filepath.Join(binDir, "educates") - binaryPath := func(version string) string { - return filepath.Join(binDir, platform.BinaryPrefix+version) - } - // A missing bin directory holds no versions. installed, active, err := m.Installed() if err != nil && !errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("failed to read bin directory %s: %w", binDir, err) + return fmt.Errorf("failed to read bin directory %s: %w", m.config.Local.Dir, err) } var ( @@ -42,7 +33,7 @@ func (m *Manager) UninstallVersions(versions []string, force bool) error { } seen[version] = true - if version == "develop" { + if version == Develop { problems = append(problems, errors.New("cannot uninstall develop: educatesenv does not manage the development binary")) continue } @@ -70,11 +61,11 @@ func (m *Manager) UninstallVersions(versions []string, force bool) error { if version == activeVersion { // The symlink goes first, so that a failure leaves no symlink // pointing at a removed binary. - if err := os.Remove(activeLink); err != nil { + if err := os.Remove(m.activeLink()); err != nil { return fmt.Errorf("failed to remove the educates symlink: %w", err) } } - if err := os.Remove(binaryPath(version)); err != nil { + if err := os.Remove(m.binaryPath(version)); err != nil { return fmt.Errorf("failed to uninstall version %s: %w", version, err) } _, _ = fmt.Fprintf(m.out, "educates %s uninstalled.\n", version) From 79cad35961583fcdf2555f946dcd6c5ac8f496fe Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Tue, 27 Oct 2026 13:16:48 +0100 Subject: [PATCH 67/75] test(fakegithub): serve a download that stalls and one that trickles StallNextDownload sends half of an asset and then nothing more until the client gives up. TrickleDownloads sends an asset in chunks spaced apart. CutNextDownload shares the part that announces the full length and sends the first half. --- internal/fakegithub/downloads.go | 78 +++++++++++++++++++++++---- internal/fakegithub/downloads_test.go | 61 ++++++++++++++++++++- 2 files changed, 129 insertions(+), 10 deletions(-) diff --git a/internal/fakegithub/downloads.go b/internal/fakegithub/downloads.go index af0e92f..0c88d4b 100644 --- a/internal/fakegithub/downloads.go +++ b/internal/fakegithub/downloads.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "sync/atomic" + "time" ) // Checksums returns a checksums.txt asset that lists the SHA-256 checksum @@ -27,9 +28,73 @@ func Checksums(assets ...Asset) Asset { // connection. Later downloads of the asset are served whole. Calling it // twice for the same asset panics, as Handle does. func (s *Server) CutNextDownload(tag, name string) { - var cut atomic.Bool + s.interruptNextDownload(tag, name, func(w http.ResponseWriter, _ *http.Request) { + conn, _, err := http.NewResponseController(w).Hijack() + if err != nil { + panic("fakegithub: cannot drop the connection: " + err.Error()) + } + _ = conn.Close() + }) +} + +// stallLimit is how long a stalled download sends nothing before the +// server drops the connection, so that a client that never gives up fails +// instead of hanging the test. +const stallLimit = 10 * time.Second + +// StallNextDownload makes the next download of the asset name of the +// release tagged tag stall mid-transfer: the response announces the +// asset's full length and sends the first half of it, then sends nothing +// more and keeps the connection open until the client gives up. After 10 +// seconds the server drops the connection. Later downloads of the asset +// are served whole. Calling it twice for the same asset panics, as Handle +// does. +func (s *Server) StallNextDownload(tag, name string) { + s.interruptNextDownload(tag, name, func(_ http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + case <-time.After(stallLimit): + } + }) +} + +// TrickleDownloads makes every download of the asset name of the release +// tagged tag arrive slowly: the response announces the asset's full length +// and sends it in chunks pieces of about the same size, waiting interval +// before each one, so the transfer takes chunks times interval in all. +// Calling it twice for the same asset panics, as Handle does. +func (s *Server) TrickleDownloads(tag, name string, chunks int, interval time.Duration) { + s.Handle("GET /download/"+tag+"/"+name, func(w http.ResponseWriter, r *http.Request) { + content, ok := s.assetContent(tag, name) + if !ok { + notFound(w) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(content))) + w.WriteHeader(http.StatusOK) + rc := http.NewResponseController(w) + _ = rc.Flush() + for i := range chunks { + select { + case <-r.Context().Done(): + return + case <-time.After(interval): + } + _, _ = w.Write(content[i*len(content)/chunks : (i+1)*len(content)/chunks]) + _ = rc.Flush() + } + }) +} + +// interruptNextDownload makes the next download of the asset name of the +// release tagged tag announce the asset's full length and send the first +// half of it, then calls interrupt to end the transfer. Later downloads of +// the asset are served whole. +func (s *Server) interruptNextDownload(tag, name string, interrupt http.HandlerFunc) { + var interrupted atomic.Bool s.Handle("GET /download/"+tag+"/"+name, func(w http.ResponseWriter, r *http.Request) { - if cut.Swap(true) { + if interrupted.Swap(true) { s.routes.ServeHTTP(w, r) return } @@ -43,13 +108,8 @@ func (s *Server) CutNextDownload(tag, name string) { w.Header().Set("Content-Length", strconv.Itoa(len(content))) w.WriteHeader(http.StatusOK) _, _ = w.Write(content[:len(content)/2]) - rc := http.NewResponseController(w) - _ = rc.Flush() - conn, _, err := rc.Hijack() - if err != nil { - panic("fakegithub: cannot drop the connection: " + err.Error()) - } - _ = conn.Close() + _ = http.NewResponseController(w).Flush() + interrupt(w, r) }) } diff --git a/internal/fakegithub/downloads_test.go b/internal/fakegithub/downloads_test.go index 254f017..219c616 100644 --- a/internal/fakegithub/downloads_test.go +++ b/internal/fakegithub/downloads_test.go @@ -2,8 +2,10 @@ package fakegithub_test import ( "io" + "net" "net/http" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -13,7 +15,12 @@ import ( func get(t *testing.T, url string) (*http.Response, []byte, error) { t.Helper() - resp, err := http.Get(url) + return getWith(t, http.DefaultClient, url) +} + +func getWith(t *testing.T, client *http.Client, url string) (*http.Response, []byte, error) { + t.Helper() + resp, err := client.Get(url) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) @@ -49,3 +56,55 @@ func TestCutsTheNextDownloadMidTransferAndServesLaterOnesWhole(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, "0123456789", string(body)) } + +func TestStallsTheNextDownloadMidTransferAndServesLaterOnesWhole(t *testing.T) { + srv := fakegithub.New(t) + srv.AddReleases(fakegithub.Release{Tag: "3.8.0", Assets: []fakegithub.Asset{ + {Name: "educates-linux-amd64", Content: []byte("0123456789")}, + }}) + srv.StallNextDownload("3.8.0", "educates-linux-amd64") + + client := &http.Client{Timeout: 200 * time.Millisecond} + resp, body, err := getWith(t, client, srv.DownloadURL("3.8.0", "educates-linux-amd64")) + var netErr net.Error + require.ErrorAs(t, err, &netErr) + assert.True(t, netErr.Timeout(), "the download times out instead of failing: %v", err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, int64(10), resp.ContentLength) + assert.Equal(t, "01234", string(body)) + + resp, body, err = get(t, srv.DownloadURL("3.8.0", "educates-linux-amd64")) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "0123456789", string(body)) +} + +func TestTricklesADownloadInChunksSpacedApart(t *testing.T) { + srv := fakegithub.New(t) + srv.AddReleases(fakegithub.Release{Tag: "3.8.0", Assets: []fakegithub.Asset{ + {Name: "educates-linux-amd64", Content: []byte("0123456789")}, + }}) + srv.TrickleDownloads("3.8.0", "educates-linux-amd64", 5, 20*time.Millisecond) + + resp, err := http.Get(srv.DownloadURL("3.8.0", "educates-linux-amd64")) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, int64(10), resp.ContentLength) + + var chunks []string + start := time.Now() + buf := make([]byte, 10) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + chunks = append(chunks, string(buf[:n])) + } + if err == io.EOF { + break + } + require.NoError(t, err) + } + assert.Equal(t, []string{"01", "23", "45", "67", "89"}, chunks) + assert.GreaterOrEqual(t, time.Since(start), 5*20*time.Millisecond) +} From 024fb8c0b66918da475a558ee92d4194e132333a Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Tue, 27 Oct 2026 13:20:12 +0100 Subject: [PATCH 68/75] feat(version): fail a download that receives no data for 60 seconds Each read of a download's body waits at most 60 seconds for data, and the request is cancelled when it waits longer. The error says the download stalled and for how long. Nothing caps the whole transfer, so a slow download that keeps making progress finishes. cmd.WithDownloadIdleTimeout shortens the wait for tests. --- internal/clitest/clitest.go | 10 ++++- pkg/cmd/install_test.go | 40 +++++++++++++++++ pkg/cmd/root.go | 15 ++++++- pkg/version/manager.go | 90 +++++++++++++++++++++++++++++-------- 4 files changed, 134 insertions(+), 21 deletions(-) diff --git a/internal/clitest/clitest.go b/internal/clitest/clitest.go index 6ed8fa7..be0a934 100644 --- a/internal/clitest/clitest.go +++ b/internal/clitest/clitest.go @@ -18,6 +18,7 @@ import ( "runtime" "strings" "testing" + "time" "github.com/educates/educatesenv/internal/fakegithub" "github.com/educates/educatesenv/pkg/cmd" @@ -53,6 +54,10 @@ type Env struct { PathDir string // GitHub is the fake GitHub server the commands call. GitHub *fakegithub.Server + // DownloadIdleTimeout, when not zero, is how long a download in the + // commands Run builds waits for data before it fails, in place of the + // 60 seconds it waits outside tests. + DownloadIdleTimeout time.Duration } // Result is what one run of a command tree produced. @@ -96,7 +101,10 @@ func New(t *testing.T) *Env { func (e *Env) Run(args ...string) Result { e.t.Helper() - root := cmd.NewRootCommand(cmd.WithGitHubBaseURL(e.GitHub.URL)) + root := cmd.NewRootCommand( + cmd.WithGitHubBaseURL(e.GitHub.URL), + cmd.WithDownloadIdleTimeout(e.DownloadIdleTimeout), + ) var stdout, stderr bytes.Buffer root.SetOut(&stdout) root.SetErr(&stderr) diff --git a/pkg/cmd/install_test.go b/pkg/cmd/install_test.go index 5a8bcdc..b009c18 100644 --- a/pkg/cmd/install_test.go +++ b/pkg/cmd/install_test.go @@ -103,6 +103,46 @@ func TestInstallCutMidTransferLeavesNothingBehindAndARetryInstalls(t *testing.T) assert.Equal(t, []string{"educates-3.8.0"}, binDirEntries(t, env)) } +func TestInstallFailsWhenTheDownloadStallsAndLeavesNothingBehindAndARetryInstalls(t *testing.T) { + env := clitest.New(t) + env.DownloadIdleTimeout = 100 * time.Millisecond + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + env.GitHub.StallNextDownload("3.8.0", clitest.PlatformBinaryName()) + + res := env.Run("install", "3.8.0") + + require.EqualError(t, res.Err, "failed to install version 3.8.0: failed to download binary (check your internet connection and try again): download stalled: no data arrived for 0.1 seconds") + assert.NotContains(t, res.Stdout, "installed successfully") + assert.Empty(t, binDirEntries(t, env)) + + res = env.Run("install", "3.8.0") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "educates 3.8.0 installed successfully.\n") + content, err := os.ReadFile(filepath.Join(env.BinDir(), "educates-3.8.0")) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0 binary", string(content)) + assert.Equal(t, []string{"educates-3.8.0"}, binDirEntries(t, env)) +} + +func TestInstallFinishesASlowDownloadThatKeepsMakingProgress(t *testing.T) { + env := clitest.New(t) + env.DownloadIdleTimeout = 250 * time.Millisecond + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + // The binary arrives in 8 chunks 50 milliseconds apart: no wait comes + // near the idle timeout, but the transfer takes longer than it in all. + env.GitHub.TrickleDownloads("3.8.0", clitest.PlatformBinaryName(), 8, 50*time.Millisecond) + + res := env.Run("install", "3.8.0") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "educates 3.8.0 installed successfully.\n") + content, err := os.ReadFile(filepath.Join(env.BinDir(), "educates-3.8.0")) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0 binary", string(content)) + assert.Equal(t, []string{"educates-3.8.0"}, binDirEntries(t, env)) +} + // releasedSum is the SHA-256 checksum of "educates 3.8.0 binary". const releasedSum = "21163080cb987ff30bc789604b421676c7065e47b4c77415af76459db9bfc4bd" diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index f5878fe..4a511e7 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "io" + "time" "github.com/spf13/cobra" @@ -16,7 +17,8 @@ import ( type Option func(*options) type options struct { - githubBaseURL string + githubBaseURL string + downloadIdleTimeout time.Duration } // WithGitHubBaseURL points the GitHub client at baseURL instead of @@ -27,6 +29,15 @@ func WithGitHubBaseURL(baseURL string) Option { } } +// WithDownloadIdleTimeout makes a download fail when no data arrives for +// timeout, instead of the 60 seconds the version manager waits by default. +// A zero timeout keeps the default. +func WithDownloadIdleTimeout(timeout time.Duration) Option { + return func(o *options) { + o.downloadIdleTimeout = timeout + } +} + // deps holds what the commands of one tree share. The root command loads // it before any command runs. type deps struct { @@ -54,7 +65,7 @@ func (d *deps) load(out, errOut io.Writer) error { } d.errOut = errOut - d.manager = version.New(d.cfg, d.githubClient, out, errOut) + d.manager = version.New(d.cfg, d.githubClient, out, errOut, version.WithDownloadIdleTimeout(d.opts.downloadIdleTimeout)) return nil } diff --git a/pkg/version/manager.go b/pkg/version/manager.go index c207da9..a969823 100644 --- a/pkg/version/manager.go +++ b/pkg/version/manager.go @@ -2,8 +2,10 @@ package version import ( "bytes" + "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" "io/fs" @@ -12,6 +14,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "strings" "time" @@ -20,36 +23,57 @@ import ( "github.com/educates/educatesenv/pkg/platform" ) -// Timeouts of the HTTP client that downloads release assets. Each one bounds -// a step that waits on the server before any of the body arrives. Nothing -// bounds the whole transfer, so a slow download that keeps making progress +// Timeouts of the downloads of release assets. The first three each bound a +// step that waits on the server before any of the body arrives, and +// downloadIdleTimeout bounds each wait for more of the body. Nothing bounds +// the whole transfer, so a slow download that keeps making progress // finishes. const ( connectTimeout = 30 * time.Second tlsHandshakeTimeout = 10 * time.Second responseHeaderTimeout = 30 * time.Second + downloadIdleTimeout = 60 * time.Second ) // Manager handles version-related operations type Manager struct { - config *config.Config - github func() (*github.Client, error) - http *http.Client - out io.Writer - errOut io.Writer + config *config.Config + github func() (*github.Client, error) + http *http.Client + downloadIdleTimeout time.Duration + out io.Writer + errOut io.Writer +} + +// Option changes how New builds a Manager. +type Option func(*Manager) + +// WithDownloadIdleTimeout makes a download fail when no data arrives for +// timeout. The default is 60 seconds, and a zero timeout keeps it. +func WithDownloadIdleTimeout(timeout time.Duration) Option { + return func(m *Manager) { + if timeout > 0 { + m.downloadIdleTimeout = timeout + } + } } // New creates a new version manager that reports progress to out and // warnings to errOut. gh returns the GitHub client, and the manager calls // it only when an operation needs GitHub. -func New(cfg *config.Config, gh func() (*github.Client, error), out, errOut io.Writer) *Manager { - return &Manager{ - config: cfg, - github: gh, - http: newDownloadClient(), - out: out, - errOut: errOut, +func New(cfg *config.Config, gh func() (*github.Client, error), out, errOut io.Writer, opts ...Option) *Manager { + m := &Manager{ + config: cfg, + github: gh, + http: newDownloadClient(), + downloadIdleTimeout: downloadIdleTimeout, + out: out, + errOut: errOut, + } + for _, opt := range opts { + opt(m) } + return m } // Develop is the version name that stands for the development binary. @@ -351,9 +375,17 @@ func (m *Manager) installBinary(assetName, url, releasedSum, binaryPath string) return nil } -// download writes the body of a GET of url to w. +// download writes the body of a GET of url to w. It fails when the body +// stalls: when a read waits longer than the download idle timeout for data. func (m *Manager) download(url string, w io.Writer) error { - resp, err := m.http.Get(url) + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := m.http.Do(req) if err != nil { return err } @@ -363,6 +395,28 @@ func (m *Manager) download(url string, w io.Writer) error { return fmt.Errorf("GET %s: %s", url, resp.Status) } - _, err = io.Copy(w, resp.Body) + seconds := strconv.FormatFloat(m.downloadIdleTimeout.Seconds(), 'f', -1, 64) + stalled := fmt.Errorf("download stalled: no data arrived for %s seconds", seconds) + timer := time.AfterFunc(m.downloadIdleTimeout, func() { cancel(stalled) }) + defer timer.Stop() + + _, err = io.Copy(w, &idleTimeoutReader{r: resp.Body, timeout: m.downloadIdleTimeout, timer: timer}) + if err != nil && errors.Is(context.Cause(ctx), stalled) { + return stalled + } return err } + +// idleTimeoutReader reads from r, and runs timer's function when a read +// waits longer than timeout for data. +type idleTimeoutReader struct { + r io.Reader + timeout time.Duration + timer *time.Timer +} + +func (r *idleTimeoutReader) Read(p []byte) (int, error) { + r.timer.Reset(r.timeout) + defer r.timer.Stop() + return r.r.Read(p) +} From fed1b95e9088d11971b745f33ac680daea76f4e0 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Tue, 27 Oct 2026 13:20:31 +0100 Subject: [PATCH 69/75] ci(release): check out the full history for the changelog GoReleaser's changelog needs the previous tag, which a shallow checkout of one commit does not have. --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 821fd27..96d1be4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,9 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.inputs.release_tag != '' && github.event.inputs.release_tag || github.ref_type == 'tag' && github.ref_name || github.sha }} + # GoReleaser's changelog needs the full history to find the previous + # tag. + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 From 64a81eb0bb660a5664f22b1725a55b735a9db88c Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Tue, 27 Oct 2026 13:20:55 +0100 Subject: [PATCH 70/75] docs(readme): say that a download fails when it stalls for 60 seconds --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 58ca79d..0df7c0b 100644 --- a/README.md +++ b/README.md @@ -214,8 +214,9 @@ renamed into place only once it is complete and verified. A failed or interrupted download leaves nothing behind, and a version that was already installed stays as it was. A download times out when the server takes more than 30 seconds to accept the connection, 10 seconds to -complete the TLS handshake or 30 seconds to start its response. Nothing -caps the transfer itself, so a slow download that keeps making progress +complete the TLS handshake or 30 seconds to start its response, and it +fails as stalled when no data arrives for 60 seconds. Nothing caps the +transfer as a whole, so a slow download that keeps making progress finishes. ### Switching versions From 0cc9f5af5219c10be309f668442720026f698529 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Tue, 27 Oct 2026 13:56:34 +0100 Subject: [PATCH 71/75] ci(release): fail a release when a signing or notary secret is empty GoReleaser signs and then skips notarization, still succeeding, when a notary secret is empty. The release workflow now checks the five macOS secrets first and fails, naming the missing ones, unless a manual run sets skip-signing. --- .github/workflows/release.yml | 23 +++++++++++++++++++++++ README.md | 17 +++++++++-------- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96d1be4..feeeeb2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,29 @@ jobs: # id-token: write steps: + # Every release signs and notarizes the macOS binaries unless a manual run + # sets skip-signing. Without this check, an empty notary secret would make + # GoReleaser sign the binaries, skip notarization and still succeed. + - name: Check the macOS signing and notarization secrets + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.skip-signing) }} + env: + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + run: | + missing="" + for name in MACOS_SIGN_P12 MACOS_SIGN_PASSWORD MACOS_NOTARY_KEY MACOS_NOTARY_KEY_ID MACOS_NOTARY_ISSUER_ID; do + if [ -z "${!name}" ]; then + missing="$missing $name" + fi + done + if [ -n "$missing" ]; then + echo "::error::These secrets are empty or missing:$missing. Set them, or run the workflow manually with skip-signing to release unsigned." + exit 1 + fi + - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/README.md b/README.md index 0df7c0b..3240132 100644 --- a/README.md +++ b/README.md @@ -478,14 +478,15 @@ repository secrets: | `MACOS_NOTARY_KEY_ID` | the ID of the `.p8` key | | `MACOS_NOTARY_ISSUER_ID` | the issuer UUID of the `.p8` key | -A release from a tag push always signs the macOS binaries. The run fails -before anything is published when `MACOS_SIGN_P12` is empty or missing, -when signing fails, or when the notarization submission fails. When any -of the three `MACOS_NOTARY_*` secrets is empty, GoReleaser signs the -binaries without submitting them for notarization. Only the manual -trigger's `skip-signing` input, described below, releases unsigned. A -snapshot build, such as `goreleaser release --snapshot --clean`, signs -only when `MACOS_SIGN_P12` is set, so it builds without the certificate. +Every release signs and notarizes the macOS binaries, except a manual run +with the `skip-signing` input described below. Its first step checks the +five secrets and fails, naming the ones that are empty or missing. The +run also fails when signing fails or when the notarization submission +fails. GoReleaser signs and notarizes before it creates the draft +release, so a failed run leaves the tag but no release behind, and a +manual run with `skip-signing` can release that tag. A snapshot build, +such as `goreleaser release --snapshot --clean`, signs only when +`MACOS_SIGN_P12` is set, so it builds without the certificate. ### Publishing the draft From a6c8e5f2868f4cade27cffc7afc36dd2f097ec32 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Tue, 27 Oct 2026 13:59:59 +0100 Subject: [PATCH 72/75] build(release): replace a draft release a failed run left behind A run that fails while uploading leaves a draft release, and GitHub's lookup by tag never finds drafts, so a rerun created a second draft for the same tag. GoReleaser now deletes a draft with the same name before creating the release. --- .goreleaser.yml | 6 ++++++ README.md | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 949a5e3..3a9926f 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -48,6 +48,12 @@ release: # We wait so that we don't make the release public until we have notarize email from Apple. draft: true + # Delete a draft release with the same name, the tag, before creating the + # release. A run that fails while uploading leaves a draft behind, and + # GitHub's lookup by tag never finds drafts, so without this a rerun would + # leave two drafts for the same tag. + replace_existing_draft: true + # What to do with the release notes in case there the release already exists. # # Valid options are: diff --git a/README.md b/README.md index 3240132..02ff6f8 100644 --- a/README.md +++ b/README.md @@ -484,7 +484,9 @@ five secrets and fails, naming the ones that are empty or missing. The run also fails when signing fails or when the notarization submission fails. GoReleaser signs and notarizes before it creates the draft release, so a failed run leaves the tag but no release behind, and a -manual run with `skip-signing` can release that tag. A snapshot build, +manual run with `skip-signing` can release that tag. A run that fails +later, while uploading, leaves a draft, which the next run for the same +tag deletes and creates again. A snapshot build, such as `goreleaser release --snapshot --clean`, signs only when `MACOS_SIGN_P12` is set, so it builds without the certificate. From 29f4d40e7d2d36e4e86dca39da01ff28727de362 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Tue, 27 Oct 2026 14:18:56 +0100 Subject: [PATCH 73/75] build(release): switch macOS signing with MACOS_SIGNING Signing and notarization were enabled by whether MACOS_SIGN_P12 was set, so a local build could not turn them on or off on demand, and goreleaser build cannot skip them. MACOS_SIGNING now switches them for release and build alike: a release signs unless it is false, and a snapshot signs only when it is true. --- .goreleaser.yml | 14 ++++++++------ README.md | 12 +++++++++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 3a9926f..cd46c90 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -113,15 +113,17 @@ notarize: macos: - # Whether this configuration is enabled or not. # - # A release always signs, so it fails when MACOS_SIGN_P12 is empty or - # missing, as well as when signing fails. Only --skip=notarize, which - # the release workflow's skip-signing input passes, releases unsigned. - # A snapshot build signs only when MACOS_SIGN_P12 is set, so that - # snapshots build without the certificate. + # MACOS_SIGNING switches signing and notarization, the same way for + # goreleaser release and goreleaser build. A release signs unless + # MACOS_SIGNING is false, so it fails when MACOS_SIGN_P12 is empty or + # missing, as well as when signing fails. A snapshot signs only when + # MACOS_SIGNING is true, so that snapshots build without the + # certificate. The release workflow sets MACOS_SIGNING from its + # skip-signing input. # # Default: false. # Templates: allowed. - enabled: '{{ or (not .IsSnapshot) (isEnvSet "MACOS_SIGN_P12") }}' + enabled: '{{ if .IsSnapshot }}{{ eq (tolower (trim (envOrDefault "MACOS_SIGNING" "false"))) "true" }}{{ else }}{{ ne (tolower (trim (envOrDefault "MACOS_SIGNING" "true"))) "false" }}{{ end }}' # Before notarizing, we need to sign the binary. # This block defines the configuration for doing so. diff --git a/README.md b/README.md index 02ff6f8..bd93d1a 100644 --- a/README.md +++ b/README.md @@ -486,9 +486,15 @@ fails. GoReleaser signs and notarizes before it creates the draft release, so a failed run leaves the tag but no release behind, and a manual run with `skip-signing` can release that tag. A run that fails later, while uploading, leaves a draft, which the next run for the same -tag deletes and creates again. A snapshot build, -such as `goreleaser release --snapshot --clean`, signs only when -`MACOS_SIGN_P12` is set, so it builds without the certificate. +tag deletes and creates again. + +The `MACOS_SIGNING` environment variable switches signing and +notarization, for `goreleaser release` and `goreleaser build` alike. A +release signs unless `MACOS_SIGNING` is `false`, and a snapshot signs +only when it is `true`. So `goreleaser release --snapshot --clean` builds +without the certificate, and +`MACOS_SIGNING=true goreleaser release --snapshot --clean` tests signing +with it. ### Publishing the draft From d32c210b418067505757d22f1ef464ffb8c35f94 Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Tue, 27 Oct 2026 14:24:32 +0100 Subject: [PATCH 74/75] ci(release): set MACOS_SIGNING from the skip-signing input The workflow sets MACOS_SIGNING once for the job, false for a manual run with skip-signing and true otherwise. The secrets check and .goreleaser.yml both read it, replacing the --skip=notarize argument. --- .github/workflows/release.yml | 18 +++++++++++------- README.md | 8 +++++--- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index feeeeb2..8e18371 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,11 @@ jobs: release: name: Create Release runs-on: ubuntu-latest + env: + # false for a manual run with skip-signing, true otherwise. The secrets + # check below and .goreleaser.yml both read it to decide whether to sign + # and notarize the macOS binaries. + MACOS_SIGNING: ${{ (github.event_name == 'workflow_dispatch' && inputs.skip-signing) && 'false' || 'true' }} permissions: contents: write # packages: write @@ -27,11 +32,11 @@ jobs: # id-token: write steps: - # Every release signs and notarizes the macOS binaries unless a manual run - # sets skip-signing. Without this check, an empty notary secret would make + # Every release signs and notarizes the macOS binaries unless MACOS_SIGNING + # is false. Without this check, an empty notary secret would make # GoReleaser sign the binaries, skip notarization and still succeed. - name: Check the macOS signing and notarization secrets - if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.skip-signing) }} + if: env.MACOS_SIGNING != 'false' env: MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} @@ -68,10 +73,9 @@ jobs: uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 with: version: v2.19.0 - # Only a manual run can skip signing. Every other release signs, and - # .goreleaser.yml makes it fail when MACOS_SIGN_P12 is empty or - # signing fails. - args: release --clean${{ github.event_name == 'workflow_dispatch' && inputs.skip-signing && ' --skip=notarize' || '' }} + # .goreleaser.yml signs and notarizes unless MACOS_SIGNING, set for the + # job above, is false. + args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The base64 of the contents of your '.p12' key. diff --git a/README.md b/README.md index bd93d1a..49cffab 100644 --- a/README.md +++ b/README.md @@ -494,7 +494,8 @@ release signs unless `MACOS_SIGNING` is `false`, and a snapshot signs only when it is `true`. So `goreleaser release --snapshot --clean` builds without the certificate, and `MACOS_SIGNING=true goreleaser release --snapshot --clean` tests signing -with it. +with it. The release workflow sets it to `false` for a manual run with +`skip-signing`, and to `true` for every other run. ### Publishing the draft @@ -514,8 +515,9 @@ ticked, or with the GitHub CLI: gh workflow run release.yml -f release_tag=v1.1.0 -f skip-signing=true ``` -GoReleaser then runs with `--skip=notarize`, and every platform ships, -the macOS binaries unsigned. No secret needs to change. There is no +The workflow then sets `MACOS_SIGNING=false` and skips the secrets +check, and every platform ships, the macOS binaries unsigned. No secret +or variable needs to change. There is no notarization email to wait for, so publish the draft once its notes are ready, and point macOS users who download in a browser to the quarantine note under [Installation](#macos-and-the-quarantine-attribute). Only the From 4aae11235fb0cca45b3053fd1f3b79e480b8ac3a Mon Sep 17 00:00:00 2001 From: Jorge Morales Pou Date: Tue, 27 Oct 2026 14:28:07 +0100 Subject: [PATCH 75/75] ci(release): let the MACOS_SIGNING repository variable turn signing off While the Apple membership or certificate is unavailable, setting the MACOS_SIGNING repository variable to false makes every run, tag pushes included, release without signing or notarizing the macOS binaries. Each such run adds a warning to its summary. --- .github/workflows/release.yml | 9 +++++++-- README.md | 22 ++++++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e18371..73dcedc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,10 +21,11 @@ jobs: name: Create Release runs-on: ubuntu-latest env: - # false for a manual run with skip-signing, true otherwise. The secrets + # false for a manual run with skip-signing, or for any run while the + # MACOS_SIGNING repository variable is false; true otherwise. The secrets # check below and .goreleaser.yml both read it to decide whether to sign # and notarize the macOS binaries. - MACOS_SIGNING: ${{ (github.event_name == 'workflow_dispatch' && inputs.skip-signing) && 'false' || 'true' }} + MACOS_SIGNING: ${{ ((github.event_name == 'workflow_dispatch' && inputs.skip-signing) || vars.MACOS_SIGNING == 'false') && 'false' || 'true' }} permissions: contents: write # packages: write @@ -35,6 +36,10 @@ jobs: # Every release signs and notarizes the macOS binaries unless MACOS_SIGNING # is false. Without this check, an empty notary secret would make # GoReleaser sign the binaries, skip notarization and still succeed. + - name: Report a release without macOS signing + if: env.MACOS_SIGNING == 'false' + run: echo "::warning::This release does not sign or notarize the macOS binaries, because the skip-signing input is set or the MACOS_SIGNING repository variable is false." + - name: Check the macOS signing and notarization secrets if: env.MACOS_SIGNING != 'false' env: diff --git a/README.md b/README.md index 49cffab..ee2ce16 100644 --- a/README.md +++ b/README.md @@ -479,7 +479,9 @@ repository secrets: | `MACOS_NOTARY_ISSUER_ID` | the issuer UUID of the `.p8` key | Every release signs and notarizes the macOS binaries, except a manual run -with the `skip-signing` input described below. Its first step checks the +with the `skip-signing` input and any run while the `MACOS_SIGNING` +repository variable is `false`, both described below. Its first step +checks the five secrets and fails, naming the ones that are empty or missing. The run also fails when signing fails or when the notarization submission fails. GoReleaser signs and notarizes before it creates the draft @@ -495,7 +497,8 @@ only when it is `true`. So `goreleaser release --snapshot --clean` builds without the certificate, and `MACOS_SIGNING=true goreleaser release --snapshot --clean` tests signing with it. The release workflow sets it to `false` for a manual run with -`skip-signing`, and to `true` for every other run. +`skip-signing` or while the `MACOS_SIGNING` repository variable is +`false`, and to `true` for every other run. ### Publishing the draft @@ -523,6 +526,21 @@ ready, and point macOS users who download in a browser to the quarantine note under [Installation](#macos-and-the-quarantine-attribute). Only the manual trigger offers `skip-signing`. +To leave signing off for every release, tag pushes included, until the +membership or the certificate is back, set the `MACOS_SIGNING` repository +variable to `false`: + +```sh +gh variable set MACOS_SIGNING --body false --repo educates/educatesenv +``` + +Every run then releases unsigned and adds a warning to its summary +saying so. Delete the variable to sign again: + +```sh +gh variable delete MACOS_SIGNING --repo educates/educatesenv +``` + ## Contributing Contributions are welcome. Before you open a pull request, run