diff --git a/internal/dependencymanager/dependencyinstaller.go b/internal/dependencymanager/dependencyinstaller.go index 1ac33b479..0d44b9a07 100644 --- a/internal/dependencymanager/dependencyinstaller.go +++ b/internal/dependencymanager/dependencyinstaller.go @@ -59,12 +59,23 @@ type categorizedLogs struct { type pendingPrompt struct { contractName string networkName string + contractAddr string + contractData string needsDeployment bool needsAlias bool needsUpdate bool updateHash string } +func (pp *pendingPrompt) matches(name, network string) bool { + return pp.contractName == name && pp.networkName == network +} + +func (di *DependencyInstaller) logFileSystemAction(message string) { + msg := util.MessageWithEmojiPrefix("βœ…", message) + di.logs.fileSystemActions = append(di.logs.fileSystemActions, msg) +} + func (cl *categorizedLogs) LogAll(logger output.Logger) { logger.Info(util.MessageWithEmojiPrefix("πŸ“", "Dependency Manager Actions Summary")) logger.Info("") // Add a line break after the section @@ -102,6 +113,7 @@ type DependencyFlags struct { skipDeployments bool `default:"false" flag:"skip-deployments" info:"Skip adding the dependency to deployments"` skipAlias bool `default:"false" flag:"skip-alias" info:"Skip prompting for an alias"` skipUpdatePrompts bool `default:"false" flag:"skip-update-prompts" info:"Skip prompting to update existing dependencies"` + update bool `default:"false" flag:"update" info:"Automatically accept all dependency updates"` deploymentAccount string `default:"" flag:"deployment-account,d" info:"Account name to use for deployments (skips deployment account prompt)"` name string `default:"" flag:"name" info:"Import alias name for the dependency (sets canonical field for Cadence import aliasing)"` } @@ -125,6 +137,8 @@ type DependencyInstaller struct { TargetDir string SkipDeployments bool SkipAlias bool + SkipUpdatePrompts bool + Update bool DeploymentAccount string Name string logs categorizedLogs @@ -132,10 +146,26 @@ type DependencyInstaller struct { accountAliases map[string]map[string]flowsdk.Address // network -> account -> alias installCount int // Track number of dependencies installed pendingPrompts []pendingPrompt // Dependencies that need prompts after tree display + prompter Prompter // Optional: for testing. If nil, uses real prompts +} + +type Prompter interface { + GenericBoolPrompt(msg string) (bool, error) +} + +type prompter struct{} + +func (prompter) GenericBoolPrompt(msg string) (bool, error) { + return prompt.GenericBoolPrompt(msg) } // NewDependencyInstaller creates a new instance of DependencyInstaller func NewDependencyInstaller(logger output.Logger, state *flowkit.State, saveState bool, targetDir string, flags DependencyFlags) (*DependencyInstaller, error) { + // Validate flags: --update and --skip-update-prompts are mutually exclusive + if flags.update && flags.skipUpdatePrompts { + return nil, fmt.Errorf("cannot use both --update and --skip-update-prompts flags together") + } + emulatorGateway, err := gateway.NewGrpcGateway(config.EmulatorNetwork) if err != nil { return nil, fmt.Errorf("error creating emulator gateway: %v", err) @@ -165,12 +195,15 @@ func NewDependencyInstaller(logger output.Logger, state *flowkit.State, saveStat TargetDir: targetDir, SkipDeployments: flags.skipDeployments, SkipAlias: flags.skipAlias, + SkipUpdatePrompts: flags.skipUpdatePrompts, + Update: flags.update, DeploymentAccount: flags.deploymentAccount, Name: flags.name, dependencies: make(map[string]config.Dependency), logs: categorizedLogs{}, accountAliases: make(map[string]map[string]flowsdk.Address), pendingPrompts: make([]pendingPrompt, 0), + prompter: prompter{}, }, nil } @@ -190,7 +223,6 @@ func (di *DependencyInstaller) Install() error { // Phase 1: Process all dependencies and display tree (no prompts) for _, dependency := range *di.State.Dependencies() { if err := di.processDependency(dependency); err != nil { - di.Logger.Error(fmt.Sprintf("Error processing dependency: %v", err)) return err } } @@ -556,18 +588,20 @@ func (di *DependencyInstaller) fetchDependenciesWithDepth(dependency config.Depe return nil } -func (di *DependencyInstaller) contractFileExists(address, contractName string) bool { +func (di *DependencyInstaller) getContractFilePath(address, contractName string) string { fileName := fmt.Sprintf("%s.cdc", contractName) - path := filepath.Join("imports", address, fileName) + return filepath.Join("imports", address, fileName) +} +func (di *DependencyInstaller) contractFileExists(address, contractName string) bool { + path := di.getContractFilePath(address, contractName) _, err := di.State.ReaderWriter().Stat(path) - return err == nil } func (di *DependencyInstaller) createContractFile(address, contractName, data string) error { - fileName := fmt.Sprintf("%s.cdc", contractName) - path := filepath.Join(di.TargetDir, "imports", address, fileName) + relativePath := di.getContractFilePath(address, contractName) + path := filepath.Join(di.TargetDir, relativePath) dir := filepath.Dir(path) if err := di.State.ReaderWriter().MkdirAll(dir, 0755); err != nil { @@ -581,14 +615,31 @@ func (di *DependencyInstaller) createContractFile(address, contractName, data st return nil } -func (di *DependencyInstaller) handleFileSystem(contractAddr, contractName, contractData, networkName string) error { +// verifyLocalFileIntegrity checks if the local file matches the expected hash in flow.json +func (di *DependencyInstaller) verifyLocalFileIntegrity(contractAddr, contractName, expectedHash string) error { if !di.contractFileExists(contractAddr, contractName) { - if err := di.createContractFile(contractAddr, contractName, contractData); err != nil { - return fmt.Errorf("failed to create contract file: %w", err) - } + return nil // File doesn't exist, nothing to verify + } + + filePath := di.getContractFilePath(contractAddr, contractName) + fileContent, err := di.State.ReaderWriter().ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file for integrity check: %w", err) + } + + // Calculate hash of existing file + fileHash := sha256.New() + fileHash.Write(fileContent) + existingFileHash := hex.EncodeToString(fileHash.Sum(nil)) - msg := util.MessageWithEmojiPrefix("βœ…οΈ", fmt.Sprintf("Contract %s from %s on %s installed", contractName, contractAddr, networkName)) - di.logs.fileSystemActions = append(di.logs.fileSystemActions, msg) + // Compare hashes + if expectedHash != existingFileHash { + return fmt.Errorf( + "dependency %s: local file has been modified (hash mismatch). Expected hash %s but file has %s. The file content does not match what is recorded in flow.json. Run 'flow dependencies install --update' to sync with the network version, or restore the file to match the stored hash", + contractName, + expectedHash, + existingFileHash, + ) } return nil @@ -598,13 +649,17 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, networkName := dependency.Source.NetworkName contractAddr := dependency.Source.Address.String() contractName := dependency.Source.ContractName - hash := sha256.New() - hash.Write(program.CodeWithUnprocessedImports()) - originalContractDataHash := hex.EncodeToString(hash.Sum(nil)) program.ConvertAddressImports() contractData := string(program.CodeWithUnprocessedImports()) + // Calculate hash of converted contract (what gets written to disk) + // This is what we store in flow.json so we can verify file integrity later + // Imported contracts are still checked for consistency by traversing the dependency tree. + hash := sha256.New() + hash.Write([]byte(contractData)) + contractDataHash := hex.EncodeToString(hash.Sum(nil)) + existingDependency := di.State.Dependencies().ByName(dependency.Name) // If a dependency by this name already exists and its remote source network or address does not match, @@ -620,42 +675,81 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, } // Check if remote source version is different from local version - // If it is, defer the prompt until after the tree is displayed - // If no hash, ignore - if existingDependency != nil && existingDependency.Hash != "" && existingDependency.Hash != originalContractDataHash { - // Find existing pending prompt for this contract or create new one - found := false - for i := range di.pendingPrompts { - if di.pendingPrompts[i].contractName == dependency.Name { - di.pendingPrompts[i].needsUpdate = true - di.pendingPrompts[i].updateHash = originalContractDataHash - found = true - break + // Decide what to do: defer prompt, skip (frozen), or auto-update + hashMismatch := existingDependency != nil && existingDependency.Hash != "" && existingDependency.Hash != contractDataHash + + if hashMismatch { + // If skip update prompts flag is set, check if we can keep frozen dependencies + if di.SkipUpdatePrompts && di.contractFileExists(contractAddr, contractName) { + // File exists - verify it matches stored hash + if err := di.verifyLocalFileIntegrity(contractAddr, contractName, existingDependency.Hash); err != nil { + // Local file was modified - FAIL + return fmt.Errorf("cannot install with --skip-update-prompts flag when local files have been modified. %w", err) } + + // File exists and matches stored hash - keep using it (frozen at old version) + return nil } - if !found { - di.pendingPrompts = append(di.pendingPrompts, pendingPrompt{ - contractName: dependency.Name, - networkName: networkName, - needsUpdate: true, - updateHash: originalContractDataHash, - }) + + // If --update flag is set, auto-accept the update (fall through to install) + // If --skip-update-prompts with no file, install from network (fall through to install) + // Otherwise (normal mode), defer prompt until after tree display + if !di.Update && !di.SkipUpdatePrompts { + found := false + for i := range di.pendingPrompts { + if di.pendingPrompts[i].matches(dependency.Name, networkName) { + di.pendingPrompts[i].needsUpdate = true + di.pendingPrompts[i].updateHash = contractDataHash + di.pendingPrompts[i].contractAddr = contractAddr + di.pendingPrompts[i].contractData = contractData + found = true + break + } + } + if !found { + di.pendingPrompts = append(di.pendingPrompts, pendingPrompt{ + contractName: dependency.Name, + networkName: networkName, + contractAddr: contractAddr, + contractData: contractData, + needsUpdate: true, + updateHash: contractDataHash, + }) + } + return nil + } + } + + // Check if file exists and needs repair (out of sync with flow.json) + fileExists := di.contractFileExists(contractAddr, contractName) + fileModified := false + if fileExists { + if err := di.verifyLocalFileIntegrity(contractAddr, contractName, contractDataHash); err != nil { + fileModified = true } - return nil } - // Check if this is a new dependency before updating state + // Install or update: new deps, out-of-sync files, or network updates with --update/--skip-update-prompts isNewDep := di.State.Dependencies().ByName(dependency.Name) == nil - err := di.updateDependencyState(dependency, originalContractDataHash) + err := di.updateDependencyState(dependency, contractDataHash) if err != nil { di.Logger.Error(fmt.Sprintf("Error updating state: %v", err)) return err } + // Log if this was an auto-update (with --update flag) or file repair + if (hashMismatch || fileModified) && di.Update { + msg := util.MessageWithEmojiPrefix("βœ…", fmt.Sprintf("%s updated to latest version", dependency.Name)) + di.logs.stateUpdates = append(di.logs.stateUpdates, msg) + } else if fileModified { + // File repair without --update flag (common after git clone) + msg := util.MessageWithEmojiPrefix("βœ…", fmt.Sprintf("%s synced", dependency.Name)) + di.logs.stateUpdates = append(di.logs.stateUpdates, msg) + } + // Handle additional tasks for new dependencies or when contract file doesn't exist - // This makes sure prompts are collected for new dependencies regardless of whether contract file exists - if isNewDep || !di.contractFileExists(contractAddr, contractName) { + if isNewDep || !fileExists { err := di.handleAdditionalDependencyTasks(networkName, dependency.Name) if err != nil { di.Logger.Error(fmt.Sprintf("Error handling additional dependency tasks: %v", err)) @@ -663,9 +757,18 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, } } - err = di.handleFileSystem(contractAddr, contractName, contractData, networkName) - if err != nil { - return fmt.Errorf("error handling file system: %w", err) + // Create or overwrite file + shouldWrite := !fileExists || fileModified || (hashMismatch && di.Update) + if !shouldWrite { + return nil + } + + if err := di.createContractFile(contractAddr, contractName, contractData); err != nil { + return fmt.Errorf("error creating contract file: %w", err) + } + + if !fileExists { + di.logFileSystemAction(fmt.Sprintf("Contract %s from %s on %s installed", contractName, contractAddr, networkName)) } return nil @@ -707,7 +810,7 @@ func (di *DependencyInstaller) handleAdditionalDependencyTasks(networkName, cont // If the contract is not a core contract and the user does not want to skip aliasing, then collect for prompting later needsAlias := !di.SkipAlias && !util.IsCoreContract(contractName) && !isDefiActionsContract(contractName) - // Only add to pending prompts if we need to prompt for something + // Only add/update pending prompts if we need to prompt for something if needsDeployment || needsAlias { di.pendingPrompts = append(di.pendingPrompts, pendingPrompt{ contractName: contractName, @@ -900,7 +1003,7 @@ func (di *DependencyInstaller) processPendingPrompts() error { setupDeployments := false if hasDeployments { - result, err := prompt.GenericBoolPrompt("Do you want to set up deployments for these dependencies?") + result, err := di.prompter.GenericBoolPrompt("Do you want to set up deployments for these dependencies?") if err != nil { return err } @@ -909,7 +1012,7 @@ func (di *DependencyInstaller) processPendingPrompts() error { setupAliases := false if hasAliases { - result, err := prompt.GenericBoolPrompt("Do you want to set up aliases for these dependencies?") + result, err := di.prompter.GenericBoolPrompt("Do you want to set up aliases for these dependencies?") if err != nil { return err } @@ -920,7 +1023,7 @@ func (di *DependencyInstaller) processPendingPrompts() error { for _, pending := range di.pendingPrompts { if pending.needsUpdate { msg := fmt.Sprintf("The latest version of %s is different from the one you have locally. Do you want to update it?", pending.contractName) - shouldUpdate, err := prompt.GenericBoolPrompt(msg) + shouldUpdate, err := di.prompter.GenericBoolPrompt(msg) if err != nil { return err } @@ -932,9 +1035,37 @@ func (di *DependencyInstaller) processPendingPrompts() error { di.Logger.Error(fmt.Sprintf("Error updating dependency: %v", err)) return err } + + // Write the updated contract file (force overwrite) + if err := di.createContractFile(pending.contractAddr, pending.contractName, pending.contractData); err != nil { + di.Logger.Error(fmt.Sprintf("Error updating contract file: %v", err)) + return fmt.Errorf("failed to update contract file: %w", err) + } + msg := util.MessageWithEmojiPrefix("βœ…", fmt.Sprintf("%s updated to latest version", pending.contractName)) di.logs.stateUpdates = append(di.logs.stateUpdates, msg) } + } else { + // User chose not to update + // If file doesn't exist, we MUST fail - can't guarantee frozen deps (no way to fetch old version) + if !di.contractFileExists(pending.contractAddr, pending.contractName) { + return fmt.Errorf("dependency %s has changed on-chain but file does not exist locally. Cannot keep at current version because we have no way to fetch the old version from the blockchain. Either accept the update or manually add the contract file", pending.contractName) + } + + // Get the stored hash from flow.json + dependency := di.State.Dependencies().ByName(pending.contractName) + if dependency == nil { + return fmt.Errorf("dependency %s not found in state", pending.contractName) + } + + // Verify the existing file's hash matches what's in flow.json to ensure integrity + if err := di.verifyLocalFileIntegrity(pending.contractAddr, pending.contractName, dependency.Hash); err != nil { + return err + } + + // File exists and hash matches - keep it at current version + msg := util.MessageWithEmojiPrefix("⏸️", fmt.Sprintf("%s kept at current version", pending.contractName)) + di.logs.stateUpdates = append(di.logs.stateUpdates, msg) } } } diff --git a/internal/dependencymanager/dependencyinstaller_test.go b/internal/dependencymanager/dependencyinstaller_test.go index 8e099c8e8..5ef81a7fb 100644 --- a/internal/dependencymanager/dependencyinstaller_test.go +++ b/internal/dependencymanager/dependencyinstaller_test.go @@ -19,7 +19,10 @@ package dependencymanager import ( + "crypto/sha256" + "encoding/hex" "fmt" + "path/filepath" "testing" "github.com/onflow/flow-go-sdk" @@ -37,6 +40,21 @@ import ( "github.com/onflow/flow-cli/internal/util" ) +// mockPrompter for testing +type mockPrompter struct { + responses []bool // Queue of responses to return + index int +} + +func (m *mockPrompter) GenericBoolPrompt(msg string) (bool, error) { + if m.index >= len(m.responses) { + return false, fmt.Errorf("no more mock responses available") + } + response := m.responses[m.index] + m.index++ + return response, nil +} + func TestDependencyInstallerInstall(t *testing.T) { logger := output.NewStdoutLogger(output.NoneLog) @@ -93,6 +111,1150 @@ func TestDependencyInstallerInstall(t *testing.T) { assert.NoError(t, err, "Failed to read generated file") assert.NotNil(t, fileContent) }) + + t.Run("Conflicting flags error", func(t *testing.T) { + _, state, _ := util.TestMocks(t) + + // Try to create installer with both --update and --skip-update-prompts + _, err := NewDependencyInstaller( + logger, + state, + true, + "", + DependencyFlags{ + update: true, + skipUpdatePrompts: true, + }, + ) + + assert.Error(t, err, "Should fail when both flags are set") + assert.Contains(t, err.Error(), "cannot use both", "Error should mention conflicting flags") + }) +} + +func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { + // This test simulates cloning a repo that has dependencies in flow.json with matching hashes, + // but no files in imports/ directory. It should create files without prompting. + + logger := output.NewStdoutLogger(output.NoneLog) + _, state, _ := util.TestMocks(t) + + serviceAcc, _ := state.EmulatorServiceAccount() + serviceAddress := serviceAcc.Address + + t.Run("First install, up-to-date hash", func(t *testing.T) { + // Use the standard test contract + contractCode := tests.ContractHelloString.Source + + // Calculate the hash for the contract (this is what would be in flow.json after a commit) + hash := sha256.New() + hash.Write(contractCode) + contractHash := hex.EncodeToString(hash.Sum(nil)) + + // Simulate a dependency that exists in flow.json with matching hash + // (like what you'd have after cloning a repo - hash matches network but no local file) + dep := config.Dependency{ + Name: "Hello", + Source: config.Source{ + NetworkName: "emulator", + Address: serviceAddress, + ContractName: "Hello", + }, + Hash: contractHash, // Hash matches what's on network + } + + state.Dependencies().AddOrUpdate(dep) + + gw := mocks.DefaultMockGateway() + + gw.GetAccount.Run(func(args mock.Arguments) { + addr := args.Get(1).(flow.Address) + assert.Equal(t, addr.String(), serviceAcc.Address.String()) + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "Hello": contractCode, + } + + gw.GetAccount.Return(acc, nil) + }) + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{ + config.EmulatorNetwork.Name: gw.Mock, + config.TestnetNetwork.Name: gw.Mock, + config.MainnetNetwork.Name: gw.Mock, + }, + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + SkipUpdatePrompts: false, + dependencies: make(map[string]config.Dependency), + accountAliases: make(map[string]map[string]flow.Address), + pendingPrompts: make([]pendingPrompt, 0), + prompter: &mockPrompter{responses: []bool{}}, + } + + err := di.Install() + assert.NoError(t, err, "Failed to install dependencies") + + // Verify file was created + filePath := fmt.Sprintf("imports/%s/%s.cdc", serviceAddress.String(), "Hello") + fileContent, err := state.ReaderWriter().ReadFile(filePath) + assert.NoError(t, err, "Failed to read generated file") + assert.NotNil(t, fileContent) + + // Verify hash remained the same (no update needed) + updatedDep := state.Dependencies().ByName("Hello") + assert.NotNil(t, updatedDep) + assert.Equal(t, contractHash, updatedDep.Hash, "Hash should remain unchanged") + }) + + t.Run("First install, outdated hash - user accepts", func(t *testing.T) { + // Fresh state for this test + _, state, _ := util.TestMocks(t) + + // Network has a newer version of the contract + newContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World! v2" } }`) + + // Calculate the new hash + newHash := sha256.New() + newHash.Write(newContractCode) + newContractHash := hex.EncodeToString(newHash.Sum(nil)) + + // Simulate a dependency that exists in flow.json with an OLD hash + dep := config.Dependency{ + Name: "Hello", + Source: config.Source{ + NetworkName: "emulator", + Address: serviceAddress, + ContractName: "Hello", + }, + Hash: "old_hash_from_previous_version", + } + + state.Dependencies().AddOrUpdate(dep) + + gw := mocks.DefaultMockGateway() + + gw.GetAccount.Run(func(args mock.Arguments) { + addr := args.Get(1).(flow.Address) + assert.Equal(t, addr.String(), serviceAddress.String()) + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "Hello": newContractCode, + } + + gw.GetAccount.Return(acc, nil) + }) + + // Mock prompter that returns true (user says "yes") + mockPrompter := &mockPrompter{responses: []bool{true}} + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{ + config.EmulatorNetwork.Name: gw.Mock, + config.TestnetNetwork.Name: gw.Mock, + config.MainnetNetwork.Name: gw.Mock, + }, + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + SkipUpdatePrompts: false, + dependencies: make(map[string]config.Dependency), + accountAliases: make(map[string]map[string]flow.Address), + pendingPrompts: make([]pendingPrompt, 0), + prompter: mockPrompter, + } + + err := di.Install() + assert.NoError(t, err, "Failed to install dependencies") + + // Verify file WAS created with new version + filePath := fmt.Sprintf("imports/%s/%s.cdc", serviceAddress.String(), "Hello") + fileContent, err := state.ReaderWriter().ReadFile(filePath) + assert.NoError(t, err, "File should exist after accepting update") + assert.NotNil(t, fileContent) + assert.Contains(t, string(fileContent), "Hello, World! v2", "Should have the new contract version") + + // Verify hash WAS updated + updatedDep := state.Dependencies().ByName("Hello") + assert.NotNil(t, updatedDep) + assert.Equal(t, newContractHash, updatedDep.Hash, "Hash should be updated to new version") + assert.NotEqual(t, "old_hash_from_previous_version", updatedDep.Hash, "Should not have old hash") + + // Verify no warnings + assert.Empty(t, di.logs.issues, "Should have no warnings when update is accepted") + }) + + t.Run("First install, outdated hash - user declines", func(t *testing.T) { + // Fresh state for this test + _, state, _ := util.TestMocks(t) + + // Network has a newer version of the contract + newContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World! v2" } }`) + + // Simulate a dependency that exists in flow.json with an OLD hash + dep := config.Dependency{ + Name: "Hello", + Source: config.Source{ + NetworkName: "emulator", + Address: serviceAddress, + ContractName: "Hello", + }, + Hash: "old_hash_from_previous_version", + } + + state.Dependencies().AddOrUpdate(dep) + + gw := mocks.DefaultMockGateway() + + gw.GetAccount.Run(func(args mock.Arguments) { + addr := args.Get(1).(flow.Address) + assert.Equal(t, addr.String(), serviceAddress.String()) + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "Hello": newContractCode, + } + + gw.GetAccount.Return(acc, nil) + }) + + // Mock prompter that returns false (user says "no") + mockPrompter := &mockPrompter{responses: []bool{false}} + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{ + config.EmulatorNetwork.Name: gw.Mock, + config.TestnetNetwork.Name: gw.Mock, + config.MainnetNetwork.Name: gw.Mock, + }, + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + SkipUpdatePrompts: false, + dependencies: make(map[string]config.Dependency), + accountAliases: make(map[string]map[string]flow.Address), + pendingPrompts: make([]pendingPrompt, 0), + prompter: mockPrompter, + } + + err := di.Install() + // Should FAIL because user declined and file doesn't exist (can't fetch old version) + assert.Error(t, err, "Should fail when user declines update and file doesn't exist") + assert.Contains(t, err.Error(), "Hello", "Error should mention the dependency name") + assert.Contains(t, err.Error(), "does not exist locally", "Error should mention missing file") + assert.Contains(t, err.Error(), "no way to fetch", "Error should explain can't fetch old version") + }) + + t.Run("First install, outdated hash - skip flag WITHOUT file", func(t *testing.T) { + // Fresh state for this test + _, state, _ := util.TestMocks(t) + + // Network has a newer version of the contract + newContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World! v2" } }`) + + // Calculate the new hash + newHash := sha256.New() + newHash.Write(newContractCode) + newContractHash := hex.EncodeToString(newHash.Sum(nil)) + + // Simulate a dependency that exists in flow.json with an OLD hash + // (like what you'd have after cloning a repo where the network has been updated) + dep := config.Dependency{ + Name: "Hello", + Source: config.Source{ + NetworkName: "emulator", + Address: serviceAddress, + ContractName: "Hello", + }, + Hash: "old_hash_from_previous_version", // Old hash that doesn't match + } + + state.Dependencies().AddOrUpdate(dep) + + gw := mocks.DefaultMockGateway() + + gw.GetAccount.Run(func(args mock.Arguments) { + addr := args.Get(1).(flow.Address) + assert.Equal(t, addr.String(), serviceAddress.String()) + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "Hello": newContractCode, // Network has new version + } + + gw.GetAccount.Return(acc, nil) + }) + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{ + config.EmulatorNetwork.Name: gw.Mock, + config.TestnetNetwork.Name: gw.Mock, + config.MainnetNetwork.Name: gw.Mock, + }, + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + SkipUpdatePrompts: true, // Skip prompts + dependencies: make(map[string]config.Dependency), + accountAliases: make(map[string]map[string]flow.Address), + pendingPrompts: make([]pendingPrompt, 0), + prompter: &mockPrompter{responses: []bool{}}, + } + + err := di.Install() + // Should SUCCEED - file doesn't exist, so just install from network + assert.NoError(t, err, "Should succeed when file doesn't exist - just install from network") + + // Verify file WAS created with network version + filePath := fmt.Sprintf("imports/%s/%s.cdc", serviceAddress.String(), "Hello") + fileContent, err := state.ReaderWriter().ReadFile(filePath) + assert.NoError(t, err, "File should exist after install") + assert.Contains(t, string(fileContent), "Hello, World! v2", "Should have the new network version") + + // Verify hash WAS updated to match network + updatedDep := state.Dependencies().ByName("Hello") + assert.NotNil(t, updatedDep) + assert.Equal(t, newContractHash, updatedDep.Hash, "Hash should be updated to network version") + }) + + t.Run("First install, outdated hash - skip flag with matching file", func(t *testing.T) { + // Fresh state for this test + _, state, _ := util.TestMocks(t) + + oldContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World! v1" } }`) + newContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World! v2" } }`) + + // Calculate the old hash + oldHash := sha256.New() + oldHash.Write(oldContractCode) + oldContractHash := hex.EncodeToString(oldHash.Sum(nil)) + + // Simulate a dependency that exists in flow.json with an OLD hash + dep := config.Dependency{ + Name: "Hello", + Source: config.Source{ + NetworkName: "emulator", + Address: serviceAddress, + ContractName: "Hello", + }, + Hash: oldContractHash, + } + + state.Dependencies().AddOrUpdate(dep) + + // Create the OLD file that matches the stored hash + filePath := fmt.Sprintf("imports/%s/Hello.cdc", serviceAddress.String()) + err := state.ReaderWriter().MkdirAll(filepath.Dir(filePath), 0755) + assert.NoError(t, err) + err = state.ReaderWriter().WriteFile(filePath, oldContractCode, 0644) + assert.NoError(t, err) + + gw := mocks.DefaultMockGateway() + + gw.GetAccount.Run(func(args mock.Arguments) { + addr := args.Get(1).(flow.Address) + assert.Equal(t, addr.String(), serviceAddress.String()) + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "Hello": newContractCode, // Network has new version + } + + gw.GetAccount.Return(acc, nil) + }) + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{ + config.EmulatorNetwork.Name: gw.Mock, + config.TestnetNetwork.Name: gw.Mock, + config.MainnetNetwork.Name: gw.Mock, + }, + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + SkipUpdatePrompts: true, // Skip prompts flag set + dependencies: make(map[string]config.Dependency), + accountAliases: make(map[string]map[string]flow.Address), + pendingPrompts: make([]pendingPrompt, 0), + prompter: &mockPrompter{responses: []bool{}}, + } + + err = di.Install() + // Should SUCCEED because local file exists and matches stored hash (frozen deps are valid) + assert.NoError(t, err, "Should succeed when file exists and matches stored hash with --skip-update-prompts") + + // Verify file was NOT changed (still has v1) + fileContent, err := state.ReaderWriter().ReadFile(filePath) + assert.NoError(t, err) + assert.Contains(t, string(fileContent), "Hello, World! v1", "Should keep the old version") + assert.NotContains(t, string(fileContent), "v2", "Should not have new version") + + // Verify hash was NOT updated in flow.json + updatedDep := state.Dependencies().ByName("Hello") + assert.NotNil(t, updatedDep) + assert.Equal(t, oldContractHash, updatedDep.Hash, "Hash should remain at old version") + }) + + t.Run("First install, outdated hash - skip flag with modified file", func(t *testing.T) { + // Fresh state for this test + _, state, _ := util.TestMocks(t) + + oldContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World! v1" } }`) + modifiedContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, Modified!" } }`) + newContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World! v2" } }`) + + // Calculate the old hash + oldHash := sha256.New() + oldHash.Write(oldContractCode) + oldContractHash := hex.EncodeToString(oldHash.Sum(nil)) + + // Simulate a dependency that exists in flow.json with an OLD hash + dep := config.Dependency{ + Name: "Hello", + Source: config.Source{ + NetworkName: "emulator", + Address: serviceAddress, + ContractName: "Hello", + }, + Hash: oldContractHash, + } + + state.Dependencies().AddOrUpdate(dep) + + // Create a MODIFIED file (different from both old and new versions) + filePath := fmt.Sprintf("imports/%s/Hello.cdc", serviceAddress.String()) + err := state.ReaderWriter().MkdirAll(filepath.Dir(filePath), 0755) + assert.NoError(t, err) + err = state.ReaderWriter().WriteFile(filePath, modifiedContractCode, 0644) + assert.NoError(t, err) + + gw := mocks.DefaultMockGateway() + + gw.GetAccount.Run(func(args mock.Arguments) { + addr := args.Get(1).(flow.Address) + assert.Equal(t, addr.String(), serviceAddress.String()) + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "Hello": newContractCode, // Network has new version + } + + gw.GetAccount.Return(acc, nil) + }) + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{ + config.EmulatorNetwork.Name: gw.Mock, + config.TestnetNetwork.Name: gw.Mock, + config.MainnetNetwork.Name: gw.Mock, + }, + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + SkipUpdatePrompts: true, // Skip prompts flag set + dependencies: make(map[string]config.Dependency), + accountAliases: make(map[string]map[string]flow.Address), + pendingPrompts: make([]pendingPrompt, 0), + prompter: &mockPrompter{responses: []bool{}}, + } + + err = di.Install() + // Should FAIL because local file has been modified (detected before checking network hash) + assert.Error(t, err, "Should fail when local file is modified with --skip-update-prompts") + assert.Contains(t, err.Error(), "local file has been modified", "Error should mention file modification") + assert.Contains(t, err.Error(), "hash mismatch", "Error should mention hash mismatch") + assert.Contains(t, err.Error(), "Hello", "Error should mention the dependency name") + }) + + t.Run("First install, outdated hash - update flag", func(t *testing.T) { + // Fresh state for this test + _, state, _ := util.TestMocks(t) + + // Network has a newer version of the contract + newContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World! v2" } }`) + + // Calculate the new hash + newHash := sha256.New() + newHash.Write(newContractCode) + newContractHash := hex.EncodeToString(newHash.Sum(nil)) + + // Simulate a dependency that exists in flow.json with an OLD hash + dep := config.Dependency{ + Name: "Hello", + Source: config.Source{ + NetworkName: "emulator", + Address: serviceAddress, + ContractName: "Hello", + }, + Hash: "old_hash_from_previous_version", + } + + state.Dependencies().AddOrUpdate(dep) + + gw := mocks.DefaultMockGateway() + + gw.GetAccount.Run(func(args mock.Arguments) { + addr := args.Get(1).(flow.Address) + assert.Equal(t, addr.String(), serviceAddress.String()) + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "Hello": newContractCode, + } + + gw.GetAccount.Return(acc, nil) + }) + + // No prompter needed - --update auto-accepts + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{ + config.EmulatorNetwork.Name: gw.Mock, + config.TestnetNetwork.Name: gw.Mock, + config.MainnetNetwork.Name: gw.Mock, + }, + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + SkipUpdatePrompts: false, + Update: true, // Auto-accept updates + dependencies: make(map[string]config.Dependency), + accountAliases: make(map[string]map[string]flow.Address), + pendingPrompts: make([]pendingPrompt, 0), + prompter: &mockPrompter{responses: []bool{}}, + } + + err := di.Install() + assert.NoError(t, err, "Should succeed with --update flag") + + // Verify file WAS created with new version + filePath := fmt.Sprintf("imports/%s/%s.cdc", serviceAddress.String(), "Hello") + fileContent, err := state.ReaderWriter().ReadFile(filePath) + assert.NoError(t, err, "File should exist after auto-update") + assert.NotNil(t, fileContent) + assert.Contains(t, string(fileContent), "Hello, World! v2", "Should have the new contract version") + + // Verify hash WAS updated + updatedDep := state.Dependencies().ByName("Hello") + assert.NotNil(t, updatedDep) + assert.Equal(t, newContractHash, updatedDep.Hash, "Hash should be updated to new version") + assert.NotEqual(t, "old_hash_from_previous_version", updatedDep.Hash, "Should not have old hash") + + // Verify success message logged + assert.NotEmpty(t, di.logs.stateUpdates, "Should have state update messages") + assert.Contains(t, di.logs.stateUpdates[0], "updated to latest version", "Should log update message") + }) + + t.Run("Already installed, up-to-date hash", func(t *testing.T) { + // Fresh state for this test + _, state, _ := util.TestMocks(t) + + contractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World!" } }`) + + // Calculate the hash + hash := sha256.New() + hash.Write(contractCode) + contractHash := hex.EncodeToString(hash.Sum(nil)) + + // Simulate a dependency with matching hash + dep := config.Dependency{ + Name: "Hello", + Source: config.Source{ + NetworkName: "emulator", + Address: serviceAddress, + ContractName: "Hello", + }, + Hash: contractHash, // Hash matches what's on network + } + + state.Dependencies().AddOrUpdate(dep) + + // Create the file (already installed) + filePath := fmt.Sprintf("imports/%s/Hello.cdc", serviceAddress.String()) + err := state.ReaderWriter().MkdirAll(filepath.Dir(filePath), 0755) + assert.NoError(t, err) + err = state.ReaderWriter().WriteFile(filePath, contractCode, 0644) + assert.NoError(t, err) + + gw := mocks.DefaultMockGateway() + + gw.GetAccount.Run(func(args mock.Arguments) { + addr := args.Get(1).(flow.Address) + assert.Equal(t, addr.String(), serviceAddress.String()) + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "Hello": contractCode, // Same version on network + } + + gw.GetAccount.Return(acc, nil) + }) + + // Mock prompter - should NOT be called + mockPrompter := &mockPrompter{responses: []bool{}} + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{ + config.EmulatorNetwork.Name: gw.Mock, + config.TestnetNetwork.Name: gw.Mock, + config.MainnetNetwork.Name: gw.Mock, + }, + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + SkipUpdatePrompts: false, + dependencies: make(map[string]config.Dependency), + accountAliases: make(map[string]map[string]flow.Address), + pendingPrompts: make([]pendingPrompt, 0), + prompter: mockPrompter, + } + + err = di.Install() + assert.NoError(t, err, "Failed to install dependencies") + + // Verify file still exists with same content + fileContent, err := state.ReaderWriter().ReadFile(filePath) + assert.NoError(t, err, "File should still exist") + assert.NotNil(t, fileContent) + assert.Equal(t, contractCode, fileContent, "File content should be unchanged") + + // Verify hash unchanged + updatedDep := state.Dependencies().ByName("Hello") + assert.NotNil(t, updatedDep) + assert.Equal(t, contractHash, updatedDep.Hash, "Hash should remain the same") + + // Verify no prompts occurred (mockPrompter.index should be 0) + assert.Equal(t, 0, mockPrompter.index, "No prompts should have been shown") + + // Verify no warnings or state updates + assert.Empty(t, di.logs.issues, "Should have no warnings") + assert.Empty(t, di.logs.stateUpdates, "Should have no state updates") + }) + + t.Run("Already installed, up-to-date hash BUT modified local file - user repairs", func(t *testing.T) { + // Network hash matches flow.json hash, but local file has been tampered with + // Should auto-repair WITHOUT prompting (flow.json is source of truth) + _, state, _ := util.TestMocks(t) + + contractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World!" } }`) + modifiedContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, HACKED!" } }`) + + // Calculate the hash of the correct contract + hash := sha256.New() + hash.Write(contractCode) + contractHash := hex.EncodeToString(hash.Sum(nil)) + + // Simulate a dependency with matching hash in flow.json + dep := config.Dependency{ + Name: "Hello", + Source: config.Source{ + NetworkName: "emulator", + Address: serviceAddress, + ContractName: "Hello", + }, + Hash: contractHash, // Hash matches what's on network + } + + state.Dependencies().AddOrUpdate(dep) + + // Create a MODIFIED file (different from what hash says it should be) + filePath := fmt.Sprintf("imports/%s/Hello.cdc", serviceAddress.String()) + err := state.ReaderWriter().MkdirAll(filepath.Dir(filePath), 0755) + assert.NoError(t, err) + err = state.ReaderWriter().WriteFile(filePath, modifiedContractCode, 0644) + assert.NoError(t, err) + + gw := mocks.DefaultMockGateway() + + gw.GetAccount.Run(func(args mock.Arguments) { + addr := args.Get(1).(flow.Address) + assert.Equal(t, addr.String(), serviceAddress.String()) + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "Hello": contractCode, // Network has the correct version + } + + gw.GetAccount.Return(acc, nil) + }) + + // No prompter needed - auto-repairs when network agrees with flow.json + mockPrompter := &mockPrompter{responses: []bool{}} + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{ + config.EmulatorNetwork.Name: gw.Mock, + config.TestnetNetwork.Name: gw.Mock, + config.MainnetNetwork.Name: gw.Mock, + }, + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + SkipUpdatePrompts: false, + dependencies: make(map[string]config.Dependency), + accountAliases: make(map[string]map[string]flow.Address), + pendingPrompts: make([]pendingPrompt, 0), + prompter: mockPrompter, + } + + err = di.Install() + // Should SUCCEED - auto-repaired without prompting + assert.NoError(t, err, "Should auto-repair when network agrees with flow.json") + + // Verify file WAS repaired + fileContent, err := state.ReaderWriter().ReadFile(filePath) + assert.NoError(t, err) + assert.Contains(t, string(fileContent), "Hello, World!", "Should have correct version") + assert.NotContains(t, string(fileContent), "HACKED", "Should not have hacked version") + + // Verify NO prompt was shown (auto-repair because network agrees with flow.json) + assert.Equal(t, 0, mockPrompter.index, "Should not prompt when network agrees with flow.json") + }) + + t.Run("Already installed, up-to-date hash BUT modified local file - skip prompts mode", func(t *testing.T) { + // Network hash matches flow.json hash, but local file has been tampered with + // Should auto-repair even with --skip-update-prompts (no network change) + _, state, _ := util.TestMocks(t) + + contractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World!" } }`) + modifiedContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, HACKED!" } }`) + + // Calculate the hash of the correct contract + hash := sha256.New() + hash.Write(contractCode) + contractHash := hex.EncodeToString(hash.Sum(nil)) + + // Simulate a dependency with matching hash in flow.json + dep := config.Dependency{ + Name: "Hello", + Source: config.Source{ + NetworkName: "emulator", + Address: serviceAddress, + ContractName: "Hello", + }, + Hash: contractHash, // Hash matches what's on network + } + + state.Dependencies().AddOrUpdate(dep) + + // Create a MODIFIED file (different from what hash says it should be) + filePath := fmt.Sprintf("imports/%s/Hello.cdc", serviceAddress.String()) + err := state.ReaderWriter().MkdirAll(filepath.Dir(filePath), 0755) + assert.NoError(t, err) + err = state.ReaderWriter().WriteFile(filePath, modifiedContractCode, 0644) + assert.NoError(t, err) + + gw := mocks.DefaultMockGateway() + + gw.GetAccount.Run(func(args mock.Arguments) { + addr := args.Get(1).(flow.Address) + assert.Equal(t, addr.String(), serviceAddress.String()) + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "Hello": contractCode, // Network has the correct version + } + + gw.GetAccount.Return(acc, nil) + }) + + // No prompter needed - auto-repairs regardless of flags + mockPrompter := &mockPrompter{responses: []bool{}} + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{ + config.EmulatorNetwork.Name: gw.Mock, + config.TestnetNetwork.Name: gw.Mock, + config.MainnetNetwork.Name: gw.Mock, + }, + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + SkipUpdatePrompts: true, // Should still auto-repair (no network change) + dependencies: make(map[string]config.Dependency), + accountAliases: make(map[string]map[string]flow.Address), + pendingPrompts: make([]pendingPrompt, 0), + prompter: mockPrompter, + } + + err = di.Install() + // Should SUCCEED - auto-repaired even with --skip-update-prompts + assert.NoError(t, err, "Should succeed even with --skip-update-prompts (no network change)") + + // Verify file WAS repaired + fileContent, err := state.ReaderWriter().ReadFile(filePath) + assert.NoError(t, err) + assert.Contains(t, string(fileContent), "Hello, World!", "Should have correct version") + assert.NotContains(t, string(fileContent), "HACKED", "Should not have hacked version") + + // Verify no prompts (auto-repair because network agrees with flow.json) + assert.Equal(t, 0, mockPrompter.index, "Should not prompt when network agrees with flow.json") + }) + + t.Run("Already installed, outdated hash - user accepts", func(t *testing.T) { + // Fresh state for this test + _, state, _ := util.TestMocks(t) + + oldContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World! v1" } }`) + newContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World! v2" } }`) + + // Calculate the old hash + oldHash := sha256.New() + oldHash.Write(oldContractCode) + oldContractHash := hex.EncodeToString(oldHash.Sum(nil)) + + // Calculate the new hash + newHash := sha256.New() + newHash.Write(newContractCode) + newContractHash := hex.EncodeToString(newHash.Sum(nil)) + + // Simulate a dependency that exists in flow.json with an OLD hash + dep := config.Dependency{ + Name: "Hello", + Source: config.Source{ + NetworkName: "emulator", + Address: serviceAddress, + ContractName: "Hello", + }, + Hash: oldContractHash, + } + + state.Dependencies().AddOrUpdate(dep) + + // Create the old file first + filePath := fmt.Sprintf("imports/%s/Hello.cdc", serviceAddress.String()) + err := state.ReaderWriter().MkdirAll(filepath.Dir(filePath), 0755) + assert.NoError(t, err) + err = state.ReaderWriter().WriteFile(filePath, oldContractCode, 0644) + assert.NoError(t, err) + + gw := mocks.DefaultMockGateway() + + gw.GetAccount.Run(func(args mock.Arguments) { + addr := args.Get(1).(flow.Address) + assert.Equal(t, addr.String(), serviceAddress.String()) + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "Hello": newContractCode, + } + + gw.GetAccount.Return(acc, nil) + }) + + // Mock prompter that returns true (user says "yes") + mockPrompter := &mockPrompter{responses: []bool{true}} + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{ + config.EmulatorNetwork.Name: gw.Mock, + config.TestnetNetwork.Name: gw.Mock, + config.MainnetNetwork.Name: gw.Mock, + }, + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + SkipUpdatePrompts: false, + dependencies: make(map[string]config.Dependency), + accountAliases: make(map[string]map[string]flow.Address), + pendingPrompts: make([]pendingPrompt, 0), + prompter: mockPrompter, + } + + err = di.Install() + assert.NoError(t, err, "Failed to install dependencies") + + // Verify file WAS overwritten with new version + fileContent, err := state.ReaderWriter().ReadFile(filePath) + assert.NoError(t, err, "File should exist after accepting update") + assert.NotNil(t, fileContent) + assert.Contains(t, string(fileContent), "Hello, World! v2", "Should have the new contract version") + assert.NotContains(t, string(fileContent), "v1", "Should not have old version") + + // Verify hash WAS updated + updatedDep := state.Dependencies().ByName("Hello") + assert.NotNil(t, updatedDep) + assert.Equal(t, newContractHash, updatedDep.Hash, "Hash should be updated to new version") + + // Verify no warnings + assert.Empty(t, di.logs.issues, "Should have no warnings when update is accepted") + }) + + t.Run("Already installed, outdated hash - user declines", func(t *testing.T) { + // Fresh state for this test + _, state, _ := util.TestMocks(t) + + oldContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World! v1" } }`) + newContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World! v2" } }`) + + // Calculate the old hash + oldHash := sha256.New() + oldHash.Write(oldContractCode) + oldContractHash := hex.EncodeToString(oldHash.Sum(nil)) + + // Simulate a dependency that exists in flow.json with an OLD hash + dep := config.Dependency{ + Name: "Hello", + Source: config.Source{ + NetworkName: "emulator", + Address: serviceAddress, + ContractName: "Hello", + }, + Hash: oldContractHash, + } + + state.Dependencies().AddOrUpdate(dep) + + // Create the old file first + filePath := fmt.Sprintf("imports/%s/Hello.cdc", serviceAddress.String()) + err := state.ReaderWriter().MkdirAll(filepath.Dir(filePath), 0755) + assert.NoError(t, err) + err = state.ReaderWriter().WriteFile(filePath, oldContractCode, 0644) + assert.NoError(t, err) + + gw := mocks.DefaultMockGateway() + + gw.GetAccount.Run(func(args mock.Arguments) { + addr := args.Get(1).(flow.Address) + assert.Equal(t, addr.String(), serviceAddress.String()) + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "Hello": newContractCode, + } + + gw.GetAccount.Return(acc, nil) + }) + + // Mock prompter that returns false (user says "no") + mockPrompter := &mockPrompter{responses: []bool{false}} + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{ + config.EmulatorNetwork.Name: gw.Mock, + config.TestnetNetwork.Name: gw.Mock, + config.MainnetNetwork.Name: gw.Mock, + }, + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + SkipUpdatePrompts: false, + dependencies: make(map[string]config.Dependency), + accountAliases: make(map[string]map[string]flow.Address), + pendingPrompts: make([]pendingPrompt, 0), + prompter: mockPrompter, + } + + err = di.Install() + assert.NoError(t, err, "Failed to install dependencies") + + // Verify file was NOT changed (still has v1) + fileContent, err := state.ReaderWriter().ReadFile(filePath) + assert.NoError(t, err, "File should still exist") + assert.NotNil(t, fileContent) + assert.Contains(t, string(fileContent), "Hello, World! v1", "Should still have the old version") + assert.NotContains(t, string(fileContent), "v2", "Should not have new version") + + // Verify hash was NOT updated + updatedDep := state.Dependencies().ByName("Hello") + assert.NotNil(t, updatedDep) + assert.Equal(t, oldContractHash, updatedDep.Hash, "Hash should remain at old version") + + // Verify no warnings (file exists, so no incomplete state) + assert.Empty(t, di.logs.issues, "Should have no warnings when file exists") + }) + + t.Run("Already installed, outdated hash - user declines with modified file", func(t *testing.T) { + // Fresh state for this test + _, state, _ := util.TestMocks(t) + + oldContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World! v1" } }`) + modifiedContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, Modified!" } }`) + newContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World! v2" } }`) + + // Calculate the old hash (of the converted contract) + oldHash := sha256.New() + oldHash.Write(oldContractCode) + oldContractHash := hex.EncodeToString(oldHash.Sum(nil)) + + // Simulate a dependency that exists in flow.json with an OLD hash + dep := config.Dependency{ + Name: "Hello", + Source: config.Source{ + NetworkName: "emulator", + Address: serviceAddress, + ContractName: "Hello", + }, + Hash: oldContractHash, + } + + state.Dependencies().AddOrUpdate(dep) + + // Create a MODIFIED file (different from both old and new versions) + filePath := fmt.Sprintf("imports/%s/Hello.cdc", serviceAddress.String()) + err := state.ReaderWriter().MkdirAll(filepath.Dir(filePath), 0755) + assert.NoError(t, err) + err = state.ReaderWriter().WriteFile(filePath, modifiedContractCode, 0644) + assert.NoError(t, err) + + gw := mocks.DefaultMockGateway() + + gw.GetAccount.Run(func(args mock.Arguments) { + addr := args.Get(1).(flow.Address) + assert.Equal(t, addr.String(), serviceAddress.String()) + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "Hello": newContractCode, + } + + gw.GetAccount.Return(acc, nil) + }) + + // Mock prompter that returns false (user says "no") + mockPrompter := &mockPrompter{responses: []bool{false}} + + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{ + config.EmulatorNetwork.Name: gw.Mock, + config.TestnetNetwork.Name: gw.Mock, + config.MainnetNetwork.Name: gw.Mock, + }, + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + SkipUpdatePrompts: false, + dependencies: make(map[string]config.Dependency), + accountAliases: make(map[string]map[string]flow.Address), + pendingPrompts: make([]pendingPrompt, 0), + prompter: mockPrompter, + } + + err = di.Install() + assert.Error(t, err, "Should fail when file hash doesn't match stored hash") + assert.Contains(t, err.Error(), "local file has been modified", "Error should mention file modification") + assert.Contains(t, err.Error(), "hash mismatch", "Error should mention hash mismatch") + }) + + t.Run("Already installed, outdated hash - update flag", func(t *testing.T) { + // Fresh state for this test + _, state, _ := util.TestMocks(t) + + oldContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World! v1" } }`) + newContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World! v2" } }`) + + // Calculate the old hash + oldHash := sha256.New() + oldHash.Write(oldContractCode) + oldContractHash := hex.EncodeToString(oldHash.Sum(nil)) + + // Calculate the new hash + newHash := sha256.New() + newHash.Write(newContractCode) + newContractHash := hex.EncodeToString(newHash.Sum(nil)) + + // Simulate a dependency that exists in flow.json with an OLD hash + dep := config.Dependency{ + Name: "Hello", + Source: config.Source{ + NetworkName: "emulator", + Address: serviceAddress, + ContractName: "Hello", + }, + Hash: oldContractHash, + } + + state.Dependencies().AddOrUpdate(dep) + + // Create the old file first + filePath := fmt.Sprintf("imports/%s/Hello.cdc", serviceAddress.String()) + err := state.ReaderWriter().MkdirAll(filepath.Dir(filePath), 0755) + assert.NoError(t, err) + err = state.ReaderWriter().WriteFile(filePath, oldContractCode, 0644) + assert.NoError(t, err) + + gw := mocks.DefaultMockGateway() + + gw.GetAccount.Run(func(args mock.Arguments) { + addr := args.Get(1).(flow.Address) + assert.Equal(t, addr.String(), serviceAddress.String()) + acc := tests.NewAccountWithAddress(addr.String()) + acc.Contracts = map[string][]byte{ + "Hello": newContractCode, + } + + gw.GetAccount.Return(acc, nil) + }) + + // No prompter needed - --update auto-accepts + di := &DependencyInstaller{ + Gateways: map[string]gateway.Gateway{ + config.EmulatorNetwork.Name: gw.Mock, + config.TestnetNetwork.Name: gw.Mock, + config.MainnetNetwork.Name: gw.Mock, + }, + Logger: logger, + State: state, + SaveState: true, + TargetDir: "", + SkipDeployments: true, + SkipAlias: true, + SkipUpdatePrompts: false, + Update: true, // Auto-accept updates + dependencies: make(map[string]config.Dependency), + accountAliases: make(map[string]map[string]flow.Address), + pendingPrompts: make([]pendingPrompt, 0), + prompter: &mockPrompter{responses: []bool{}}, + } + + err = di.Install() + assert.NoError(t, err, "Should succeed with --update flag") + + // Verify file WAS overwritten with new version + fileContent, err := state.ReaderWriter().ReadFile(filePath) + assert.NoError(t, err, "File should exist after auto-update") + assert.NotNil(t, fileContent) + assert.Contains(t, string(fileContent), "Hello, World! v2", "Should have the new contract version") + assert.NotContains(t, string(fileContent), "v1", "Should not have old version") + + // Verify hash WAS updated + updatedDep := state.Dependencies().ByName("Hello") + assert.NotNil(t, updatedDep) + assert.Equal(t, newContractHash, updatedDep.Hash, "Hash should be updated to new version") + + // Verify success message logged + assert.NotEmpty(t, di.logs.stateUpdates, "Should have state update messages") + assert.Contains(t, di.logs.stateUpdates[0], "updated to latest version", "Should log update message") + }) } func TestDependencyInstallerAdd(t *testing.T) { @@ -369,6 +1531,7 @@ func TestTransitiveConflictAllowedWithMatchingAlias(t *testing.T) { SkipDeployments: true, SkipAlias: true, dependencies: make(map[string]config.Dependency), + prompter: &mockPrompter{responses: []bool{}}, } // Attempt to install Bar from testnet, which imports Foo from testnet transitively @@ -414,6 +1577,7 @@ func TestDependencyInstallerAliasTracking(t *testing.T) { SkipAlias: false, dependencies: make(map[string]config.Dependency), accountAliases: make(map[string]map[string]flow.Address), + prompter: &mockPrompter{responses: []bool{}}, } dep1 := config.Dependency{ diff --git a/internal/dependencymanager/install.go b/internal/dependencymanager/install.go index c180634ad..7d657f32c 100644 --- a/internal/dependencymanager/install.go +++ b/internal/dependencymanager/install.go @@ -178,7 +178,6 @@ func install( logger.Info(util.MessageWithEmojiPrefix("πŸ”„", "Installing added dependencies...")) if err := installer.Install(); err != nil { - logger.Error(fmt.Sprintf("Error installing dependencies: %v", err)) return nil, err } @@ -190,7 +189,6 @@ func install( logger.Info(util.MessageWithEmojiPrefix("πŸ”„", "Installing dependencies from flow.json...")) if err := installer.Install(); err != nil { - logger.Error(fmt.Sprintf("Error installing dependencies: %v", err)) return nil, err }