diff --git a/cmd/arduino-app-cli/version/version.go b/cmd/arduino-app-cli/version/version.go index 86ed7b3c..5619ebca 100644 --- a/cmd/arduino-app-cli/version/version.go +++ b/cmd/arduino-app-cli/version/version.go @@ -16,34 +16,131 @@ package version import ( + "encoding/json" "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" "github.com/spf13/cobra" "github.com/arduino/arduino-app-cli/cmd/feedback" ) -func NewVersionCmd(version string) *cobra.Command { +// The actual listening address for the daemon +// is defined in the installation package +const ( + DefaultHostname = "localhost" + DefaultPort = "8800" + ProgramName = "Arduino App CLI" +) + +func NewVersionCmd(clientVersion string) *cobra.Command { cmd := &cobra.Command{ Use: "version", - Short: "Print the version number of Arduino App CLI", + Short: "Print the client and server version numbers for the Arduino App CLI.", Run: func(cmd *cobra.Command, args []string) { - feedback.PrintResult(versionResult{ - AppName: "Arduino App CLI", - Version: version, - }) + host, _ := cmd.Flags().GetString("host") + + validatedHostAndPort, err := validateHost(host) + if err != nil { + feedback.Fatal("Error: invalid host:port format", feedback.ErrBadArgument) + } + + httpClient := http.Client{ + Timeout: time.Second, + } + + result, err := versionHandler(httpClient, clientVersion, validatedHostAndPort) + if err != nil { + feedback.Warnf("Waning: " + err.Error() + "\n") + } + feedback.PrintResult(result) }, } + cmd.Flags().String("host", fmt.Sprintf("%s:%s", DefaultHostname, DefaultPort), + "The daemon network address [host]:[port]") return cmd } +func versionHandler(httpClient http.Client, clientVersion string, hostAndPort string) (versionResult, error) { + url := url.URL{ + Scheme: "http", + Host: hostAndPort, + Path: "/v1/version", + } + + daemonVersion := getServerVersion(httpClient, url.String()) + + result := versionResult{ + Name: ProgramName, + ClientVersion: clientVersion, + DaemonVersion: daemonVersion, + } + + if daemonVersion == "" { + return result, fmt.Errorf("cannot connect to %s", hostAndPort) + } + return result, nil +} + +func validateHost(hostPort string) (string, error) { + if !strings.Contains(hostPort, ":") { + hostPort = hostPort + ":" + } + + h, p, err := net.SplitHostPort(hostPort) + if err != nil { + return "", err + } + if h == "" { + h = DefaultHostname + } + if p == "" { + p = DefaultPort + } + + return net.JoinHostPort(h, p), nil +} + +func getServerVersion(httpClient http.Client, url string) string { + resp, err := httpClient.Get(url) + if err != nil { + return "" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "" + } + + var serverResponse struct { + Version string `json:"version"` + } + if err := json.NewDecoder(resp.Body).Decode(&serverResponse); err != nil { + return "" + } + + return serverResponse.Version +} + type versionResult struct { - AppName string `json:"appName"` - Version string `json:"version"` + Name string `json:"name"` + ClientVersion string `json:"client_version"` + DaemonVersion string `json:"daemon_version,omitempty"` } func (r versionResult) String() string { - return fmt.Sprintf("%s v%s", r.AppName, r.Version) + serverMessage := fmt.Sprintf("%s client version %s", + ProgramName, r.ClientVersion) + + if r.DaemonVersion != "" { + serverMessage = fmt.Sprintf("%s\ndaemon version: %s", + serverMessage, r.DaemonVersion) + } + return serverMessage } func (r versionResult) Data() interface{} { diff --git a/cmd/arduino-app-cli/version/version_test.go b/cmd/arduino-app-cli/version/version_test.go new file mode 100644 index 00000000..cd796e54 --- /dev/null +++ b/cmd/arduino-app-cli/version/version_test.go @@ -0,0 +1,166 @@ +// This file is part of arduino-app-cli. +// +// Copyright 2025 ARDUINO SA (http://www.arduino.cc/) +// +// This software is released under the GNU General Public License version 3, +// which covers the main part of arduino-app-cli. +// The terms of this license can be found at: +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// You can be released from the requirements of the above licenses by purchasing +// a commercial license. Buying such a license is mandatory if you want to +// modify or otherwise use the software for commercial activities involving the +// Arduino software without disclosing the source code of your own applications. +// To purchase a commercial license, send an email to license@arduino.cc. + +package version + +import ( + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetValidUrl(t *testing.T) { + testCases := []struct { + name string + hostPort string + expectedResult string + }{ + { + name: "Valid host and port should return default.", + hostPort: "localhost:8800", + expectedResult: "localhost:8800", + }, + { + name: "Missing host should return default host.", + hostPort: ":8800", + expectedResult: "localhost:8800", + }, + { + name: "Missing port should return default port.", + hostPort: "localhost:", + expectedResult: "localhost:8800", + }, + { + name: "Custom host and port should return the default.", + hostPort: "192.168.100.1:1234", + expectedResult: "192.168.100.1:1234", + }, + { + name: "Host only should return provided input and default port.", + hostPort: "192.168.1.1", + expectedResult: "192.168.1.1:8800", + }, + { + name: "Missing host and port should return default.", + hostPort: "", + expectedResult: "localhost:8800", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + url, _ := validateHost(tc.hostPort) + require.Equal(t, tc.expectedResult, url) + }) + } +} + +func TestServerVersion(t *testing.T) { + clientVersion := "5.1-dev" + unreacheableUrl := "unreacheable:123" + daemonVersion := "" + + testCases := []struct { + name string + serverStub Tripper + expectedResult versionResult + hostAndPort string + }{ + { + name: "return the server version when the server is up", + serverStub: successServer, + expectedResult: versionResult{ + Name: ProgramName, + ClientVersion: clientVersion, + DaemonVersion: "3.0", + }, + hostAndPort: "localhost:8800", + }, + { + name: "return error if default server is not listening", + serverStub: failureServer, + expectedResult: versionResult{ + Name: ProgramName, + ClientVersion: clientVersion, + DaemonVersion: daemonVersion, + }, + hostAndPort: unreacheableUrl, + }, + { + name: "return error if provided server is not listening", + serverStub: failureServer, + expectedResult: versionResult{ + Name: ProgramName, + ClientVersion: clientVersion, + DaemonVersion: daemonVersion, + }, + hostAndPort: unreacheableUrl, + }, + { + name: "return error for server resopnse 500 Internal Server Error", + serverStub: failureInternalServerError, + expectedResult: versionResult{ + Name: ProgramName, + ClientVersion: clientVersion, + DaemonVersion: daemonVersion, + }, + hostAndPort: unreacheableUrl, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // arrange + httpClient := http.Client{} + httpClient.Transport = tc.serverStub + + // act + result, _ := versionHandler(httpClient, clientVersion, tc.hostAndPort) + + // assert + require.Equal(t, tc.expectedResult, result) + }) + } +} + +// Leverage the http.Client's RoundTripper +// to return a canned response and bypass network calls. +type Tripper func(*http.Request) (*http.Response, error) + +func (t Tripper) RoundTrip(request *http.Request) (*http.Response, error) { + return t(request) +} + +var successServer = Tripper(func(*http.Request) (*http.Response, error) { + body := io.NopCloser(strings.NewReader(`{"version":"3.0"}`)) + return &http.Response{ + StatusCode: http.StatusOK, + Body: body, + }, nil +}) + +var failureServer = Tripper(func(*http.Request) (*http.Response, error) { + return nil, errors.New("connetion refused") +}) + +var failureInternalServerError = Tripper(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader("")), + }, nil +})