From fd939c58446fab70a91d226bfa6dbb28f149f114 Mon Sep 17 00:00:00 2001 From: Marta Carbone Date: Thu, 6 Nov 2025 09:31:52 +0100 Subject: [PATCH 1/3] Add the server version to arduino-app-cli version. --- cmd/arduino-app-cli/version/version.go | 116 ++++++++++++++++++-- cmd/arduino-app-cli/version/version_test.go | 100 +++++++++++++++++ 2 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 cmd/arduino-app-cli/version/version_test.go diff --git a/cmd/arduino-app-cli/version/version.go b/cmd/arduino-app-cli/version/version.go index 86ed7b3c..384c2e29 100644 --- a/cmd/arduino-app-cli/version/version.go +++ b/cmd/arduino-app-cli/version/version.go @@ -16,34 +16,132 @@ package version import ( + "encoding/json" "fmt" + "io" + "net" + "net/http" + "net/url" + "time" "github.com/spf13/cobra" "github.com/arduino/arduino-app-cli/cmd/feedback" + "github.com/arduino/arduino-app-cli/cmd/i18n" ) -func NewVersionCmd(version string) *cobra.Command { +// The actual listening address for the daemon +// is defined in the installation package +const ( + DefaultHostname = "localhost" + DefaultPort = "8800" +) + +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") + + versionHandler(clientVersion, host) }, } + cmd.Flags().String("host", fmt.Sprintf("%s:%s", DefaultHostname, DefaultPort), + "The daemon network address [host]:[port]") return cmd } -type versionResult struct { - AppName string `json:"appName"` +func versionHandler(clientVersion string, host string) { + httpClient := http.Client{ + Timeout: time.Second, + } + result := doVersionHandler(httpClient, clientVersion, host) + feedback.PrintResult(result) +} + +func doVersionHandler(httpClient http.Client, clientVersion string, host string) versionResult { + url, err := getValidOrDefaultUrl(host) + if err != nil { + feedback.Fatal(i18n.Tr("Error: invalid host:port format"), feedback.ErrBadArgument) + } + + serverVersion, err := getServerVersion(httpClient, url) + if err != nil { + serverVersion = fmt.Sprintf("n/a (cannot connect to the server %s://%s)", url.Scheme, url.Host) + } + + return versionResult{ + ClientVersion: clientVersion, + ServerVersion: serverVersion, + } +} + +func getValidOrDefaultUrl(hostPort string) (url.URL, error) { + host := DefaultHostname + port := DefaultPort + + if hostPort != "" { + h, p, err := net.SplitHostPort(hostPort) + if err != nil { + return url.URL{}, err + } + if h != "" { + host = h + } + if p != "" { + port = p + } + + } + + hostAndPort := net.JoinHostPort(host, port) + + u := url.URL{ + Scheme: "http", + Host: hostAndPort, + Path: "/v1/version", + } + + return u, nil +} + +func getServerVersion(httpClient http.Client, url url.URL) (string, error) { + resp, err := httpClient.Get(url.String()) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("request failed with status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var serverResponse serverVersionResponse + if err := json.Unmarshal(body, &serverResponse); err != nil { + return "", err + } + + return serverResponse.Version, nil +} + +type serverVersionResponse struct { Version string `json:"version"` } +type versionResult struct { + ClientVersion string `json:"version"` + ServerVersion string `json:"serverVersion"` +} + func (r versionResult) String() string { - return fmt.Sprintf("%s v%s", r.AppName, r.Version) + return fmt.Sprintf("client: %s\nserver: %s", + r.ClientVersion, r.ServerVersion) } 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..d0db5bef --- /dev/null +++ b/cmd/arduino-app-cli/version/version_test.go @@ -0,0 +1,100 @@ +package version + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestServerVersion(t *testing.T) { + clientVersion := "5.1-dev" + + testCases := []struct { + name string + serverStub Tripper + expectedResult versionResult + host string + }{ + { + name: "return the server version when the server is up", + serverStub: successServer, + expectedResult: versionResult{ + ClientVersion: "5.1-dev", + ServerVersion: "3.0", + }, + host: "", + }, + { + name: "return error if default server is not listening", + serverStub: failureServer, + expectedResult: versionResult{ + ClientVersion: "5.1-dev", + ServerVersion: fmt.Sprintf("n/a (cannot connect to the server http://%s:%s)", DefaultHostname, DefaultPort), + }, + host: "", + }, + { + name: "return error if provided server is not listening", + serverStub: failureServer, + expectedResult: versionResult{ + ClientVersion: "5.1-dev", + ServerVersion: "n/a (cannot connect to the server http://unreacheable:123)", + }, + host: "unreacheable:123", + }, + { + name: "return error for server resopnse 500 Internal Server Error", + serverStub: failureInternalServerError, + expectedResult: versionResult{ + ClientVersion: "5.1-dev", + ServerVersion: "n/a (cannot connect to the server http://unreacheable:123)", + }, + host: "unreacheable:123", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // arrange + httpClient := http.Client{} + httpClient.Transport = tc.serverStub + + // act + result := doVersionHandler(httpClient, clientVersion, tc.host) + + // 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 +}) From 466245c5f1b94f541675e25f2d89b4b3c872fb13 Mon Sep 17 00:00:00 2001 From: Marta Carbone Date: Thu, 6 Nov 2025 08:40:49 +0100 Subject: [PATCH 2/3] Add copyright header. --- cmd/arduino-app-cli/version/version_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cmd/arduino-app-cli/version/version_test.go b/cmd/arduino-app-cli/version/version_test.go index d0db5bef..0db21fdf 100644 --- a/cmd/arduino-app-cli/version/version_test.go +++ b/cmd/arduino-app-cli/version/version_test.go @@ -1,3 +1,18 @@ +// 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 ( From bf435c6e3d8450ac05343f520b465e5c2ae7f20e Mon Sep 17 00:00:00 2001 From: Marta Carbone Date: Fri, 7 Nov 2025 18:19:16 +0100 Subject: [PATCH 3/3] Address review comments. --- cmd/arduino-app-cli/version/version.go | 125 ++++++++++---------- cmd/arduino-app-cli/version/version_test.go | 81 ++++++++++--- 2 files changed, 128 insertions(+), 78 deletions(-) diff --git a/cmd/arduino-app-cli/version/version.go b/cmd/arduino-app-cli/version/version.go index 384c2e29..5619ebca 100644 --- a/cmd/arduino-app-cli/version/version.go +++ b/cmd/arduino-app-cli/version/version.go @@ -18,16 +18,15 @@ package version import ( "encoding/json" "fmt" - "io" "net" "net/http" "net/url" + "strings" "time" "github.com/spf13/cobra" "github.com/arduino/arduino-app-cli/cmd/feedback" - "github.com/arduino/arduino-app-cli/cmd/i18n" ) // The actual listening address for the daemon @@ -35,6 +34,7 @@ import ( const ( DefaultHostname = "localhost" DefaultPort = "8800" + ProgramName = "Arduino App CLI" ) func NewVersionCmd(clientVersion string) *cobra.Command { @@ -44,7 +44,20 @@ func NewVersionCmd(clientVersion string) *cobra.Command { Run: func(cmd *cobra.Command, args []string) { host, _ := cmd.Flags().GetString("host") - versionHandler(clientVersion, 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), @@ -52,96 +65,82 @@ func NewVersionCmd(clientVersion string) *cobra.Command { return cmd } -func versionHandler(clientVersion string, host string) { - httpClient := http.Client{ - Timeout: time.Second, +func versionHandler(httpClient http.Client, clientVersion string, hostAndPort string) (versionResult, error) { + url := url.URL{ + Scheme: "http", + Host: hostAndPort, + Path: "/v1/version", } - result := doVersionHandler(httpClient, clientVersion, host) - feedback.PrintResult(result) -} -func doVersionHandler(httpClient http.Client, clientVersion string, host string) versionResult { - url, err := getValidOrDefaultUrl(host) - if err != nil { - feedback.Fatal(i18n.Tr("Error: invalid host:port format"), feedback.ErrBadArgument) - } + daemonVersion := getServerVersion(httpClient, url.String()) - serverVersion, err := getServerVersion(httpClient, url) - if err != nil { - serverVersion = fmt.Sprintf("n/a (cannot connect to the server %s://%s)", url.Scheme, url.Host) + result := versionResult{ + Name: ProgramName, + ClientVersion: clientVersion, + DaemonVersion: daemonVersion, } - return versionResult{ - ClientVersion: clientVersion, - ServerVersion: serverVersion, + if daemonVersion == "" { + return result, fmt.Errorf("cannot connect to %s", hostAndPort) } + return result, nil } -func getValidOrDefaultUrl(hostPort string) (url.URL, error) { - host := DefaultHostname - port := DefaultPort - - if hostPort != "" { - h, p, err := net.SplitHostPort(hostPort) - if err != nil { - return url.URL{}, err - } - if h != "" { - host = h - } - if p != "" { - port = p - } - +func validateHost(hostPort string) (string, error) { + if !strings.Contains(hostPort, ":") { + hostPort = hostPort + ":" } - hostAndPort := net.JoinHostPort(host, port) - - u := url.URL{ - Scheme: "http", - Host: hostAndPort, - Path: "/v1/version", + h, p, err := net.SplitHostPort(hostPort) + if err != nil { + return "", err + } + if h == "" { + h = DefaultHostname + } + if p == "" { + p = DefaultPort } - return u, nil + return net.JoinHostPort(h, p), nil } -func getServerVersion(httpClient http.Client, url url.URL) (string, error) { - resp, err := httpClient.Get(url.String()) +func getServerVersion(httpClient http.Client, url string) string { + resp, err := httpClient.Get(url) if err != nil { - return "", err + return "" } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("request failed with status %d", resp.StatusCode) + return "" } - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err + var serverResponse struct { + Version string `json:"version"` } - - var serverResponse serverVersionResponse - if err := json.Unmarshal(body, &serverResponse); err != nil { - return "", err + if err := json.NewDecoder(resp.Body).Decode(&serverResponse); err != nil { + return "" } - return serverResponse.Version, nil -} - -type serverVersionResponse struct { - Version string `json:"version"` + return serverResponse.Version } type versionResult struct { - ClientVersion string `json:"version"` - ServerVersion string `json:"serverVersion"` + Name string `json:"name"` + ClientVersion string `json:"client_version"` + DaemonVersion string `json:"daemon_version,omitempty"` } func (r versionResult) String() string { - return fmt.Sprintf("client: %s\nserver: %s", - r.ClientVersion, r.ServerVersion) + 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 index 0db21fdf..cd796e54 100644 --- a/cmd/arduino-app-cli/version/version_test.go +++ b/cmd/arduino-app-cli/version/version_test.go @@ -17,7 +17,6 @@ package version import ( "errors" - "fmt" "io" "net/http" "strings" @@ -26,50 +25,102 @@ import ( "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 - host string + hostAndPort string }{ { name: "return the server version when the server is up", serverStub: successServer, expectedResult: versionResult{ - ClientVersion: "5.1-dev", - ServerVersion: "3.0", + Name: ProgramName, + ClientVersion: clientVersion, + DaemonVersion: "3.0", }, - host: "", + hostAndPort: "localhost:8800", }, { name: "return error if default server is not listening", serverStub: failureServer, expectedResult: versionResult{ - ClientVersion: "5.1-dev", - ServerVersion: fmt.Sprintf("n/a (cannot connect to the server http://%s:%s)", DefaultHostname, DefaultPort), + Name: ProgramName, + ClientVersion: clientVersion, + DaemonVersion: daemonVersion, }, - host: "", + hostAndPort: unreacheableUrl, }, { name: "return error if provided server is not listening", serverStub: failureServer, expectedResult: versionResult{ - ClientVersion: "5.1-dev", - ServerVersion: "n/a (cannot connect to the server http://unreacheable:123)", + Name: ProgramName, + ClientVersion: clientVersion, + DaemonVersion: daemonVersion, }, - host: "unreacheable:123", + hostAndPort: unreacheableUrl, }, { name: "return error for server resopnse 500 Internal Server Error", serverStub: failureInternalServerError, expectedResult: versionResult{ - ClientVersion: "5.1-dev", - ServerVersion: "n/a (cannot connect to the server http://unreacheable:123)", + Name: ProgramName, + ClientVersion: clientVersion, + DaemonVersion: daemonVersion, }, - host: "unreacheable:123", + hostAndPort: unreacheableUrl, }, } for _, tc := range testCases { @@ -79,7 +130,7 @@ func TestServerVersion(t *testing.T) { httpClient.Transport = tc.serverStub // act - result := doVersionHandler(httpClient, clientVersion, tc.host) + result, _ := versionHandler(httpClient, clientVersion, tc.hostAndPort) // assert require.Equal(t, tc.expectedResult, result)