From 0a87ca2bdac69e917e362e98e16aa90122573a1e Mon Sep 17 00:00:00 2001
From: MatteoPologruto <m.pologruto@arduino.cc>
Date: Wed, 15 May 2024 16:33:10 +0200
Subject: [PATCH 1/5] Wrap v2 tools install function inside tools.Download

---
 tools/download.go | 88 ++++++-----------------------------------------
 1 file changed, 11 insertions(+), 77 deletions(-)

diff --git a/tools/download.go b/tools/download.go
index 360d6e4c..277bdf2d 100644
--- a/tools/download.go
+++ b/tools/download.go
@@ -16,24 +16,18 @@
 package tools
 
 import (
-	"bytes"
 	"context"
-	"crypto/sha256"
-	"encoding/hex"
 	"encoding/json"
 	"errors"
-	"fmt"
-	"io"
-	"net/http"
 	"os"
 	"os/exec"
 	"path/filepath"
 	"runtime"
 
+	"github.com/arduino/arduino-create-agent/gen/tools"
+	"github.com/arduino/arduino-create-agent/utilities"
 	"github.com/arduino/arduino-create-agent/v2/pkgs"
-	"github.com/arduino/go-paths-helper"
 	"github.com/blang/semver"
-	"github.com/codeclysm/extract/v3"
 )
 
 // public vars to allow override in the tests
@@ -99,68 +93,21 @@ func (t *Tools) Download(pack, name, version, behaviour string) error {
 		}
 	}
 
-	// Download the tool
-	t.logger("Downloading tool " + name + " from " + correctSystem.URL)
-	resp, err := http.Get(correctSystem.URL)
+	tool := pkgs.New(t.index, t.directory.String())
+	_, err = tool.Install(context.Background(), &tools.ToolPayload{Name: correctTool.Name, Version: correctTool.Version, Packager: pack})
 	if err != nil {
 		return err
 	}
-	defer resp.Body.Close()
 
-	// Read the body
-	body, err = io.ReadAll(resp.Body)
+	path := filepath.Join(pack, correctTool.Name, correctTool.Version)
+	safePath, err := utilities.SafeJoin(t.directory.String(), path)
 	if err != nil {
 		return err
 	}
 
-	// Checksum
-	checksum := sha256.Sum256(body)
-	checkSumString := "SHA-256:" + hex.EncodeToString(checksum[:sha256.Size])
-
-	if checkSumString != correctSystem.Checksum {
-		return errors.New("checksum doesn't match")
-	}
-
-	tempPath := paths.TempDir()
-	// Create a temporary dir to extract package
-	if err := tempPath.MkdirAll(); err != nil {
-		return fmt.Errorf("creating temp dir for extraction: %s", err)
-	}
-	tempDir, err := tempPath.MkTempDir("package-")
-	if err != nil {
-		return fmt.Errorf("creating temp dir for extraction: %s", err)
-	}
-	defer tempDir.RemoveAll()
-
-	t.logger("Unpacking tool " + name)
-	ctx := context.Background()
-	reader := bytes.NewReader(body)
-	// Extract into temp directory
-	if err := extract.Archive(ctx, reader, tempDir.String(), nil); err != nil {
-		return fmt.Errorf("extracting archive: %s", err)
-	}
-
-	location := t.directory.Join(pack, correctTool.Name, correctTool.Version)
-	err = location.RemoveAll()
-	if err != nil {
-		return err
-	}
-
-	// Check package content and find package root dir
-	root, err := findPackageRoot(tempDir)
-	if err != nil {
-		return fmt.Errorf("searching package root dir: %s", err)
-	}
-
-	if err := root.Rename(location); err != nil {
-		if err := root.CopyDirTo(location); err != nil {
-			return fmt.Errorf("moving extracted archive to destination dir: %s", err)
-		}
-	}
-
 	// if the tool contains a post_install script, run it: it means it is a tool that needs to install drivers
 	// AFAIK this is only the case for the windows-driver tool
-	err = t.installDrivers(location.String())
+	err = t.installDrivers(safePath)
 	if err != nil {
 		return err
 	}
