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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 107 additions & 9 deletions cmd/arduino-app-cli/version/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this extra function is too much. I think it is ok to just move this code directly inside the Run function of the cobra command.


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)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you can just use url.Parse, but anyway I would parse the url right after getting the flag, e.g., host, _ := cmd.Flags().GetString("host"), so you can directly handle the error.
Then here you can just add the path with

url = url.Join("/v1/version")


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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably in this case, you aren't improving much, but usually it is better to make JSON handling the decoding buffer instead of reading the body into a buffer and then passing it to json library.

Suggested change
if err := json.Unmarshal(body, &serverResponse); err != nil {
var serverResponse struct {
Version string `json:"version"`
}
if err := json.NewDecoder(resp.Body).Decode(&serverResponse); err != nil {

return "", err
}

return serverResponse.Version, nil
}

type serverVersionResponse struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually structs used only for parsing JSON are defined directly inside the function that uses them

Version string `json:"version"`
}

type versionResult struct {
ClientVersion string `json:"version"`
ServerVersion string `json:"serverVersion"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would probably return those info

Suggested change
ClientVersion string `json:"version"`
ServerVersion string `json:"serverVersion"`
AppName string `json:"app_name"`
Version string `json:"version"`
DaemonVersion string `json:"daemon_version"`

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could print the name with the arduino-flasher-cli output https://github.com/arduino/arduino-flasher-cli/blob/1899741a4e890a2ff0727669d23d25b6499e0522/main.go#L63

Arduino App CLI 

BUT I would avoid theapp_name that can be misleading (the term "app" is for the Arduino Apps).

maybe only "name" in the json field.

}

func (r versionResult) String() string {
return fmt.Sprintf("%s v%s", r.AppName, r.Version)
return fmt.Sprintf("client: %s\nserver: %s",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering if we should make a more text-friendly line like

Arduino App CLI v0.6.6
daemon server at "localhost:8800" v0.6.6

r.ClientVersion, r.ServerVersion)
}

func (r versionResult) Data() interface{} {
Expand Down
115 changes: 115 additions & 0 deletions cmd/arduino-app-cli/version/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// 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"
"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
})