fix: support non-interactive template uploads (#228)
diff --git a/cmd/fileupload.go b/cmd/fileupload.go
index 30bf986..ecd6a06 100644
--- a/cmd/fileupload.go
+++ b/cmd/fileupload.go
@@ -110,18 +110,33 @@
 	metadata, _ := params["metadata"].(string)
 
 	fmt.Println("Uploading files for", api, ":", validFiles)
-	spinner := r.Config.StartSpinner(uploadingMessage)
+
+	var spinner *spinner.Spinner
+	if r.Config.HasShell {
+		spinner = r.Config.StartSpinner(uploadingMessage)
+	}
+
 	errored := 0
 	for i, filePath := range validFiles {
-		spinner.Suffix = fmt.Sprintf(" uploading %d/%d %s...", i+1, len(validFiles), filepath.Base(filePath))
+		if spinner != nil {
+			spinner.Suffix = fmt.Sprintf(" uploading %d/%d %s...", i+1, len(validFiles), filepath.Base(filePath))
+		}
+
 		if err := uploadFile(i, len(validFiles), postURL, filePath, signature, expires, metadata, spinner); err != nil {
-			spinner.Stop()
+			if spinner != nil {
+				spinner.Stop()
+			}
+
 			fmt.Println("Error uploading", filePath, ":", err)
 			errored++
-			spinner.Suffix = fmt.Sprintf(" %s", uploadingMessage)
-			spinner.Start()
+
+			if spinner != nil {
+				spinner.Suffix = fmt.Sprintf(" %s", uploadingMessage)
+				spinner.Start()
+			}
 		}
 	}
+
 	r.Config.StopSpinner(spinner)
 	if errored > 0 {
 		fmt.Printf("🙈 %d out of %d files failed to upload.\n", errored, len(validFiles))
@@ -220,7 +235,9 @@
 		f:     tmp,
 		total: size,
 		update: func(pct int) {
-			spn.Suffix = fmt.Sprintf(" [%d/%d] %s\t%s %d%%", index+1, count, fileName, barArrow(pct), pct)
+			if spn != nil {
+				spn.Suffix = fmt.Sprintf(" [%d/%d] %s\t%s %d%%", index+1, count, fileName, barArrow(pct), pct)
+			}
 		},
 	}
 	req.Body = pb
@@ -247,9 +264,13 @@
 		return fmt.Errorf("[%d/%d] %s\tupload failed: %s", index+1, count, fileName, string(b))
 	}
 
-	spn.Stop()
+	if spn != nil {
+		spn.Stop()
+	}
 	fmt.Printf("[%d/%d] %s\t%s ✅\n", index+1, count, fileName, barArrow(100))
-	spn.Suffix = fmt.Sprintf(" %s", uploadingMessage)
-	spn.Start()
+	if spn != nil {
+		spn.Suffix = fmt.Sprintf(" %s", uploadingMessage)
+		spn.Start()
+	}
 	return nil
 }
diff --git a/cmd/fileupload_test.go b/cmd/fileupload_test.go
new file mode 100644
index 0000000..93b93c8
--- /dev/null
+++ b/cmd/fileupload_test.go
@@ -0,0 +1,99 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package cmd
+
+import (
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"testing"
+
+	"github.com/apache/cloudstack-cloudmonkey/config"
+)
+
+func TestUploadFilesNonInteractive(t *testing.T) {
+	file, err := os.CreateTemp("", "cloudmonkey-upload-test-*")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer os.Remove(file.Name())
+
+	const fileContent = "test upload content"
+
+	if _, err := file.WriteString(fileContent); err != nil {
+		t.Fatal(err)
+	}
+	if err := file.Close(); err != nil {
+		t.Fatal(err)
+	}
+
+	var uploaded bool
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodPost {
+			t.Errorf("request method = %s, want POST", r.Method)
+		}
+
+		if err := r.ParseMultipartForm(10 << 20); err != nil {
+			t.Fatalf("failed to parse multipart form: %v", err)
+		}
+
+		uploadedFile, _, err := r.FormFile("file")
+		if err != nil {
+			t.Fatalf("failed to get uploaded file: %v", err)
+		}
+		defer uploadedFile.Close()
+
+		data, err := io.ReadAll(uploadedFile)
+		if err != nil {
+			t.Fatalf("failed to read uploaded file: %v", err)
+		}
+
+		if string(data) != fileContent {
+			t.Errorf("uploaded content = %q, want %q", string(data), fileContent)
+		}
+
+		uploaded = true
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	cfg := &config.Config{
+		HasShell: false,
+	}
+
+	request := &Request{
+		Config: cfg,
+	}
+
+	response := map[string]interface{}{
+		"getuploadparams": map[string]interface{}{
+			"postURL":   server.URL,
+			"metadata":  "metadata",
+			"signature": "signature",
+			"expires":   "expires",
+		},
+	}
+
+	UploadFiles(request, "getUploadParamsForTemplate", response, []string{file.Name()})
+
+	if !uploaded {
+		t.Fatal("file was not uploaded")
+	}
+}