@@ -169,25 +116,12 @@ func (t *Tools) Download(pack, name, version, behaviour string) error {
 	t.logger("Ensure that the files are executable")
 
 	// Update the tool map
-	t.logger("Updating map with location " + location.String())
+	t.logger("Updating map with location " + safePath)
 
-	t.setMapValue(name, location.String())
-	t.setMapValue(name+"-"+correctTool.Version, location.String())
-	return t.writeMap()
-}
-
-func findPackageRoot(parent *paths.Path) (*paths.Path, error) {
-	files, err := parent.ReadDir()
-	if err != nil {
-		return nil, fmt.Errorf("reading package root dir: %s", err)
-	}
-	files.FilterOutPrefix("__MACOSX")
+	t.setMapValue(name, safePath)
+	t.setMapValue(name+"-"+version, safePath)
 
-	// if there is only one dir, it is the root dir
-	if len(files) == 1 && files[0].IsDir() {
-		return files[0], nil
-	}
-	return parent, nil
+	return nil
 }
 
 func findTool(pack, name, version string, data pkgs.Index) (pkgs.Tool, pkgs.System) {

From ed8eac622f17d003ad6246f6cbf547d49e76f729 Mon Sep 17 00:00:00 2001
From: MatteoPologruto <m.pologruto@arduino.cc>
Date: Wed, 22 May 2024 14:12:54 +0200
Subject: [PATCH 2/5] Download tools defaulting to the replace behaviour

---
 tools/download.go | 47 ++++---------------------------------------
 tools/tools.go    | 12 -----------
 v2/pkgs/tools.go  | 51 ++++++++++++++++++++++++++++++++++-------------
 3 files changed, 41 insertions(+), 69 deletions(-)

diff --git a/tools/download.go b/tools/download.go
index 277bdf2d..95615446 100644
--- a/tools/download.go
+++ b/tools/download.go
@@ -17,7 +17,6 @@ package tools
 
 import (
 	"context"
-	"encoding/json"
 	"errors"
 	"os"
 	"os/exec"
@@ -36,17 +35,6 @@ var (
 	Arch = runtime.GOARCH
 )
 
-func pathExists(path string) bool {
-	_, err := os.Stat(path)
-	if err == nil {
-		return true
-	}
-	if os.IsNotExist(err) {
-		return false
-	}
-	return true
-}
-
 // Download will parse the index at the indexURL for the tool to download.
 // It will extract it in a folder in .arduino-create, and it will update the
 // Installed map.
@@ -62,44 +50,17 @@ func pathExists(path string) bool {
 // If version is not "latest" and behaviour is "replace", it will download the
 // version again. If instead behaviour is "keep" it will not download the version
 // if it already exists.
+//
+// At the moment the value of behaviour is ignored.
 func (t *Tools) Download(pack, name, version, behaviour string) error {
 
-	body, err := t.index.Read()
-	if err != nil {
-		return err
-	}
-
-	var data pkgs.Index
-	json.Unmarshal(body, &data)
-
-	// Find the tool by name
-	correctTool, correctSystem := findTool(pack, name, version, data)
-
-	if correctTool.Name == "" || correctSystem.URL == "" {
-		t.logger("We couldn't find a tool with the name " + name + " and version " + version + " packaged by " + pack)
-		return nil
-	}
-
-	key := correctTool.Name + "-" + correctTool.Version
-
-	// Check if it already exists
-	if behaviour == "keep" {
-		location, ok := t.getMapValue(key)
-		if ok && pathExists(location) {
-			// overwrite the default tool with this one
-			t.setMapValue(correctTool.Name, location)
-			t.logger("The tool is already present on the system")
-			return t.writeMap()
-		}
-	}
-
 	tool := pkgs.New(t.index, t.directory.String())
-	_, err = tool.Install(context.Background(), &tools.ToolPayload{Name: correctTool.Name, Version: correctTool.Version, Packager: pack})
+	_, err := tool.Install(context.Background(), &tools.ToolPayload{Name: name, Version: version, Packager: pack})
 	if err != nil {
 		return err
 	}
 
-	path := filepath.Join(pack, correctTool.Name, correctTool.Version)
+	path := filepath.Join(pack, name, version)
 	safePath, err := utilities.SafeJoin(t.directory.String(), path)
 	if err != nil {
 		return err
diff --git a/tools/tools.go b/tools/tools.go
index e641db35..cb9efc78 100644
--- a/tools/tools.go
+++ b/tools/tools.go
@@ -78,18 +78,6 @@ func (t *Tools) getMapValue(key string) (string, bool) {
 	return value, ok
 }
 
-// writeMap() writes installed map to the json file "installed.json"
-func (t *Tools) writeMap() error {
-	t.mutex.RLock()
-	defer t.mutex.RUnlock()
-	b, err := json.Marshal(t.installed)
-	if err != nil {
-		return err
-	}
-	filePath := t.directory.Join("installed.json")
-	return filePath.WriteFile(b)
-}
-
 // readMap() reads the installed map from json file "installed.json"
 func (t *Tools) readMap() error {
 	t.mutex.Lock()
diff --git a/v2/pkgs/tools.go b/v2/pkgs/tools.go
index 55ff6c2e..7510f638 100644
--- a/v2/pkgs/tools.go
+++ b/v2/pkgs/tools.go
@@ -33,6 +33,7 @@ import (
 	"github.com/arduino/arduino-create-agent/gen/tools"
 	"github.com/arduino/arduino-create-agent/index"
 	"github.com/arduino/arduino-create-agent/utilities"
+	"github.com/blang/semver"
 	"github.com/codeclysm/extract/v3"
 )
 
@@ -166,20 +167,9 @@ func (t *Tools) Install(ctx context.Context, payload *tools.ToolPayload) (*tools
 	var index Index
 	json.Unmarshal(body, &index)
 
-	for _, packager := range index.Packages {
-		if packager.Name != payload.Packager {
-			continue
-		}
-
-		for _, tool := range packager.Tools {
-			if tool.Name == payload.Name &&
-				tool.Version == payload.Version {
-
-				sys := tool.GetFlavourCompatibleWith(runtime.GOOS, runtime.GOARCH)
-
-				return t.install(ctx, path, sys.URL, sys.Checksum)
-			}
-		}
+	correctSystem, found := findTool(payload.Packager, payload.Name, payload.Version, index)
+	if found {
+		return t.install(ctx, path, correctSystem.URL, correctSystem.Checksum)
 	}
 
 	return nil, tools.MakeNotFound(
@@ -295,3 +285,36 @@ func writeInstalled(folder, path string) error {
 
 	return os.WriteFile(installedFile, data, 0644)
 }
+
+func findTool(pack, name, version string, data Index) (System, bool) {
+	var correctTool Tool
+	correctTool.Version = "0.0"
+	found := false
+
+	for _, p := range data.Packages {
+		if p.Name != pack {
+			continue
+		}
+		for _, t := range p.Tools {
+			if version != "latest" {
+				if t.Name == name && t.Version == version {
+					correctTool = t
+					found = true
+				}
+			} else {
+				// Find latest
+				v1, _ := semver.Make(t.Version)
+				v2, _ := semver.Make(correctTool.Version)
+				if t.Name == name && v1.Compare(v2) > 0 {
+					correctTool = t
+					found = true
+				}
+			}
+		}
+	}
+
+	// Find the url based on system
+	correctSystem := correctTool.GetFlavourCompatibleWith(runtime.GOOS, runtime.GOARCH)
+
+	return correctSystem, found
+}

From 887b22bbf9804958b3752002e51f20548892766a Mon Sep 17 00:00:00 2001
From: MatteoPologruto <m.pologruto@arduino.cc>
Date: Wed, 29 May 2024 16:17:48 +0200
Subject: [PATCH 3/5] Improve archive renamer and fix failing tests

---
 v2/pkgs/tools.go | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/v2/pkgs/tools.go b/v2/pkgs/tools.go
index 7510f638..8cbed5b3 100644
--- a/v2/pkgs/tools.go
+++ b/v2/pkgs/tools.go
@@ -246,8 +246,11 @@ func (t *Tools) Remove(ctx context.Context, payload *tools.ToolPayload) (*tools.
 func rename(base string) extract.Renamer {
 	return func(path string) string {
 		parts := strings.Split(filepath.ToSlash(path), "/")
-		path = strings.Join(parts[1:], "/")
-		path = filepath.Join(base, path)
+		newPath := strings.Join(parts[1:], "/")
+		if newPath == "" {
+			newPath = filepath.Join(newPath, path)
+		}
+		path = filepath.Join(base, newPath)
 		return path
 	}
 }

From a882ed7a2bd6927f97207db591149b7795f757fd Mon Sep 17 00:00:00 2001
From: MatteoPologruto <m.pologruto@arduino.cc>
Date: Wed, 29 May 2024 17:41:08 +0200
Subject: [PATCH 4/5] Find the correct tool and system when `version=latest` is
 specified

---
 tools/download.go      | 41 ++---------------------------------------
 tools/download_test.go | 24 +++++++++++++-----------
 v2/pkgs/tools.go       | 16 ++++++++++++----
 3 files changed, 27 insertions(+), 54 deletions(-)

diff --git a/tools/download.go b/tools/download.go
index 95615446..df3336d1 100644
--- a/tools/download.go
+++ b/tools/download.go
@@ -26,13 +26,6 @@ import (
 	"github.com/arduino/arduino-create-agent/gen/tools"
 	"github.com/arduino/arduino-create-agent/utilities"
 	"github.com/arduino/arduino-create-agent/v2/pkgs"
-	"github.com/blang/semver"
-)
-
-// public vars to allow override in the tests
-var (
-	OS   = runtime.GOOS
-	Arch = runtime.GOARCH
 )
 
 // Download will parse the index at the indexURL for the tool to download.
@@ -85,42 +78,12 @@ func (t *Tools) Download(pack, name, version, behaviour string) error {
 	return nil
 }
 
-func findTool(pack, name, version string, data pkgs.Index) (pkgs.Tool, pkgs.System) {
-	var correctTool pkgs.Tool
-	correctTool.Version = "0.0"
-
-	for _, p := range data.Packages {
-		if p.Name != pack {
-			continue
-		}
-		for _, t := range p.Tools {
-			if version != "latest" {
-				if t.Name == name && t.Version == version {
-					correctTool = t
-				}
-			} else {
-				// Find latest
-				v1, _ := semver.Make(t.Version)
-				v2, _ := semver.Make(correctTool.Version)
-				if t.Name == name && v1.Compare(v2) > 0 {
-					correctTool = t
-				}
-			}
-		}
-	}
-
-	// Find the url based on system
-	correctSystem := correctTool.GetFlavourCompatibleWith(OS, Arch)
-
-	return correctTool, correctSystem
-}
-
 func (t *Tools) installDrivers(location string) error {
 	OkPressed := 6
 	extension := ".bat"
 	// add .\ to force locality
 	preamble := ".\\"
-	if OS != "windows" {
+	if runtime.GOOS != "windows" {
 		extension = ".sh"
 		// add ./ to force locality
 		preamble = "./"
@@ -132,7 +95,7 @@ func (t *Tools) installDrivers(location string) error {
 			os.Chdir(location)
 			t.logger(preamble + "post_install" + extension)
 			oscmd := exec.Command(preamble + "post_install" + extension)
-			if OS != "linux" {
+			if runtime.GOOS != "linux" {
 				// spawning a shell could be the only way to let the user type his password
 				TellCommandNotToSpawnShell(oscmd)
 			}
diff --git a/tools/download_test.go b/tools/download_test.go
index c45914b5..1e958de9 100644
--- a/tools/download_test.go
+++ b/tools/download_test.go
@@ -42,8 +42,8 @@ func TestDownloadCorrectPlatform(t *testing.T) {
 		{"linux", "arm", "arm-linux-gnueabihf"},
 	}
 	defer func() {
-		OS = runtime.GOOS     // restore `runtime.OS`
-		Arch = runtime.GOARCH // restore `runtime.ARCH`
+		pkgs.OS = runtime.GOOS     // restore `runtime.OS`
+		pkgs.Arch = runtime.GOARCH // restore `runtime.ARCH`
 	}()
 	testIndex := paths.New("testdata", "test_tool_index.json")
 	buf, err := testIndex.ReadFile()
@@ -54,10 +54,11 @@ func TestDownloadCorrectPlatform(t *testing.T) {
 	require.NoError(t, err)
 	for _, tc := range testCases {
 		t.Run(tc.hostOS+tc.hostArch, func(t *testing.T) {
-			OS = tc.hostOS     // override `runtime.OS` for testing purposes
-			Arch = tc.hostArch // override `runtime.ARCH` for testing purposes
+			pkgs.OS = tc.hostOS     // override `runtime.OS` for testing purposes
+			pkgs.Arch = tc.hostArch // override `runtime.ARCH` for testing purposes
 			// Find the tool by name
-			correctTool, correctSystem := findTool("arduino-test", "arduino-fwuploader", "2.2.2", data)
+			correctTool, correctSystem, found := pkgs.FindTool("arduino-test", "arduino-fwuploader", "2.2.2", data)
+			require.True(t, found)
 			require.NotNil(t, correctTool)
 			require.NotNil(t, correctSystem)
 			require.Equal(t, correctTool.Name, "arduino-fwuploader")
@@ -78,8 +79,8 @@ func TestDownloadFallbackPlatform(t *testing.T) {
 		{"windows", "amd64", "i686-mingw32"},
 	}
 	defer func() {
-		OS = runtime.GOOS     // restore `runtime.OS`
-		Arch = runtime.GOARCH // restore `runtime.ARCH`
+		pkgs.OS = runtime.GOOS     // restore `runtime.OS`
+		pkgs.Arch = runtime.GOARCH // restore `runtime.ARCH`
 	}()
 	testIndex := paths.New("testdata", "test_tool_index.json")
 	buf, err := testIndex.ReadFile()
@@ -90,10 +91,11 @@ func TestDownloadFallbackPlatform(t *testing.T) {
 	require.NoError(t, err)
 	for _, tc := range testCases {
 		t.Run(tc.hostOS+tc.hostArch, func(t *testing.T) {
-			OS = tc.hostOS     // override `runtime.OS` for testing purposes
-			Arch = tc.hostArch // override `runtime.ARCH` for testing purposes
+			pkgs.OS = tc.hostOS     // override `runtime.OS` for testing purposes
+			pkgs.Arch = tc.hostArch // override `runtime.ARCH` for testing purposes
 			// Find the tool by name
-			correctTool, correctSystem := findTool("arduino-test", "arduino-fwuploader", "2.2.0", data)
+			correctTool, correctSystem, found := pkgs.FindTool("arduino-test", "arduino-fwuploader", "2.2.0", data)
+			require.True(t, found)
 			require.NotNil(t, correctTool)
 			require.NotNil(t, correctSystem)
 			require.Equal(t, correctTool.Name, "arduino-fwuploader")
@@ -145,7 +147,7 @@ func TestDownload(t *testing.T) {
 				if filePath.IsDir() {
 					require.DirExists(t, filePath.String())
 				} else {
-					if OS == "windows" {
+					if runtime.GOOS == "windows" {
 						require.FileExists(t, filePath.String()+".exe")
 					} else {
 						require.FileExists(t, filePath.String())
diff --git a/v2/pkgs/tools.go b/v2/pkgs/tools.go
index 8cbed5b3..180ab792 100644
--- a/v2/pkgs/tools.go
+++ b/v2/pkgs/tools.go
@@ -37,6 +37,12 @@ import (
 	"github.com/codeclysm/extract/v3"
 )
 
+// public vars to allow override in the tests
+var (
+	OS   = runtime.GOOS
+	Arch = runtime.GOARCH
+)
+
 // Tools is a client that implements github.com/arduino/arduino-create-agent/gen/tools.Service interface.
 // It saves tools in a specified folder with this structure: packager/name/version
 // For example:
@@ -167,7 +173,8 @@ func (t *Tools) Install(ctx context.Context, payload *tools.ToolPayload) (*tools
 	var index Index
 	json.Unmarshal(body, &index)
 
-	correctSystem, found := findTool(payload.Packager, payload.Name, payload.Version, index)
+	correctTool, correctSystem, found := FindTool(payload.Packager, payload.Name, payload.Version, index)
+	path = filepath.Join(payload.Packager, correctTool.Name, correctTool.Version)
 	if found {
 		return t.install(ctx, path, correctSystem.URL, correctSystem.Checksum)
 	}
@@ -289,7 +296,8 @@ func writeInstalled(folder, path string) error {
 	return os.WriteFile(installedFile, data, 0644)
 }
 
-func findTool(pack, name, version string, data Index) (System, bool) {
+// FindTool searches the index for the correct tool and system that match the specified tool name and version
+func FindTool(pack, name, version string, data Index) (Tool, System, bool) {
 	var correctTool Tool
 	correctTool.Version = "0.0"
 	found := false
@@ -317,7 +325,7 @@ func findTool(pack, name, version string, data Index) (System, bool) {
 	}
 
 	// Find the url based on system
-	correctSystem := correctTool.GetFlavourCompatibleWith(runtime.GOOS, runtime.GOARCH)
+	correctSystem := correctTool.GetFlavourCompatibleWith(OS, Arch)
 
-	return correctSystem, found
+	return correctTool, correctSystem, found
 }

From 18e9250110c8185a0d4cff95e0f4b9cd0922b280 Mon Sep 17 00:00:00 2001
From: MatteoPologruto <m.pologruto@arduino.cc>
Date: Thu, 20 Jun 2024 17:09:00 +0200
Subject: [PATCH 5/5] Reintroduce caching option when downloading tools

---
 tools/download.go     |  4 +--
 v2/http.go            |  2 +-
 v2/pkgs/tools.go      | 77 +++++++++++++++++++++++++++++++++++--------
 v2/pkgs/tools_test.go |  8 ++---
 4 files changed, 70 insertions(+), 21 deletions(-)

diff --git a/tools/download.go b/tools/download.go
index df3336d1..6e5fa8b7 100644
--- a/tools/download.go
+++ b/tools/download.go
@@ -43,11 +43,9 @@ import (
 // If version is not "latest" and behaviour is "replace", it will download the
 // version again. If instead behaviour is "keep" it will not download the version
 // if it already exists.
-//
-// At the moment the value of behaviour is ignored.
 func (t *Tools) Download(pack, name, version, behaviour string) error {
 
-	tool := pkgs.New(t.index, t.directory.String())
+	tool := pkgs.New(t.index, t.directory.String(), behaviour)
 	_, err := tool.Install(context.Background(), &tools.ToolPayload{Name: name, Version: version, Packager: pack})
 	if err != nil {
 		return err
diff --git a/v2/http.go b/v2/http.go
index bcfbc82a..390ec398 100644
--- a/v2/http.go
+++ b/v2/http.go
@@ -40,7 +40,7 @@ func Server(directory string, index *index.Resource) http.Handler {
 	logAdapter := LogAdapter{Logger: logger}
 
 	// Mount tools
-	toolsSvc := pkgs.New(index, directory)
+	toolsSvc := pkgs.New(index, directory, "replace")
 	toolsEndpoints := toolssvc.NewEndpoints(toolsSvc)
 	toolsServer := toolssvr.New(toolsEndpoints, mux, CustomRequestDecoder, goahttp.ResponseEncoder, errorHandler(logger), nil)
 	toolssvr.Mount(mux, toolsServer)
diff --git a/v2/pkgs/tools.go b/v2/pkgs/tools.go
index 180ab792..b0daaaae 100644
--- a/v2/pkgs/tools.go
+++ b/v2/pkgs/tools.go
@@ -57,17 +57,19 @@ var (
 //
 // It requires an Index Resource to search for tools
 type Tools struct {
-	index  *index.Resource
-	folder string
+	index     *index.Resource
+	folder    string
+	behaviour string
 }
 
 // New will return a Tool object, allowing the caller to execute operations on it.
 // The New function will accept an index as parameter (used to download the indexes)
 // and a folder used to download the indexes
-func New(index *index.Resource, folder string) *Tools {
+func New(index *index.Resource, folder, behaviour string) *Tools {
 	return &Tools{
-		index:  index,
-		folder: folder,
+		index:     index,
+		folder:    folder,
+		behaviour: behaviour,
 	}
 }
 
@@ -175,6 +177,23 @@ func (t *Tools) Install(ctx context.Context, payload *tools.ToolPayload) (*tools
 
 	correctTool, correctSystem, found := FindTool(payload.Packager, payload.Name, payload.Version, index)
 	path = filepath.Join(payload.Packager, correctTool.Name, correctTool.Version)
+
+	key := correctTool.Name + "-" + correctTool.Version
+	// Check if it already exists
+	if t.behaviour == "keep" && pathExists(t.folder) {
+		location, ok, err := checkInstalled(t.folder, key)
+		if err != nil {
+			return nil, err
+		}
+		if ok && pathExists(location) {
+			// overwrite the default tool with this one
+			err := writeInstalled(t.folder, path)
+			if err != nil {
+				return nil, err
+			}
+			return &tools.Operation{Status: "ok"}, nil
+		}
+	}
 	if found {
 		return t.install(ctx, path, correctSystem.URL, correctSystem.Checksum)
 	}
@@ -262,21 +281,42 @@ func rename(base string) extract.Renamer {
 	}
 }
 
-func writeInstalled(folder, path string) error {
+func readInstalled(installedFile string) (map[string]string, error) {
 	// read installed.json
 	installed := map[string]string{}
-
-	installedFile, err := utilities.SafeJoin(folder, "installed.json")
-	if err != nil {
-		return err
-	}
 	data, err := os.ReadFile(installedFile)
 	if err == nil {
 		err = json.Unmarshal(data, &installed)
 		if err != nil {
-			return err
+			return nil, err
 		}
 	}
+	return installed, nil
+}
+
+func checkInstalled(folder, key string) (string, bool, error) {
+	installedFile, err := utilities.SafeJoin(folder, "installed.json")
+	if err != nil {
+		return "", false, err
+	}
+	installed, err := readInstalled(installedFile)
+	if err != nil {
+		return "", false, err
+	}
+	location, ok := installed[key]
+	return location, ok, err
+}
+
+func writeInstalled(folder, path string) error {
+	// read installed.json
+	installedFile, err := utilities.SafeJoin(folder, "installed.json")
+	if err != nil {
+		return err
+	}
+	installed, err := readInstalled(installedFile)
+	if err != nil {
+		return err
+	}
 
 	parts := strings.Split(path, string(filepath.Separator))
 	tool := parts[len(parts)-2]
@@ -288,7 +328,7 @@ func writeInstalled(folder, path string) error {
 	installed[tool] = toolFile
 	installed[toolWithVersion] = toolFile
 
-	data, err = json.Marshal(installed)
+	data, err := json.Marshal(installed)
 	if err != nil {
 		return err
 	}
@@ -296,6 +336,17 @@ func writeInstalled(folder, path string) error {
 	return os.WriteFile(installedFile, data, 0644)
 }
 
+func pathExists(path string) bool {
+	_, err := os.Stat(path)
+	if err == nil {
+		return true
+	}
+	if os.IsNotExist(err) {
+		return false
+	}
+	return true
+}
+
 // FindTool searches the index for the correct tool and system that match the specified tool name and version
 func FindTool(pack, name, version string, data Index) (Tool, System, bool) {
 	var correctTool Tool
diff --git a/v2/pkgs/tools_test.go b/v2/pkgs/tools_test.go
index 78c56398..be4d5e4d 100644
--- a/v2/pkgs/tools_test.go
+++ b/v2/pkgs/tools_test.go
@@ -45,7 +45,7 @@ func TestTools(t *testing.T) {
 	// Instantiate Index
 	Index := index.Init(indexURL, config.GetDataDir())
 
-	service := pkgs.New(Index, tmp)
+	service := pkgs.New(Index, tmp, "replace")
 
 	ctx := context.Background()
 
@@ -126,7 +126,7 @@ func TestEvilFilename(t *testing.T) {
 	// Instantiate Index
 	Index := index.Init(indexURL, config.GetDataDir())
 
-	service := pkgs.New(Index, tmp)
+	service := pkgs.New(Index, tmp, "replace")
 
 	ctx := context.Background()
 
@@ -195,7 +195,7 @@ func TestInstalledHead(t *testing.T) {
 	// Instantiate Index
 	Index := index.Init(indexURL, config.GetDataDir())
 
-	service := pkgs.New(Index, tmp)
+	service := pkgs.New(Index, tmp, "replace")
 
 	ctx := context.Background()
 
@@ -216,7 +216,7 @@ func TestInstall(t *testing.T) {
 		LastRefresh: time.Now(),
 	}
 
-	tool := pkgs.New(testIndex, tmp)
+	tool := pkgs.New(testIndex, tmp, "replace")
 
 	ctx := context.Background()