From ba803f85c77094cecb0a5a3b4669fd4c3219ae64 Mon Sep 17 00:00:00 2001 From: Enrique Lacal Date: Tue, 2 Dec 2025 17:06:42 +0000 Subject: [PATCH 1/4] Verify stack type on deploy command - Allow errors on init to bubble up correctly - Fix spinner not being stopped and terminal session being unusable Signed-off-by: Enrique Lacal --- cmd/deploy_ethereum.go | 4 ++ cmd/deploy_fabric.go | 4 ++ cmd/init_cardano.go | 5 +-- cmd/init_fabric.go | 5 +-- cmd/init_tezos.go | 5 +-- cmd/pull.go | 8 ++-- cmd/start.go | 6 +-- internal/blockchain/fabric/fabric_provider.go | 44 ++++++++++++++++++- internal/stacks/stack_manager.go | 14 +++--- 9 files changed, 74 insertions(+), 21 deletions(-) diff --git a/cmd/deploy_ethereum.go b/cmd/deploy_ethereum.go index 5268136f..c18b4a26 100644 --- a/cmd/deploy_ethereum.go +++ b/cmd/deploy_ethereum.go @@ -23,6 +23,7 @@ import ( "github.com/hyperledger/firefly-cli/internal/docker" "github.com/hyperledger/firefly-cli/internal/log" "github.com/hyperledger/firefly-cli/internal/stacks" + "github.com/hyperledger/firefly-cli/pkg/types" "github.com/spf13/cobra" ) @@ -55,6 +56,9 @@ solc --combined-json abi,bin contract.sol > contract.json if err := stackManager.LoadStack(stackName); err != nil { return err } + if !stackManager.Stack.BlockchainProvider.Equals(types.BlockchainProviderEthereum) { + return fmt.Errorf("stack '%s' is not an Ethereum stack (blockchain provider: %s). Use 'ff deploy fabric' for Fabric stacks", stackName, stackManager.Stack.BlockchainProvider) + } contractNames, err := stackManager.GetContracts(filename, args[2:]) if err != nil { return err diff --git a/cmd/deploy_fabric.go b/cmd/deploy_fabric.go index f1f23eff..1e5b5a59 100644 --- a/cmd/deploy_fabric.go +++ b/cmd/deploy_fabric.go @@ -23,6 +23,7 @@ import ( "github.com/hyperledger/firefly-cli/internal/docker" "github.com/hyperledger/firefly-cli/internal/log" "github.com/hyperledger/firefly-cli/internal/stacks" + "github.com/hyperledger/firefly-cli/pkg/types" "github.com/spf13/cobra" ) @@ -49,6 +50,9 @@ var deployFabricCmd = &cobra.Command{ if err := stackManager.LoadStack(stackName); err != nil { return err } + if !stackManager.Stack.BlockchainProvider.Equals(types.BlockchainProviderFabric) { + return fmt.Errorf("stack '%s' is not a Fabric stack (blockchain provider: %s). Use 'ff deploy ethereum' for Ethereum-based stacks", stackName, stackManager.Stack.BlockchainProvider) + } contractAddress, err := stackManager.DeployContract(filename, filename, 0, args[2:]) if err != nil { return fmt.Errorf("%s. usage: %s deploy ", err.Error(), ExecutableName) diff --git a/cmd/init_cardano.go b/cmd/init_cardano.go index 2c4dc242..ad84e78a 100644 --- a/cmd/init_cardano.go +++ b/cmd/init_cardano.go @@ -49,9 +49,8 @@ var initCardanoCmd = &cobra.Command{ return err } if err := stackManager.InitStack(&initOptions); err != nil { - if err := stackManager.RemoveStack(); err != nil { - return err - } + // Try to clean up, but don't mask the original error + _ = stackManager.RemoveStack() return err } fmt.Printf("Stack '%s' created!\nTo start your new stack run:\n\n%s start %s\n", initOptions.StackName, rootCmd.Use, initOptions.StackName) diff --git a/cmd/init_fabric.go b/cmd/init_fabric.go index 53379217..cddf6621 100644 --- a/cmd/init_fabric.go +++ b/cmd/init_fabric.go @@ -54,9 +54,8 @@ var initFabricCmd = &cobra.Command{ return err } if err := stackManager.InitStack(&initOptions); err != nil { - if err := stackManager.RemoveStack(); err != nil { - return err - } + // Try to clean up, but don't mask the original error + _ = stackManager.RemoveStack() return err } fmt.Printf("Stack '%s' created!\nTo start your new stack run:\n\n%s start %s\n", initOptions.StackName, rootCmd.Use, initOptions.StackName) diff --git a/cmd/init_tezos.go b/cmd/init_tezos.go index a8af84a2..3c122916 100644 --- a/cmd/init_tezos.go +++ b/cmd/init_tezos.go @@ -50,9 +50,8 @@ var initTezosCmd = &cobra.Command{ return err } if err := stackManager.InitStack(&initOptions); err != nil { - if err := stackManager.RemoveStack(); err != nil { - return err - } + // Try to clean up, but don't mask the original error + _ = stackManager.RemoveStack() return err } fmt.Printf("Stack '%s' created!\nTo start your new stack run:\n\n%s start %s\n", initOptions.StackName, rootCmd.Use, initOptions.StackName) diff --git a/cmd/pull.go b/cmd/pull.go index c45b3250..a93b2cc3 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -66,12 +66,14 @@ Pull the images for a stack . if spin != nil { spin.Start() } - if err := stackManager.PullStack(&pullOptions); err != nil { - return err - } + err = stackManager.PullStack(&pullOptions) if spin != nil { spin.Stop() } + // Throw an error after stopping the spin, this will prevent the user's terminal from having the spinner as overlay + if err != nil { + return err + } return nil }, } diff --git a/cmd/start.go b/cmd/start.go index 95e0f496..86b27d96 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -75,12 +75,12 @@ This command will start a stack and run it in the background. spin.Start() } messages, err := stackManager.StartStack(&startOptions) - if err != nil { - return err - } if spin != nil { spin.Stop() } + if err != nil { + return err + } fmt.Print("\n\n") for _, message := range messages { fmt.Printf("%s\n\n", message) diff --git a/internal/blockchain/fabric/fabric_provider.go b/internal/blockchain/fabric/fabric_provider.go index 42d70ad2..2ef4db69 100644 --- a/internal/blockchain/fabric/fabric_provider.go +++ b/internal/blockchain/fabric/fabric_provider.go @@ -438,7 +438,7 @@ func (p *FabricProvider) queryInstalled() (*QueryInstalledResponse, error) { var res *QueryInstalledResponse err = json.Unmarshal([]byte(str), &res) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to parse queryinstalled response as JSON: %w. Raw output: %s", err, str) } return res, nil } @@ -515,11 +515,53 @@ func (p *FabricProvider) GetContracts(filename string, extraArgs []string) ([]st return []string{filename}, nil } +// validateChaincodePackage checks that the chaincode package file exists and is a valid gzip file. +// Fabric chaincode packages created by 'peer lifecycle chaincode package' are tar.gz files. +func (p *FabricProvider) validateChaincodePackage(filename string) error { + // Check if file exists + fileInfo, err := os.Stat(filename) + if os.IsNotExist(err) { + return fmt.Errorf("chaincode package file not found: %s", filename) + } + if err != nil { + return fmt.Errorf("error accessing chaincode package file: %w", err) + } + if fileInfo.IsDir() { + return fmt.Errorf("chaincode package path is a directory, expected a file: %s", filename) + } + + // Read the first two bytes to check for gzip magic number (0x1f, 0x8b) + file, err := os.Open(filename) + if err != nil { + return fmt.Errorf("failed to open chaincode package file: %w", err) + } + defer file.Close() + + header := make([]byte, 2) + n, err := file.Read(header) + if err != nil || n < 2 { + return fmt.Errorf("failed to read chaincode package file header: %w", err) + } + + // Check for gzip magic number + if header[0] != 0x1f || header[1] != 0x8b { + return fmt.Errorf("invalid chaincode package file format. Expected a gzip-compressed tar archive (.tar.gz or .tgz) created by 'peer lifecycle chaincode package'. The file '%s' does not appear to be a valid gzip file", filename) + } + + return nil +} + func (p *FabricProvider) DeployContract(filename, contractName, instanceName string, member *types.Organization, extraArgs []string) (*types.ContractDeploymentResult, error) { filename, err := filepath.Abs(filename) if err != nil { return nil, err } + + // Validate that the chaincode package file exists and is a valid gzip file + if err := p.validateChaincodePackage(filename); err != nil { + return nil, err + } + switch { case len(extraArgs) < 1: return nil, fmt.Errorf("channel not set") diff --git a/internal/stacks/stack_manager.go b/internal/stacks/stack_manager.go index 04582d31..b9b3667e 100644 --- a/internal/stacks/stack_manager.go +++ b/internal/stacks/stack_manager.go @@ -782,11 +782,15 @@ func (s *StackManager) ResetStack() error { } func (s *StackManager) RemoveStack() error { - if err := s.runDockerComposeCommand("down"); err != nil { - return err - } - if err := s.removeVolumes(); err != nil { - return err + // Only run docker compose down if the docker-compose.yml file exists + composeFile := filepath.Join(s.Stack.StackDir, "docker-compose.yml") + if _, err := os.Stat(composeFile); err == nil { + if err := s.runDockerComposeCommand("down"); err != nil { + return err + } + if err := s.removeVolumes(); err != nil { + return err + } } return os.RemoveAll(s.Stack.StackDir) } From fadf9a6d5fca632f6d083e465e45bad77de2e12d Mon Sep 17 00:00:00 2001 From: Enrique Lacal Date: Tue, 2 Dec 2025 17:13:58 +0000 Subject: [PATCH 2/4] Improved validation of tar.gz for chaincode package Signed-off-by: Enrique Lacal --- internal/blockchain/fabric/fabric_provider.go | 26 ++-- .../blockchain/fabric/fabric_provider_test.go | 141 ++++++++++++++++++ 2 files changed, 158 insertions(+), 9 deletions(-) diff --git a/internal/blockchain/fabric/fabric_provider.go b/internal/blockchain/fabric/fabric_provider.go index 2ef4db69..b3b83d1f 100644 --- a/internal/blockchain/fabric/fabric_provider.go +++ b/internal/blockchain/fabric/fabric_provider.go @@ -17,11 +17,14 @@ package fabric import ( + "archive/tar" + "compress/gzip" "context" _ "embed" "encoding/json" "errors" "fmt" + "io" "os" "path" "path/filepath" @@ -515,7 +518,7 @@ func (p *FabricProvider) GetContracts(filename string, extraArgs []string) ([]st return []string{filename}, nil } -// validateChaincodePackage checks that the chaincode package file exists and is a valid gzip file. +// validateChaincodePackage checks that the chaincode package file exists and is a valid tar.gz file. // Fabric chaincode packages created by 'peer lifecycle chaincode package' are tar.gz files. func (p *FabricProvider) validateChaincodePackage(filename string) error { // Check if file exists @@ -530,22 +533,27 @@ func (p *FabricProvider) validateChaincodePackage(filename string) error { return fmt.Errorf("chaincode package path is a directory, expected a file: %s", filename) } - // Read the first two bytes to check for gzip magic number (0x1f, 0x8b) + // Verify the file is a valid gzip archive file, err := os.Open(filename) if err != nil { return fmt.Errorf("failed to open chaincode package file: %w", err) } defer file.Close() - header := make([]byte, 2) - n, err := file.Read(header) - if err != nil || n < 2 { - return fmt.Errorf("failed to read chaincode package file header: %w", err) + gzReader, err := gzip.NewReader(file) + if err != nil { + return fmt.Errorf("invalid chaincode package file format. Expected a gzip-compressed tar archive (.tar.gz or .tgz) created by 'peer lifecycle chaincode package'. The file '%s' does not appear to be a valid gzip file: %w", filename, err) } + defer gzReader.Close() - // Check for gzip magic number - if header[0] != 0x1f || header[1] != 0x8b { - return fmt.Errorf("invalid chaincode package file format. Expected a gzip-compressed tar archive (.tar.gz or .tgz) created by 'peer lifecycle chaincode package'. The file '%s' does not appear to be a valid gzip file", filename) + // Verify the gzip contains a valid tar archive by reading at least one header + tarReader := tar.NewReader(gzReader) + _, err = tarReader.Next() + if err == io.EOF { + return fmt.Errorf("invalid chaincode package: the tar.gz archive is empty") + } + if err != nil { + return fmt.Errorf("invalid chaincode package file format. The file '%s' is gzip-compressed but does not contain a valid tar archive: %w", filename, err) } return nil diff --git a/internal/blockchain/fabric/fabric_provider_test.go b/internal/blockchain/fabric/fabric_provider_test.go index 9d7c17e5..fb701de8 100644 --- a/internal/blockchain/fabric/fabric_provider_test.go +++ b/internal/blockchain/fabric/fabric_provider_test.go @@ -1,6 +1,9 @@ package fabric import ( + "archive/tar" + "bytes" + "compress/gzip" "context" "fmt" "os" @@ -404,3 +407,141 @@ func TestRegisterIdentity(t *testing.T) { }) } + +func TestValidateChaincodePackage(t *testing.T) { + p := &FabricProvider{} + + // Helper function to create a valid tar.gz file + createValidTarGz := func(t *testing.T, filename string) { + var buf bytes.Buffer + gzWriter := gzip.NewWriter(&buf) + tarWriter := tar.NewWriter(gzWriter) + + // Add a file to the tar archive + content := []byte("test chaincode content") + header := &tar.Header{ + Name: "metadata.json", + Mode: 0644, + Size: int64(len(content)), + } + err := tarWriter.WriteHeader(header) + assert.NoError(t, err) + _, err = tarWriter.Write(content) + assert.NoError(t, err) + + err = tarWriter.Close() + assert.NoError(t, err) + err = gzWriter.Close() + assert.NoError(t, err) + + err = os.WriteFile(filename, buf.Bytes(), 0644) + assert.NoError(t, err) + } + + t.Run("valid tar.gz file", func(t *testing.T) { + tmpDir := t.TempDir() + validTarGzFile := filepath.Join(tmpDir, "valid.tar.gz") + createValidTarGz(t, validTarGzFile) + + err := p.validateChaincodePackage(validTarGzFile) + assert.NoError(t, err) + }) + + t.Run("file not found", func(t *testing.T) { + err := p.validateChaincodePackage("/nonexistent/path/chaincode.tar.gz") + assert.Error(t, err) + assert.Contains(t, err.Error(), "chaincode package file not found") + }) + + t.Run("path is a directory", func(t *testing.T) { + tmpDir := t.TempDir() + err := p.validateChaincodePackage(tmpDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "chaincode package path is a directory") + }) + + t.Run("invalid - plain text file", func(t *testing.T) { + tmpDir := t.TempDir() + plainTextFile := filepath.Join(tmpDir, "plain.txt") + err := os.WriteFile(plainTextFile, []byte("this is not a gzip file"), 0644) + assert.NoError(t, err) + + err = p.validateChaincodePackage(plainTextFile) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid chaincode package file format") + assert.Contains(t, err.Error(), "does not appear to be a valid gzip file") + }) + + t.Run("invalid - zip file", func(t *testing.T) { + tmpDir := t.TempDir() + // ZIP files start with PK (0x50, 0x4B) + zipLikeFile := filepath.Join(tmpDir, "fake.zip") + err := os.WriteFile(zipLikeFile, []byte{0x50, 0x4B, 0x03, 0x04, 0x00, 0x00}, 0644) + assert.NoError(t, err) + + err = p.validateChaincodePackage(zipLikeFile) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid chaincode package file format") + }) + + t.Run("invalid - random binary", func(t *testing.T) { + tmpDir := t.TempDir() + randomFile := filepath.Join(tmpDir, "random.bin") + err := os.WriteFile(randomFile, []byte{0xDE, 0xAD, 0xBE, 0xEF}, 0644) + assert.NoError(t, err) + + err = p.validateChaincodePackage(randomFile) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid chaincode package file format") + }) + + t.Run("invalid - gzip but not tar (plain gzipped content)", func(t *testing.T) { + tmpDir := t.TempDir() + gzipOnlyFile := filepath.Join(tmpDir, "gzip_only.gz") + + var buf bytes.Buffer + gzWriter := gzip.NewWriter(&buf) + _, err := gzWriter.Write([]byte("this is gzipped but not a tar archive")) + assert.NoError(t, err) + err = gzWriter.Close() + assert.NoError(t, err) + + err = os.WriteFile(gzipOnlyFile, buf.Bytes(), 0644) + assert.NoError(t, err) + + err = p.validateChaincodePackage(gzipOnlyFile) + assert.Error(t, err) + assert.Contains(t, err.Error(), "does not contain a valid tar archive") + }) + + t.Run("invalid - empty tar.gz", func(t *testing.T) { + tmpDir := t.TempDir() + emptyTarGzFile := filepath.Join(tmpDir, "empty.tar.gz") + + var buf bytes.Buffer + gzWriter := gzip.NewWriter(&buf) + tarWriter := tar.NewWriter(gzWriter) + // Close without adding any files + err := tarWriter.Close() + assert.NoError(t, err) + err = gzWriter.Close() + assert.NoError(t, err) + + err = os.WriteFile(emptyTarGzFile, buf.Bytes(), 0644) + assert.NoError(t, err) + + err = p.validateChaincodePackage(emptyTarGzFile) + assert.Error(t, err) + assert.Contains(t, err.Error(), "tar.gz archive is empty") + }) + + t.Run("invalid - empty file", func(t *testing.T) { + tmpDir := t.TempDir() + emptyFile := filepath.Join(tmpDir, "empty.tar.gz") + err := os.WriteFile(emptyFile, []byte{}, 0644) + assert.NoError(t, err) + + err = p.validateChaincodePackage(emptyFile) + assert.Error(t, err) + }) +} From f1bab8927e8568837a74ce44889dc21a8eff341a Mon Sep 17 00:00:00 2001 From: Enrique Lacal Date: Wed, 8 Jul 2026 16:39:49 +0100 Subject: [PATCH 3/4] fix: gofmt Signed-off-by: Enrique Lacal --- cmd/deploy_ethereum.go | 3 ++- cmd/deploy_fabric.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/deploy_ethereum.go b/cmd/deploy_ethereum.go index 34872365..42f107d0 100644 --- a/cmd/deploy_ethereum.go +++ b/cmd/deploy_ethereum.go @@ -19,10 +19,11 @@ package cmd import ( "context" "fmt" + "github.com/hyperledger-firefly/cli/internal/docker" "github.com/hyperledger-firefly/cli/internal/log" "github.com/hyperledger-firefly/cli/internal/stacks" - "github.com/hyperledger-firefly/cli/pkg/types" + "github.com/hyperledger-firefly/cli/pkg/types" "github.com/spf13/cobra" ) diff --git a/cmd/deploy_fabric.go b/cmd/deploy_fabric.go index 254c43ab..56adc08a 100644 --- a/cmd/deploy_fabric.go +++ b/cmd/deploy_fabric.go @@ -19,10 +19,11 @@ package cmd import ( "context" "fmt" + "github.com/hyperledger-firefly/cli/internal/docker" "github.com/hyperledger-firefly/cli/internal/log" "github.com/hyperledger-firefly/cli/internal/stacks" - "github.com/hyperledger-firefly/cli/pkg/types" + "github.com/hyperledger-firefly/cli/pkg/types" "github.com/spf13/cobra" ) From 64240dd960d26abcf1233a69b48a3a47e49f3863 Mon Sep 17 00:00:00 2001 From: Enrique Lacal Date: Wed, 8 Jul 2026 17:10:17 +0100 Subject: [PATCH 4/4] fix: address review comments on error swallowing and spinner handling Signed-off-by: Enrique Lacal --- cmd/init_cardano.go | 7 +++++-- cmd/init_fabric.go | 7 +++++-- cmd/init_tezos.go | 7 +++++-- cmd/pull.go | 15 +++------------ cmd/root.go | 11 +++++++++++ cmd/start.go | 13 +++++-------- 6 files changed, 34 insertions(+), 26 deletions(-) diff --git a/cmd/init_cardano.go b/cmd/init_cardano.go index ec6e1ac3..9b3e0eb8 100644 --- a/cmd/init_cardano.go +++ b/cmd/init_cardano.go @@ -49,8 +49,11 @@ var initCardanoCmd = &cobra.Command{ return err } if err := stackManager.InitStack(&initOptions); err != nil { - // Try to clean up, but don't mask the original error - _ = stackManager.RemoveStack() + // Try to clean up, but log rather than return any error to not mask the original error + if verr := stackManager.RemoveStack(); verr != nil { + l := log.LoggerFromContext(ctx) + l.Info(fmt.Sprintf("Error whilst removing the stack: %s", verr.Error())) + } return err } fmt.Printf("Stack '%s' created!\nTo start your new stack run:\n\n%s start %s\n", initOptions.StackName, rootCmd.Use, initOptions.StackName) diff --git a/cmd/init_fabric.go b/cmd/init_fabric.go index 1c0e15c1..2589b548 100644 --- a/cmd/init_fabric.go +++ b/cmd/init_fabric.go @@ -54,8 +54,11 @@ var initFabricCmd = &cobra.Command{ return err } if err := stackManager.InitStack(&initOptions); err != nil { - // Try to clean up, but don't mask the original error - _ = stackManager.RemoveStack() + // Try to clean up, but log rather than return any error to not mask the original error + if verr := stackManager.RemoveStack(); verr != nil { + l := log.LoggerFromContext(ctx) + l.Info(fmt.Sprintf("Error whilst removing the stack: %s", verr.Error())) + } return err } fmt.Printf("Stack '%s' created!\nTo start your new stack run:\n\n%s start %s\n", initOptions.StackName, rootCmd.Use, initOptions.StackName) diff --git a/cmd/init_tezos.go b/cmd/init_tezos.go index 8f624762..022bd587 100644 --- a/cmd/init_tezos.go +++ b/cmd/init_tezos.go @@ -50,8 +50,11 @@ var initTezosCmd = &cobra.Command{ return err } if err := stackManager.InitStack(&initOptions); err != nil { - // Try to clean up, but don't mask the original error - _ = stackManager.RemoveStack() + // Try to clean up, but log rather than return any error to not mask the original error + if verr := stackManager.RemoveStack(); verr != nil { + l := log.LoggerFromContext(ctx) + l.Info(fmt.Sprintf("Error whilst removing the stack: %s", verr.Error())) + } return err } fmt.Printf("Stack '%s' created!\nTo start your new stack run:\n\n%s start %s\n", initOptions.StackName, rootCmd.Use, initOptions.StackName) diff --git a/cmd/pull.go b/cmd/pull.go index 104978c6..99bd3e48 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -63,18 +63,9 @@ Pull the images for a stack . if err := stackManager.LoadStack(stackName); err != nil { return err } - if spin != nil { - spin.Start() - } - err = stackManager.PullStack(&pullOptions) - if spin != nil { - spin.Stop() - } - // Throw an error after stopping the spin, this will prevent the user's terminal from having the spinner as overlay - if err != nil { - return err - } - return nil + return withSpinner(spin, func() error { + return stackManager.PullStack(&pullOptions) + }) }, } diff --git a/cmd/root.go b/cmd/root.go index e79e4c1a..e4bb16d3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,6 +21,7 @@ import ( "fmt" "os" + "github.com/briandowns/spinner" "github.com/mattn/go-isatty" "github.com/spf13/cobra" @@ -42,6 +43,16 @@ var logger log.Logger = &log.StdoutLogger{ // name of the executable, this is for the help messages var ExecutableName string = os.Args[0] +// withSpinner runs fn with the spinner (if configured) running, and stops it +// before returning so an error is not overlaid by the spinner in the user's terminal +func withSpinner(spin *spinner.Spinner, fn func() error) error { + if spin != nil { + spin.Start() + defer spin.Stop() + } + return fn() +} + func GetFireflyASCIIArt() string { s := "" s += "\u001b[33m _______ ________ \u001b[0m\n" // yellow diff --git a/cmd/start.go b/cmd/start.go index 799e72f7..c7fbd3ca 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -71,14 +71,11 @@ This command will start a stack and run it in the background. fmt.Println("this will take a few seconds longer since this is the first time you're running this stack...") } - if spin != nil { - spin.Start() - } - messages, err := stackManager.StartStack(&startOptions) - if spin != nil { - spin.Stop() - } - if err != nil { + var messages []string + if err := withSpinner(spin, func() (err error) { + messages, err = stackManager.StartStack(&startOptions) + return err + }); err != nil { return err } fmt.Print("\n\n")