diff --git a/cmd/arduino-app-cli/version/version.go b/cmd/arduino-app-cli/version/version.go index 86ed7b3c..1cb06d05 100644 --- a/cmd/arduino-app-cli/version/version.go +++ b/cmd/arduino-app-cli/version/version.go @@ -16,34 +16,95 @@ package version import ( + "encoding/json" "fmt" + "net" + "net/http" + "net/url" + "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", Run: func(cmd *cobra.Command, args []string) { - feedback.PrintResult(versionResult{ - AppName: "Arduino App CLI", - Version: version, - }) + port, _ := cmd.Flags().GetString("port") + + daemonVersion, err := getDaemonVersion(http.Client{}, port) + if err != nil { + feedback.Warnf("Warning: cannot get the running daemon version on %s:%s\n", DefaultHostname, port) + } + + result := versionResult{ + Name: ProgramName, + Version: clientVersion, + DaemonVersion: daemonVersion, + } + + feedback.PrintResult(result) }, } + cmd.Flags().String("port", DefaultPort, "The daemon network port") return cmd } +func getDaemonVersion(httpClient http.Client, port string) (string, error) { + + httpClient.Timeout = time.Second + + url := url.URL{ + Scheme: "http", + Host: net.JoinHostPort(DefaultHostname, port), + Path: "/v1/version", + } + + resp, err := httpClient.Get(url.String()) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code received") + } + + var daemonResponse struct { + Version string `json:"version"` + } + if err := json.NewDecoder(resp.Body).Decode(&daemonResponse); err != nil { + return "", err + } + + return daemonResponse.Version, nil +} + type versionResult struct { - AppName string `json:"appName"` - Version string `json:"version"` + Name string `json:"name"` + Version string `json:"version"` + DaemonVersion string `json:"daemon_version,omitempty"` } func (r versionResult) String() string { - return fmt.Sprintf("%s v%s", r.AppName, r.Version) + resultMessage := fmt.Sprintf("%s version %s", ProgramName, r.Version) + + if r.DaemonVersion != "" { + resultMessage = fmt.Sprintf("%s\ndaemon version: %s", + resultMessage, r.DaemonVersion) + } + return resultMessage } 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..39617968 --- /dev/null +++ b/cmd/arduino-app-cli/version/version_test.go @@ -0,0 +1,125 @@ +// 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 TestDaemonVersion(t *testing.T) { + testCases := []struct { + name string + serverStub Tripper + port string + expectedResult string + expectedErrorMessage string + }{ + { + name: "return the server version when the server is up", + serverStub: successServer, + port: "8800", + expectedResult: "3.0-server", + expectedErrorMessage: "", + }, + { + name: "return error if default server is not listening on default port", + serverStub: failureServer, + port: "8800", + expectedResult: "", + expectedErrorMessage: `Get "http://localhost:8800/v1/version": connection refused`, + }, + { + name: "return error if provided server is not listening on provided port", + serverStub: failureServer, + port: "1234", + expectedResult: "", + expectedErrorMessage: `Get "http://localhost:1234/v1/version": connection refused`, + }, + { + name: "return error for server response 500 Internal Server Error", + serverStub: failureInternalServerError, + port: "0", + expectedResult: "", + expectedErrorMessage: "unexpected status code received", + }, + + { + name: "return error for server up and wrong json response", + serverStub: successServerWrongJson, + port: "8800", + expectedResult: "", + expectedErrorMessage: "invalid character '<' looking for beginning of value", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // arrange + httpClient := http.Client{} + httpClient.Transport = tc.serverStub + + // act + result, err := getDaemonVersion(httpClient, tc.port) + + // assert + require.Equal(t, tc.expectedResult, result) + if err != nil { + require.Equal(t, tc.expectedErrorMessage, err.Error()) + } + }) + } +} + +// 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-server"}`)) + return &http.Response{ + StatusCode: http.StatusOK, + Body: body, + }, nil +}) + +var successServerWrongJson = Tripper(func(*http.Request) (*http.Response, error) { + body := io.NopCloser(strings.NewReader(`