From 6c95655cd1c0f062400362a382639bf7270c5c60 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Tue, 16 Dec 2025 12:03:49 -0800 Subject: [PATCH 01/16] Fix dependency manager update prompts and filesystem actions --- .../dependencymanager/dependencyinstaller.go | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/internal/dependencymanager/dependencyinstaller.go b/internal/dependencymanager/dependencyinstaller.go index 1ac33b479..06e003469 100644 --- a/internal/dependencymanager/dependencyinstaller.go +++ b/internal/dependencymanager/dependencyinstaller.go @@ -59,6 +59,8 @@ type categorizedLogs struct { type pendingPrompt struct { contractName string networkName string + contractAddr string + contractData string needsDeployment bool needsAlias bool needsUpdate bool @@ -125,6 +127,7 @@ type DependencyInstaller struct { TargetDir string SkipDeployments bool SkipAlias bool + SkipUpdatePrompts bool DeploymentAccount string Name string logs categorizedLogs @@ -165,6 +168,7 @@ func NewDependencyInstaller(logger output.Logger, state *flowkit.State, saveStat TargetDir: targetDir, SkipDeployments: flags.skipDeployments, SkipAlias: flags.skipAlias, + SkipUpdatePrompts: flags.skipUpdatePrompts, DeploymentAccount: flags.deploymentAccount, Name: flags.name, dependencies: make(map[string]config.Dependency), @@ -620,15 +624,24 @@ 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 it is, defer the prompt until after the tree is displayed (unless skip flag is set) // If no hash, ignore if existingDependency != nil && existingDependency.Hash != "" && existingDependency.Hash != originalContractDataHash { + // If skip update prompts flag is set, don't prompt and keep existing version + if di.SkipUpdatePrompts { + msg := util.MessageWithEmojiPrefix("⏸️ ", fmt.Sprintf("%s kept at current version (update available)", dependency.Name)) + di.logs.stateUpdates = append(di.logs.stateUpdates, msg) + return nil + } + // 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 + di.pendingPrompts[i].contractAddr = contractAddr + di.pendingPrompts[i].contractData = contractData found = true break } @@ -637,10 +650,13 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, di.pendingPrompts = append(di.pendingPrompts, pendingPrompt{ contractName: dependency.Name, networkName: networkName, + contractAddr: contractAddr, + contractData: contractData, needsUpdate: true, updateHash: originalContractDataHash, }) } + return nil } @@ -707,7 +723,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, @@ -932,9 +948,21 @@ func (di *DependencyInstaller) processPendingPrompts() error { di.Logger.Error(fmt.Sprintf("Error updating dependency: %v", err)) return err } + + // Write the updated contract file + err = di.handleFileSystem(pending.contractAddr, pending.contractName, pending.contractData, pending.networkName) + if err != nil { + di.Logger.Error(fmt.Sprintf("Error updating contract file: %v", err)) + return 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 - keep the existing file and hash as is + msg := util.MessageWithEmojiPrefix("⏸️", fmt.Sprintf("%s kept at current version", pending.contractName)) + di.logs.stateUpdates = append(di.logs.stateUpdates, msg) } } } From 209f5bc710a68ab6e8f9faf9145b51cee77cf95d Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Tue, 16 Dec 2025 12:28:24 -0800 Subject: [PATCH 02/16] fix emoji --- internal/dependencymanager/dependencyinstaller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/dependencymanager/dependencyinstaller.go b/internal/dependencymanager/dependencyinstaller.go index 06e003469..5db25d696 100644 --- a/internal/dependencymanager/dependencyinstaller.go +++ b/internal/dependencymanager/dependencyinstaller.go @@ -629,7 +629,7 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, if existingDependency != nil && existingDependency.Hash != "" && existingDependency.Hash != originalContractDataHash { // If skip update prompts flag is set, don't prompt and keep existing version if di.SkipUpdatePrompts { - msg := util.MessageWithEmojiPrefix("⏸️ ", fmt.Sprintf("%s kept at current version (update available)", dependency.Name)) + msg := util.MessageWithEmojiPrefix("⏸️", fmt.Sprintf("%s kept at current version (update available)", dependency.Name)) di.logs.stateUpdates = append(di.logs.stateUpdates, msg) return nil } From b80d6e9eaf452b830176eef5526c6562e9cf6b6f Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Tue, 16 Dec 2025 14:19:55 -0800 Subject: [PATCH 03/16] Fix edge cases & add tests --- .../dependencymanager/dependencyinstaller.go | 59 +- .../dependencyinstaller_test.go | 510 +++++++++++++++++- 2 files changed, 549 insertions(+), 20 deletions(-) diff --git a/internal/dependencymanager/dependencyinstaller.go b/internal/dependencymanager/dependencyinstaller.go index 5db25d696..edad21b8d 100644 --- a/internal/dependencymanager/dependencyinstaller.go +++ b/internal/dependencymanager/dependencyinstaller.go @@ -135,6 +135,17 @@ 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 @@ -175,6 +186,7 @@ func NewDependencyInstaller(logger output.Logger, state *flowkit.State, saveStat logs: categorizedLogs{}, accountAliases: make(map[string]map[string]flowsdk.Address), pendingPrompts: make([]pendingPrompt, 0), + prompter: prompter{}, }, nil } @@ -560,18 +572,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 { @@ -629,8 +643,14 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, if existingDependency != nil && existingDependency.Hash != "" && existingDependency.Hash != originalContractDataHash { // If skip update prompts flag is set, don't prompt and keep existing version if di.SkipUpdatePrompts { - msg := util.MessageWithEmojiPrefix("⏸️", fmt.Sprintf("%s kept at current version (update available)", dependency.Name)) - di.logs.stateUpdates = append(di.logs.stateUpdates, msg) + // Warn if file doesn't exist - incomplete project state + if !di.contractFileExists(contractAddr, contractName) { + msg := util.MessageWithEmojiPrefix("❗", fmt.Sprintf("%s kept at current version (update available), but file does not exist locally. Your project may be incomplete. Remove --skip-update-prompts flag and accept the update to fix this.", dependency.Name)) + di.logs.issues = append(di.logs.issues, msg) + } else { + msg := util.MessageWithEmojiPrefix("⏸️", fmt.Sprintf("%s kept at current version (update available)", dependency.Name)) + di.logs.stateUpdates = append(di.logs.stateUpdates, msg) + } return nil } @@ -916,7 +936,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 } @@ -925,7 +945,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 } @@ -936,7 +956,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 } @@ -949,20 +969,25 @@ func (di *DependencyInstaller) processPendingPrompts() error { return err } - // Write the updated contract file - err = di.handleFileSystem(pending.contractAddr, pending.contractName, pending.contractData, pending.networkName) - if err != nil { + // 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 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 - keep the existing file and hash as is - msg := util.MessageWithEmojiPrefix("⏸️", fmt.Sprintf("%s kept at current version", pending.contractName)) - di.logs.stateUpdates = append(di.logs.stateUpdates, msg) + // User chose not to update - keep existing file and hash + // Check if file exists - if not, warn about incomplete state + if !di.contractFileExists(pending.contractAddr, pending.contractName) { + msg := util.MessageWithEmojiPrefix("❗", fmt.Sprintf("%s kept at current version, but file does not exist locally. Your project may be incomplete. Run install again and accept the update to fix this.", pending.contractName)) + di.logs.issues = append(di.logs.issues, msg) + } else { + 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..2c8571881 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) @@ -95,6 +113,490 @@ func TestDependencyInstallerInstall(t *testing.T) { }) } +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("Create file when hash matches but file doesn't exist", 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("Skip update when hash mismatches with SkipUpdatePrompts 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" } }`) + + // 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 - should NOT update + 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 NOT created (hash mismatch triggers prompt, but we skipped it) + filePath := fmt.Sprintf("imports/%s/%s.cdc", serviceAddress.String(), "Hello") + _, err = state.ReaderWriter().ReadFile(filePath) + assert.Error(t, err, "File should not exist when update is skipped") + + // Verify hash was NOT updated (kept old version) + updatedDep := state.Dependencies().ByName("Hello") + assert.NotNil(t, updatedDep) + assert.Equal(t, "old_hash_from_previous_version", updatedDep.Hash, "Hash should remain unchanged") + + // Verify warning was logged about incomplete state + assert.NotEmpty(t, di.logs.issues, "Should have warning about incomplete state") + assert.Contains(t, di.logs.issues[0], "does not exist locally", "Warning should mention missing file") + assert.Contains(t, di.logs.issues[0], "incomplete", "Warning should mention incomplete state") + }) + + t.Run("User declines update when file doesn't exist", 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() + assert.NoError(t, err, "Failed to install dependencies") + + // Verify file was NOT created + filePath := fmt.Sprintf("imports/%s/%s.cdc", serviceAddress.String(), "Hello") + _, err = state.ReaderWriter().ReadFile(filePath) + assert.Error(t, err, "File should not exist when user declines update") + + // Verify hash was NOT updated + updatedDep := state.Dependencies().ByName("Hello") + assert.NotNil(t, updatedDep) + assert.Equal(t, "old_hash_from_previous_version", updatedDep.Hash, "Hash should remain unchanged") + + // Verify warning was logged about incomplete state + assert.NotEmpty(t, di.logs.issues, "Should have warning about incomplete state") + assert.Contains(t, di.logs.issues[0], "does not exist locally", "Warning should mention missing file") + assert.Contains(t, di.logs.issues[0], "incomplete", "Warning should mention incomplete state") + }) + + t.Run("User accepts update when file doesn't exist", 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("User accepts update when file exists", 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("User declines update when file exists", 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") + }) +} + func TestDependencyInstallerAdd(t *testing.T) { logger := output.NewStdoutLogger(output.NoneLog) @@ -369,6 +871,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 @@ -412,9 +915,10 @@ func TestDependencyInstallerAliasTracking(t *testing.T) { TargetDir: "", SkipDeployments: true, SkipAlias: false, - dependencies: make(map[string]config.Dependency), - accountAliases: make(map[string]map[string]flow.Address), - } + dependencies: make(map[string]config.Dependency), + accountAliases: make(map[string]map[string]flow.Address), + prompter: &mockPrompter{responses: []bool{}}, + } dep1 := config.Dependency{ Name: "ContractOne", From 96838db7767c00e3e8086b7bb5cb96a7b5d31466 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Tue, 16 Dec 2025 14:32:55 -0800 Subject: [PATCH 04/16] fix lint --- internal/dependencymanager/dependencyinstaller_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/dependencymanager/dependencyinstaller_test.go b/internal/dependencymanager/dependencyinstaller_test.go index 2c8571881..35ee657e3 100644 --- a/internal/dependencymanager/dependencyinstaller_test.go +++ b/internal/dependencymanager/dependencyinstaller_test.go @@ -915,10 +915,10 @@ func TestDependencyInstallerAliasTracking(t *testing.T) { TargetDir: "", SkipDeployments: true, SkipAlias: false, - dependencies: make(map[string]config.Dependency), - accountAliases: make(map[string]map[string]flow.Address), - prompter: &mockPrompter{responses: []bool{}}, - } + dependencies: make(map[string]config.Dependency), + accountAliases: make(map[string]map[string]flow.Address), + prompter: &mockPrompter{responses: []bool{}}, + } dep1 := config.Dependency{ Name: "ContractOne", From d8a7b7069c943cfb311896bf03339f05887710a0 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Tue, 16 Dec 2025 18:24:59 -0800 Subject: [PATCH 05/16] cleanup tests --- .../dependencyinstaller_test.go | 166 ++++++++++++++---- 1 file changed, 127 insertions(+), 39 deletions(-) diff --git a/internal/dependencymanager/dependencyinstaller_test.go b/internal/dependencymanager/dependencyinstaller_test.go index 35ee657e3..4fee7fed5 100644 --- a/internal/dependencymanager/dependencyinstaller_test.go +++ b/internal/dependencymanager/dependencyinstaller_test.go @@ -123,7 +123,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { serviceAcc, _ := state.EmulatorServiceAccount() serviceAddress := serviceAcc.Address - t.Run("Create file when hash matches but file doesn't exist", func(t *testing.T) { + t.Run("First install, up-to-date hash", func(t *testing.T) { // Use the standard test contract contractCode := tests.ContractHelloString.Source @@ -193,15 +193,19 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.Equal(t, contractHash, updatedDep.Hash, "Hash should remain unchanged") }) - t.Run("Skip update when hash mismatches with SkipUpdatePrompts flag", func(t *testing.T) { + 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 - // (like what you'd have after cloning a repo where the network has been updated) dep := config.Dependency{ Name: "Hello", Source: config.Source{ @@ -209,7 +213,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { Address: serviceAddress, ContractName: "Hello", }, - Hash: "old_hash_from_previous_version", // Old hash that doesn't match + Hash: "old_hash_from_previous_version", } state.Dependencies().AddOrUpdate(dep) @@ -221,12 +225,15 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.Equal(t, addr.String(), serviceAddress.String()) acc := tests.NewAccountWithAddress(addr.String()) acc.Contracts = map[string][]byte{ - "Hello": newContractCode, // Network has new version + "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, @@ -239,33 +246,34 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { TargetDir: "", SkipDeployments: true, SkipAlias: true, - SkipUpdatePrompts: true, // Skip prompts - should NOT update + SkipUpdatePrompts: false, dependencies: make(map[string]config.Dependency), accountAliases: make(map[string]map[string]flow.Address), pendingPrompts: make([]pendingPrompt, 0), - prompter: &mockPrompter{responses: []bool{}}, + prompter: mockPrompter, } err := di.Install() assert.NoError(t, err, "Failed to install dependencies") - // Verify file was NOT created (hash mismatch triggers prompt, but we skipped it) + // Verify file WAS created with new version filePath := fmt.Sprintf("imports/%s/%s.cdc", serviceAddress.String(), "Hello") - _, err = state.ReaderWriter().ReadFile(filePath) - assert.Error(t, err, "File should not exist when update is skipped") + 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 NOT updated (kept old version) + // Verify hash WAS updated updatedDep := state.Dependencies().ByName("Hello") assert.NotNil(t, updatedDep) - assert.Equal(t, "old_hash_from_previous_version", updatedDep.Hash, "Hash should remain unchanged") + 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 warning was logged about incomplete state - assert.NotEmpty(t, di.logs.issues, "Should have warning about incomplete state") - assert.Contains(t, di.logs.issues[0], "does not exist locally", "Warning should mention missing file") - assert.Contains(t, di.logs.issues[0], "incomplete", "Warning should mention incomplete state") + // Verify no warnings + assert.Empty(t, di.logs.issues, "Should have no warnings when update is accepted") }) - t.Run("User declines update when file doesn't exist", func(t *testing.T) { + t.Run("First install, outdated hash - user declines", func(t *testing.T) { // Fresh state for this test _, state, _ := util.TestMocks(t) @@ -339,19 +347,15 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.Contains(t, di.logs.issues[0], "incomplete", "Warning should mention incomplete state") }) - t.Run("User accepts update when file doesn't exist", func(t *testing.T) { + t.Run("First install, outdated hash - skip 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 + // (like what you'd have after cloning a repo where the network has been updated) dep := config.Dependency{ Name: "Hello", Source: config.Source{ @@ -359,7 +363,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { Address: serviceAddress, ContractName: "Hello", }, - Hash: "old_hash_from_previous_version", + Hash: "old_hash_from_previous_version", // Old hash that doesn't match } state.Dependencies().AddOrUpdate(dep) @@ -371,14 +375,96 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.Equal(t, addr.String(), serviceAddress.String()) acc := tests.NewAccountWithAddress(addr.String()) acc.Contracts = map[string][]byte{ - "Hello": newContractCode, + "Hello": newContractCode, // Network has new version } 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: true, // Skip prompts - should NOT update + 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 NOT created (hash mismatch triggers prompt, but we skipped it) + filePath := fmt.Sprintf("imports/%s/%s.cdc", serviceAddress.String(), "Hello") + _, err = state.ReaderWriter().ReadFile(filePath) + assert.Error(t, err, "File should not exist when update is skipped") + + // Verify hash was NOT updated (kept old version) + updatedDep := state.Dependencies().ByName("Hello") + assert.NotNil(t, updatedDep) + assert.Equal(t, "old_hash_from_previous_version", updatedDep.Hash, "Hash should remain unchanged") + + // Verify warning was logged about incomplete state + assert.NotEmpty(t, di.logs.issues, "Should have warning about incomplete state") + assert.Contains(t, di.logs.issues[0], "does not exist locally", "Warning should mention missing file") + assert.Contains(t, di.logs.issues[0], "incomplete", "Warning should mention incomplete state") + }) + + 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{ @@ -399,27 +485,29 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { prompter: mockPrompter, } - err := di.Install() + 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") + // Verify file still exists with same content fileContent, err := state.ReaderWriter().ReadFile(filePath) - assert.NoError(t, err, "File should exist after accepting update") + assert.NoError(t, err, "File should still exist") assert.NotNil(t, fileContent) - assert.Contains(t, string(fileContent), "Hello, World! v2", "Should have the new contract version") + assert.Equal(t, contractCode, fileContent, "File content should be unchanged") - // Verify hash WAS updated + // Verify hash unchanged 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") + assert.Equal(t, contractHash, updatedDep.Hash, "Hash should remain the same") - // Verify no warnings - assert.Empty(t, di.logs.issues, "Should have no warnings when update is accepted") + // 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("User accepts update when file exists", func(t *testing.T) { + t.Run("Already installed, outdated hash - user accepts", func(t *testing.T) { // Fresh state for this test _, state, _ := util.TestMocks(t) @@ -510,7 +598,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.Empty(t, di.logs.issues, "Should have no warnings when update is accepted") }) - t.Run("User declines update when file exists", func(t *testing.T) { + t.Run("Already installed, outdated hash - user declines", func(t *testing.T) { // Fresh state for this test _, state, _ := util.TestMocks(t) From b01830d00b8a56f0ba93e9974742f86f4b9802e2 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Tue, 16 Dec 2025 19:49:12 -0800 Subject: [PATCH 06/16] Switch to fail if hash mismatch unless `--update` --- .../dependencymanager/dependencyinstaller.go | 54 +++-- .../dependencyinstaller_test.go | 219 +++++++++++++++--- 2 files changed, 232 insertions(+), 41 deletions(-) diff --git a/internal/dependencymanager/dependencyinstaller.go b/internal/dependencymanager/dependencyinstaller.go index edad21b8d..eccaeb8a2 100644 --- a/internal/dependencymanager/dependencyinstaller.go +++ b/internal/dependencymanager/dependencyinstaller.go @@ -104,6 +104,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)"` } @@ -128,6 +129,7 @@ type DependencyInstaller struct { SkipDeployments bool SkipAlias bool SkipUpdatePrompts bool + Update bool DeploymentAccount string Name string logs categorizedLogs @@ -150,6 +152,11 @@ func (prompter) GenericBoolPrompt(msg string) (bool, error) { // 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) @@ -180,6 +187,7 @@ func NewDependencyInstaller(logger output.Logger, state *flowkit.State, saveStat SkipDeployments: flags.skipDeployments, SkipAlias: flags.skipAlias, SkipUpdatePrompts: flags.skipUpdatePrompts, + Update: flags.update, DeploymentAccount: flags.deploymentAccount, Name: flags.name, dependencies: make(map[string]config.Dependency), @@ -641,16 +649,33 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, // If it is, defer the prompt until after the tree is displayed (unless skip flag is set) // If no hash, ignore if existingDependency != nil && existingDependency.Hash != "" && existingDependency.Hash != originalContractDataHash { - // If skip update prompts flag is set, don't prompt and keep existing version + // If skip update prompts flag is set, fail immediately - can't guarantee frozen dependencies if di.SkipUpdatePrompts { - // Warn if file doesn't exist - incomplete project state - if !di.contractFileExists(contractAddr, contractName) { - msg := util.MessageWithEmojiPrefix("❗", fmt.Sprintf("%s kept at current version (update available), but file does not exist locally. Your project may be incomplete. Remove --skip-update-prompts flag and accept the update to fix this.", dependency.Name)) - di.logs.issues = append(di.logs.issues, msg) - } else { - msg := util.MessageWithEmojiPrefix("⏸️", fmt.Sprintf("%s kept at current version (update available)", dependency.Name)) - di.logs.stateUpdates = append(di.logs.stateUpdates, msg) + return fmt.Errorf( + "dependency %s has changed on-chain (hash mismatch). Expected hash %s but got %s. Cannot install with --skip-update-prompts flag when dependencies have been updated on-chain. Either remove the flag to accept updates interactively, or update your flow.json to the new hash", + dependency.Name, + existingDependency.Hash, + originalContractDataHash, + ) + } + + // If --update flag is set, auto-accept the update without prompting + if di.Update { + // Update the hash in state + err := di.updateDependencyState(*existingDependency, originalContractDataHash) + if err != nil { + return fmt.Errorf("error updating dependency state: %w", err) } + + // Create/overwrite the file with new version + err = di.createContractFile(contractAddr, contractName, contractData) + if err != nil { + return fmt.Errorf("error creating contract file: %w", err) + } + + msg := util.MessageWithEmojiPrefix("✅", fmt.Sprintf("%s updated to latest version", dependency.Name)) + di.logs.stateUpdates = append(di.logs.stateUpdates, msg) + return nil } @@ -979,15 +1004,14 @@ func (di *DependencyInstaller) processPendingPrompts() error { di.logs.stateUpdates = append(di.logs.stateUpdates, msg) } } else { - // User chose not to update - keep existing file and hash - // Check if file exists - if not, warn about incomplete state + // 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) { - msg := util.MessageWithEmojiPrefix("❗", fmt.Sprintf("%s kept at current version, but file does not exist locally. Your project may be incomplete. Run install again and accept the update to fix this.", pending.contractName)) - di.logs.issues = append(di.logs.issues, msg) - } else { - msg := util.MessageWithEmojiPrefix("⏸️", fmt.Sprintf("%s kept at current version", pending.contractName)) - di.logs.stateUpdates = append(di.logs.stateUpdates, msg) + 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) } + // File exists - 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 4fee7fed5..aa4cf6eb3 100644 --- a/internal/dependencymanager/dependencyinstaller_test.go +++ b/internal/dependencymanager/dependencyinstaller_test.go @@ -111,6 +111,25 @@ 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) { @@ -329,22 +348,11 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { } err := di.Install() - assert.NoError(t, err, "Failed to install dependencies") - - // Verify file was NOT created - filePath := fmt.Sprintf("imports/%s/%s.cdc", serviceAddress.String(), "Hello") - _, err = state.ReaderWriter().ReadFile(filePath) - assert.Error(t, err, "File should not exist when user declines update") - - // Verify hash was NOT updated - updatedDep := state.Dependencies().ByName("Hello") - assert.NotNil(t, updatedDep) - assert.Equal(t, "old_hash_from_previous_version", updatedDep.Hash, "Hash should remain unchanged") - - // Verify warning was logged about incomplete state - assert.NotEmpty(t, di.logs.issues, "Should have warning about incomplete state") - assert.Contains(t, di.logs.issues[0], "does not exist locally", "Warning should mention missing file") - assert.Contains(t, di.logs.issues[0], "incomplete", "Warning should mention incomplete state") + // 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", func(t *testing.T) { @@ -401,22 +409,90 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { } err := di.Install() - assert.NoError(t, err, "Failed to install dependencies") + // Should FAIL because hash mismatch with skip flag (can't guarantee frozen deps, we have no way to fetch them) + assert.Error(t, err, "Should fail when hash mismatches with --skip-update-prompts") + 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() - // Verify file was NOT created (hash mismatch triggers prompt, but we skipped it) + 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") - _, err = state.ReaderWriter().ReadFile(filePath) - assert.Error(t, err, "File should not exist when update is skipped") + 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 NOT updated (kept old version) + // Verify hash WAS updated updatedDep := state.Dependencies().ByName("Hello") assert.NotNil(t, updatedDep) - assert.Equal(t, "old_hash_from_previous_version", updatedDep.Hash, "Hash should remain unchanged") + 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 warning was logged about incomplete state - assert.NotEmpty(t, di.logs.issues, "Should have warning about incomplete state") - assert.Contains(t, di.logs.issues[0], "does not exist locally", "Warning should mention missing file") - assert.Contains(t, di.logs.issues[0], "incomplete", "Warning should mention incomplete state") + // 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) { @@ -683,6 +759,97 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { // 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 - 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) { From 6c775c727077f6286ffc1cf9f8362376886c784c Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Fri, 19 Dec 2025 11:32:34 -0800 Subject: [PATCH 07/16] Add hash check for preserved dependencies & change hashing function --- .../dependencymanager/dependencyinstaller.go | 72 +++++++-- .../dependencyinstaller_test.go | 147 ++++++++++++++++++ 2 files changed, 209 insertions(+), 10 deletions(-) diff --git a/internal/dependencymanager/dependencyinstaller.go b/internal/dependencymanager/dependencyinstaller.go index eccaeb8a2..01732b078 100644 --- a/internal/dependencymanager/dependencyinstaller.go +++ b/internal/dependencymanager/dependencyinstaller.go @@ -624,13 +624,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, @@ -648,21 +652,41 @@ 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 (unless skip flag is set) // If no hash, ignore - if existingDependency != nil && existingDependency.Hash != "" && existingDependency.Hash != originalContractDataHash { + if existingDependency != nil && existingDependency.Hash != "" && existingDependency.Hash != contractDataHash { // If skip update prompts flag is set, fail immediately - can't guarantee frozen dependencies if di.SkipUpdatePrompts { + // First check if local file has been modified before reporting network hash mismatch + if di.contractFileExists(contractAddr, contractName) { + filePath := di.getContractFilePath(contractAddr, contractName) + fileContent, err := di.State.ReaderWriter().ReadFile(filePath) + if err == nil { + fileHash := sha256.New() + fileHash.Write(fileContent) + existingFileHash := hex.EncodeToString(fileHash.Sum(nil)) + + if existingDependency.Hash != existingFileHash { + return fmt.Errorf( + "dependency %s: local file has been modified (hash mismatch). Expected hash %s but file has %s. Cannot install with --skip-update-prompts flag when local files have been modified. Either restore the file to match the stored hash or remove the flag to update interactively", + dependency.Name, + existingDependency.Hash, + existingFileHash, + ) + } + } + } + return fmt.Errorf( "dependency %s has changed on-chain (hash mismatch). Expected hash %s but got %s. Cannot install with --skip-update-prompts flag when dependencies have been updated on-chain. Either remove the flag to accept updates interactively, or update your flow.json to the new hash", dependency.Name, existingDependency.Hash, - originalContractDataHash, + contractDataHash, ) } // If --update flag is set, auto-accept the update without prompting if di.Update { // Update the hash in state - err := di.updateDependencyState(*existingDependency, originalContractDataHash) + err := di.updateDependencyState(*existingDependency, contractDataHash) if err != nil { return fmt.Errorf("error updating dependency state: %w", err) } @@ -684,7 +708,7 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, for i := range di.pendingPrompts { if di.pendingPrompts[i].contractName == dependency.Name { di.pendingPrompts[i].needsUpdate = true - di.pendingPrompts[i].updateHash = originalContractDataHash + di.pendingPrompts[i].updateHash = contractDataHash di.pendingPrompts[i].contractAddr = contractAddr di.pendingPrompts[i].contractData = contractData found = true @@ -698,7 +722,7 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, contractAddr: contractAddr, contractData: contractData, needsUpdate: true, - updateHash: originalContractDataHash, + updateHash: contractDataHash, }) } @@ -708,7 +732,7 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, // Check if this is a new dependency before updating state 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 @@ -1009,7 +1033,35 @@ func (di *DependencyInstaller) processPendingPrompts() error { 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) } - // File exists - keep it at current version + + // Verify the existing file's hash matches what's in flow.json to ensure integrity + filePath := di.getContractFilePath(pending.contractAddr, pending.contractName) + fileContent, err := di.State.ReaderWriter().ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read existing file for %s: %w", pending.contractName, err) + } + + // Calculate hash of existing file + fileHash := sha256.New() + fileHash.Write(fileContent) + existingFileHash := hex.EncodeToString(fileHash.Sum(nil)) + + // 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) + } + + // Compare hashes - file content should match what's recorded in flow.json + if dependency.Hash != 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. Either accept the update to sync with the network version, or restore the file to match the stored hash", + pending.contractName, + dependency.Hash, + existingFileHash, + ) + } + + // 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 aa4cf6eb3..e0aeb8ea8 100644 --- a/internal/dependencymanager/dependencyinstaller_test.go +++ b/internal/dependencymanager/dependencyinstaller_test.go @@ -415,6 +415,79 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.Contains(t, err.Error(), "Hello", "Error should mention the dependency name") }) + 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) @@ -760,6 +833,80 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { 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) From 98587061f21a2aee15952a25e40c4f83597a5908 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Fri, 19 Dec 2025 12:48:23 -0800 Subject: [PATCH 08/16] Deterministic linter order --- internal/cadence/linter.go | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/internal/cadence/linter.go b/internal/cadence/linter.go index a1603e94b..160cce8e7 100644 --- a/internal/cadence/linter.go +++ b/internal/cadence/linter.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" "path/filepath" + "slices" "strings" "github.com/onflow/flow-cli/internal/util" @@ -36,7 +37,6 @@ import ( "github.com/onflow/cadence/stdlib" "github.com/onflow/cadence/tools/analysis" "github.com/onflow/flowkit/v2" - "golang.org/x/exp/maps" ) type linter struct { @@ -58,7 +58,25 @@ const ( ErrorCategory = "error" ) -var analyzers = maps.Values(cdclint.Analyzers) +// getAnalyzers returns analyzers in deterministic order (sorted by name) +// to ensure consistent lint results across runs +func getAnalyzers() []*analysis.Analyzer { + // Get keys into a slice + keys := make([]string, 0, len(cdclint.Analyzers)) + for key := range cdclint.Analyzers { + keys = append(keys, key) + } + + // Sort keys for deterministic order + slices.Sort(keys) + + // Build analyzer list in sorted order + analyzers := make([]*analysis.Analyzer, 0, len(keys)) + for _, key := range keys { + analyzers = append(analyzers, cdclint.Analyzers[key]) + } + return analyzers +} func newLinter(state *flowkit.State) *linter { l := &linter{ @@ -152,7 +170,7 @@ func (l *linter) lintFile( report := func(diagnostic analysis.Diagnostic) { diagnostics = append(diagnostics, diagnostic) } - analysisProgram.Run(analyzers, report) + analysisProgram.Run(getAnalyzers(), report) return diagnostics, nil } From 475cfa3262ce501b0f009c69f27e7ca25d23a72d Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Fri, 19 Dec 2025 22:03:17 -0800 Subject: [PATCH 09/16] remove linter changes --- internal/cadence/linter.go | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/internal/cadence/linter.go b/internal/cadence/linter.go index 160cce8e7..a1603e94b 100644 --- a/internal/cadence/linter.go +++ b/internal/cadence/linter.go @@ -22,7 +22,6 @@ import ( "errors" "fmt" "path/filepath" - "slices" "strings" "github.com/onflow/flow-cli/internal/util" @@ -37,6 +36,7 @@ import ( "github.com/onflow/cadence/stdlib" "github.com/onflow/cadence/tools/analysis" "github.com/onflow/flowkit/v2" + "golang.org/x/exp/maps" ) type linter struct { @@ -58,25 +58,7 @@ const ( ErrorCategory = "error" ) -// getAnalyzers returns analyzers in deterministic order (sorted by name) -// to ensure consistent lint results across runs -func getAnalyzers() []*analysis.Analyzer { - // Get keys into a slice - keys := make([]string, 0, len(cdclint.Analyzers)) - for key := range cdclint.Analyzers { - keys = append(keys, key) - } - - // Sort keys for deterministic order - slices.Sort(keys) - - // Build analyzer list in sorted order - analyzers := make([]*analysis.Analyzer, 0, len(keys)) - for _, key := range keys { - analyzers = append(analyzers, cdclint.Analyzers[key]) - } - return analyzers -} +var analyzers = maps.Values(cdclint.Analyzers) func newLinter(state *flowkit.State) *linter { l := &linter{ @@ -170,7 +152,7 @@ func (l *linter) lintFile( report := func(diagnostic analysis.Diagnostic) { diagnostics = append(diagnostics, diagnostic) } - analysisProgram.Run(getAnalyzers(), report) + analysisProgram.Run(analyzers, report) return diagnostics, nil } From 49e771c7f89494d3c15ffd0e2b754e85791cf38d Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Sun, 21 Dec 2025 23:25:06 -0800 Subject: [PATCH 10/16] Add additional integrity check --- .../dependencymanager/dependencyinstaller.go | 40 ++++++++++ .../dependencyinstaller_test.go | 79 +++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/internal/dependencymanager/dependencyinstaller.go b/internal/dependencymanager/dependencyinstaller.go index 01732b078..4b860e606 100644 --- a/internal/dependencymanager/dependencyinstaller.go +++ b/internal/dependencymanager/dependencyinstaller.go @@ -620,6 +620,40 @@ func (di *DependencyInstaller) handleFileSystem(contractAddr, contractName, cont return nil } +// 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) { + return nil // File doesn't exist, nothing to verify + } + + if expectedHash == "" { + return nil // No hash stored, can't verify (legacy state) + } + + 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)) + + // 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 +} + func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, program *project.Program) error { networkName := dependency.Source.NetworkName contractAddr := dependency.Source.Address.String() @@ -753,6 +787,12 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, return fmt.Errorf("error handling file system: %w", err) } + // Verify local file integrity matches stored hash (if file exists and hash is stored) + err = di.verifyLocalFileIntegrity(contractAddr, contractName, contractDataHash) + if err != nil { + return err + } + return nil } diff --git a/internal/dependencymanager/dependencyinstaller_test.go b/internal/dependencymanager/dependencyinstaller_test.go index e0aeb8ea8..c9c31afc6 100644 --- a/internal/dependencymanager/dependencyinstaller_test.go +++ b/internal/dependencymanager/dependencyinstaller_test.go @@ -656,6 +656,85 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.Empty(t, di.logs.stateUpdates, "Should have no state updates") }) + t.Run("Already installed, up-to-date hash BUT modified local file", func(t *testing.T) { + // This is the CRITICAL test case: network hash matches flow.json hash, + // but local file has been tampered with. This should FAIL. + _, 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) + }) + + // Mock prompter - should NOT be called (no update prompt since hashes match) + 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 FAIL because local file has been modified (tampering detected) + assert.Error(t, err, "Should fail when local file is modified even if network hash matches") + 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") + + // Verify no prompts occurred (integrity check happens before any prompts) + assert.Equal(t, 0, mockPrompter.index, "No prompts should have been shown") + }) + t.Run("Already installed, outdated hash - user accepts", func(t *testing.T) { // Fresh state for this test _, state, _ := util.TestMocks(t) From 9c28e1f433e64163c56cc28ebf4987df9d5393ae Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 22 Dec 2025 12:22:28 -0800 Subject: [PATCH 11/16] reuse integrity check helper --- .../dependencymanager/dependencyinstaller.go | 47 +++++-------------- 1 file changed, 11 insertions(+), 36 deletions(-) diff --git a/internal/dependencymanager/dependencyinstaller.go b/internal/dependencymanager/dependencyinstaller.go index 4b860e606..ab17b0469 100644 --- a/internal/dependencymanager/dependencyinstaller.go +++ b/internal/dependencymanager/dependencyinstaller.go @@ -690,25 +690,16 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, // If skip update prompts flag is set, fail immediately - can't guarantee frozen dependencies if di.SkipUpdatePrompts { // First check if local file has been modified before reporting network hash mismatch - if di.contractFileExists(contractAddr, contractName) { - filePath := di.getContractFilePath(contractAddr, contractName) - fileContent, err := di.State.ReaderWriter().ReadFile(filePath) - if err == nil { - fileHash := sha256.New() - fileHash.Write(fileContent) - existingFileHash := hex.EncodeToString(fileHash.Sum(nil)) - - if existingDependency.Hash != existingFileHash { - return fmt.Errorf( - "dependency %s: local file has been modified (hash mismatch). Expected hash %s but file has %s. Cannot install with --skip-update-prompts flag when local files have been modified. Either restore the file to match the stored hash or remove the flag to update interactively", - dependency.Name, - existingDependency.Hash, - existingFileHash, - ) - } - } + if err := di.verifyLocalFileIntegrity(contractAddr, contractName, existingDependency.Hash); err != nil { + // Local file was modified - report that specifically + return fmt.Errorf( + "dependency %s: local file has been modified (hash mismatch). Cannot install with --skip-update-prompts flag when local files have been modified. Either restore the file to match the stored hash or remove the flag to update interactively. %w", + dependency.Name, + err, + ) } + // File is OK, but network has changed return fmt.Errorf( "dependency %s has changed on-chain (hash mismatch). Expected hash %s but got %s. Cannot install with --skip-update-prompts flag when dependencies have been updated on-chain. Either remove the flag to accept updates interactively, or update your flow.json to the new hash", dependency.Name, @@ -1074,31 +1065,15 @@ func (di *DependencyInstaller) processPendingPrompts() error { 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) } - // Verify the existing file's hash matches what's in flow.json to ensure integrity - filePath := di.getContractFilePath(pending.contractAddr, pending.contractName) - fileContent, err := di.State.ReaderWriter().ReadFile(filePath) - if err != nil { - return fmt.Errorf("failed to read existing file for %s: %w", pending.contractName, err) - } - - // Calculate hash of existing file - fileHash := sha256.New() - fileHash.Write(fileContent) - existingFileHash := hex.EncodeToString(fileHash.Sum(nil)) - // 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) } - // Compare hashes - file content should match what's recorded in flow.json - if dependency.Hash != 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. Either accept the update to sync with the network version, or restore the file to match the stored hash", - pending.contractName, - dependency.Hash, - existingFileHash, - ) + // 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 From bcbe97a99425cc9acde4f84f6f692d34466c4ec3 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 22 Dec 2025 13:08:35 -0800 Subject: [PATCH 12/16] Tidy integrity checks logic --- .../dependencymanager/dependencyinstaller.go | 140 ++++++++---------- .../dependencyinstaller_test.go | 106 ++++++++++++- 2 files changed, 164 insertions(+), 82 deletions(-) diff --git a/internal/dependencymanager/dependencyinstaller.go b/internal/dependencymanager/dependencyinstaller.go index ab17b0469..888be706d 100644 --- a/internal/dependencymanager/dependencyinstaller.go +++ b/internal/dependencymanager/dependencyinstaller.go @@ -607,19 +607,6 @@ func (di *DependencyInstaller) createContractFile(address, contractName, data st return nil } -func (di *DependencyInstaller) handleFileSystem(contractAddr, contractName, contractData, networkName 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) - } - - msg := util.MessageWithEmojiPrefix("✅️", fmt.Sprintf("Contract %s from %s on %s installed", contractName, contractAddr, networkName)) - di.logs.fileSystemActions = append(di.logs.fileSystemActions, msg) - } - - return nil -} - // 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) { @@ -684,14 +671,15 @@ 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 (unless skip flag is set) - // If no hash, ignore - if existingDependency != nil && existingDependency.Hash != "" && existingDependency.Hash != contractDataHash { - // If skip update prompts flag is set, fail immediately - can't guarantee frozen dependencies - if di.SkipUpdatePrompts { - // First check if local file has been modified before reporting network hash mismatch + // 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 - report that specifically + // Local file was modified - FAIL return fmt.Errorf( "dependency %s: local file has been modified (hash mismatch). Cannot install with --skip-update-prompts flag when local files have been modified. Either restore the file to match the stored hash or remove the flag to update interactively. %w", dependency.Name, @@ -699,62 +687,44 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, ) } - // File is OK, but network has changed - return fmt.Errorf( - "dependency %s has changed on-chain (hash mismatch). Expected hash %s but got %s. Cannot install with --skip-update-prompts flag when dependencies have been updated on-chain. Either remove the flag to accept updates interactively, or update your flow.json to the new hash", - dependency.Name, - existingDependency.Hash, - contractDataHash, - ) + // File exists and matches stored hash - keep using it (frozen at old version) + return nil } - // If --update flag is set, auto-accept the update without prompting - if di.Update { - // Update the hash in state - err := di.updateDependencyState(*existingDependency, contractDataHash) - if err != nil { - return fmt.Errorf("error updating dependency state: %w", err) + // 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].contractName == dependency.Name { + di.pendingPrompts[i].needsUpdate = true + di.pendingPrompts[i].updateHash = contractDataHash + di.pendingPrompts[i].contractAddr = contractAddr + di.pendingPrompts[i].contractData = contractData + found = true + break + } } - - // Create/overwrite the file with new version - err = di.createContractFile(contractAddr, contractName, contractData) - if err != nil { - return fmt.Errorf("error creating contract file: %w", err) + if !found { + di.pendingPrompts = append(di.pendingPrompts, pendingPrompt{ + contractName: dependency.Name, + networkName: networkName, + contractAddr: contractAddr, + contractData: contractData, + needsUpdate: true, + updateHash: contractDataHash, + }) } - - msg := util.MessageWithEmojiPrefix("✅", fmt.Sprintf("%s updated to latest version", dependency.Name)) - di.logs.stateUpdates = append(di.logs.stateUpdates, msg) - return nil } - - // 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 = 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 this is a new dependency before updating state + // Install or update the dependency + // This is the shared installation path for: + // - New dependencies (no hash mismatch) + // - Hash mismatch with --skip-update-prompts and no local file + // - Hash mismatch with --update flag isNewDep := di.State.Dependencies().ByName(dependency.Name) == nil err := di.updateDependencyState(dependency, contractDataHash) @@ -763,8 +733,13 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, return err } + // Log if this was an auto-update + if hashMismatch && di.Update { + msg := util.MessageWithEmojiPrefix("✅", fmt.Sprintf("%s updated to latest version", 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) { err := di.handleAdditionalDependencyTasks(networkName, dependency.Name) if err != nil { @@ -773,15 +748,28 @@ 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) + // Handle file creation/update + fileExists := di.contractFileExists(contractAddr, contractName) + forceOverwrite := hashMismatch && di.Update + + if !fileExists || forceOverwrite { + err = di.createContractFile(contractAddr, contractName, contractData) + if err != nil { + return fmt.Errorf("error creating contract file: %w", err) + } + + if !fileExists { + msg := util.MessageWithEmojiPrefix("✅️", fmt.Sprintf("Contract %s from %s on %s installed", contractName, contractAddr, networkName)) + di.logs.fileSystemActions = append(di.logs.fileSystemActions, msg) + } } - // Verify local file integrity matches stored hash (if file exists and hash is stored) - err = di.verifyLocalFileIntegrity(contractAddr, contractName, contractDataHash) - if err != nil { - return err + // Verify local file integrity matches stored hash (if file exists and we didn't just overwrite it) + if fileExists && !forceOverwrite { + err = di.verifyLocalFileIntegrity(contractAddr, contractName, contractDataHash) + if err != nil { + return err + } } return nil diff --git a/internal/dependencymanager/dependencyinstaller_test.go b/internal/dependencymanager/dependencyinstaller_test.go index c9c31afc6..10aa23e37 100644 --- a/internal/dependencymanager/dependencyinstaller_test.go +++ b/internal/dependencymanager/dependencyinstaller_test.go @@ -355,13 +355,18 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { 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", func(t *testing.T) { + 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{ @@ -401,7 +406,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { TargetDir: "", SkipDeployments: true, SkipAlias: true, - SkipUpdatePrompts: true, // Skip prompts - should NOT update + SkipUpdatePrompts: true, // Skip prompts dependencies: make(map[string]config.Dependency), accountAliases: make(map[string]map[string]flow.Address), pendingPrompts: make([]pendingPrompt, 0), @@ -409,10 +414,99 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { } err := di.Install() - // Should FAIL because hash mismatch with skip flag (can't guarantee frozen deps, we have no way to fetch them) - assert.Error(t, err, "Should fail when hash mismatches with --skip-update-prompts") - assert.Contains(t, err.Error(), "hash mismatch", "Error should mention hash mismatch") - assert.Contains(t, err.Error(), "Hello", "Error should mention the dependency name") + // 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) { From 8b93bac980b5ba6571b88cbd3bdaf18e79690b14 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 22 Dec 2025 13:12:39 -0800 Subject: [PATCH 13/16] remove needless defensive check --- internal/dependencymanager/dependencyinstaller.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/dependencymanager/dependencyinstaller.go b/internal/dependencymanager/dependencyinstaller.go index 888be706d..b6ecdcc36 100644 --- a/internal/dependencymanager/dependencyinstaller.go +++ b/internal/dependencymanager/dependencyinstaller.go @@ -613,10 +613,6 @@ func (di *DependencyInstaller) verifyLocalFileIntegrity(contractAddr, contractNa return nil // File doesn't exist, nothing to verify } - if expectedHash == "" { - return nil // No hash stored, can't verify (legacy state) - } - filePath := di.getContractFilePath(contractAddr, contractName) fileContent, err := di.State.ReaderWriter().ReadFile(filePath) if err != nil { From fb48b325c33ebfad9c00fa1e38a9de4d3185d9c0 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 22 Dec 2025 15:25:30 -0800 Subject: [PATCH 14/16] Fix repair logic --- .../dependencymanager/dependencyinstaller.go | 40 ++++--- .../dependencyinstaller_test.go | 107 ++++++++++++++++-- 2 files changed, 115 insertions(+), 32 deletions(-) diff --git a/internal/dependencymanager/dependencyinstaller.go b/internal/dependencymanager/dependencyinstaller.go index b6ecdcc36..59d938d8b 100644 --- a/internal/dependencymanager/dependencyinstaller.go +++ b/internal/dependencymanager/dependencyinstaller.go @@ -716,11 +716,16 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, } } - // Install or update the dependency - // This is the shared installation path for: - // - New dependencies (no hash mismatch) - // - Hash mismatch with --skip-update-prompts and no local file - // - Hash mismatch with --update flag + // 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 + } + } + + // 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, contractDataHash) @@ -729,14 +734,18 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, return err } - // Log if this was an auto-update - if hashMismatch && di.Update { + // 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 - 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)) @@ -744,11 +753,8 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, } } - // Handle file creation/update - fileExists := di.contractFileExists(contractAddr, contractName) - forceOverwrite := hashMismatch && di.Update - - if !fileExists || forceOverwrite { + // Create or overwrite file + if !fileExists || fileModified || (hashMismatch && di.Update) { err = di.createContractFile(contractAddr, contractName, contractData) if err != nil { return fmt.Errorf("error creating contract file: %w", err) @@ -760,14 +766,6 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, } } - // Verify local file integrity matches stored hash (if file exists and we didn't just overwrite it) - if fileExists && !forceOverwrite { - err = di.verifyLocalFileIntegrity(contractAddr, contractName, contractDataHash) - if err != nil { - return err - } - } - return nil } diff --git a/internal/dependencymanager/dependencyinstaller_test.go b/internal/dependencymanager/dependencyinstaller_test.go index 10aa23e37..5ef81a7fb 100644 --- a/internal/dependencymanager/dependencyinstaller_test.go +++ b/internal/dependencymanager/dependencyinstaller_test.go @@ -750,9 +750,9 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { assert.Empty(t, di.logs.stateUpdates, "Should have no state updates") }) - t.Run("Already installed, up-to-date hash BUT modified local file", func(t *testing.T) { - // This is the CRITICAL test case: network hash matches flow.json hash, - // but local file has been tampered with. This should FAIL. + 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!" } }`) @@ -796,7 +796,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { gw.GetAccount.Return(acc, nil) }) - // Mock prompter - should NOT be called (no update prompt since hashes match) + // No prompter needed - auto-repairs when network agrees with flow.json mockPrompter := &mockPrompter{responses: []bool{}} di := &DependencyInstaller{ @@ -819,14 +819,99 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) { } err = di.Install() - // Should FAIL because local file has been modified (tampering detected) - assert.Error(t, err, "Should fail when local file is modified even if network hash matches") - 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") + // Should SUCCEED - auto-repaired without prompting + assert.NoError(t, err, "Should auto-repair when network agrees with flow.json") - // Verify no prompts occurred (integrity check happens before any prompts) - assert.Equal(t, 0, mockPrompter.index, "No prompts should have been shown") + // 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) { From 51cc6f11693773426ac538818a3b70481274c249 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 22 Dec 2025 16:49:50 -0800 Subject: [PATCH 15/16] address feedback --- .../dependencymanager/dependencyinstaller.go | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/internal/dependencymanager/dependencyinstaller.go b/internal/dependencymanager/dependencyinstaller.go index 59d938d8b..0baa88f44 100644 --- a/internal/dependencymanager/dependencyinstaller.go +++ b/internal/dependencymanager/dependencyinstaller.go @@ -67,6 +67,15 @@ type pendingPrompt struct { 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 @@ -676,11 +685,7 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, // 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( - "dependency %s: local file has been modified (hash mismatch). Cannot install with --skip-update-prompts flag when local files have been modified. Either restore the file to match the stored hash or remove the flag to update interactively. %w", - dependency.Name, - err, - ) + 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) @@ -693,7 +698,7 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, if !di.Update && !di.SkipUpdatePrompts { found := false for i := range di.pendingPrompts { - if di.pendingPrompts[i].contractName == dependency.Name { + if di.pendingPrompts[i].matches(dependency.Name, networkName) { di.pendingPrompts[i].needsUpdate = true di.pendingPrompts[i].updateHash = contractDataHash di.pendingPrompts[i].contractAddr = contractAddr @@ -754,16 +759,17 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency, } // Create or overwrite file - if !fileExists || fileModified || (hashMismatch && di.Update) { - err = di.createContractFile(contractAddr, contractName, contractData) - if err != nil { - return fmt.Errorf("error creating contract file: %w", err) - } + shouldWrite := !fileExists || fileModified || (hashMismatch && di.Update) + if !shouldWrite { + return nil + } - if !fileExists { - msg := util.MessageWithEmojiPrefix("✅️", fmt.Sprintf("Contract %s from %s on %s installed", contractName, contractAddr, networkName)) - di.logs.fileSystemActions = append(di.logs.fileSystemActions, msg) - } + 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 From 4a58bf0c67398700fe2aedb5a64c779042b07ff5 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 22 Dec 2025 18:04:12 -0800 Subject: [PATCH 16/16] Remove duplicative error reporting --- internal/dependencymanager/dependencyinstaller.go | 1 - internal/dependencymanager/install.go | 2 -- 2 files changed, 3 deletions(-) diff --git a/internal/dependencymanager/dependencyinstaller.go b/internal/dependencymanager/dependencyinstaller.go index 0baa88f44..0d44b9a07 100644 --- a/internal/dependencymanager/dependencyinstaller.go +++ b/internal/dependencymanager/dependencyinstaller.go @@ -223,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 } } 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 }