Skip to content

Commit e7ae0ce

Browse files
authored
Fix dependency manager update prompts and filesystem actions (#2226)
1 parent 8219931 commit e7ae0ce

File tree

3 files changed

+1340
-47
lines changed

3 files changed

+1340
-47
lines changed

internal/dependencymanager/dependencyinstaller.go

Lines changed: 176 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,23 @@ type categorizedLogs struct {
5959
type pendingPrompt struct {
6060
contractName string
6161
networkName string
62+
contractAddr string
63+
contractData string
6264
needsDeployment bool
6365
needsAlias bool
6466
needsUpdate bool
6567
updateHash string
6668
}
6769

70+
func (pp *pendingPrompt) matches(name, network string) bool {
71+
return pp.contractName == name && pp.networkName == network
72+
}
73+
74+
func (di *DependencyInstaller) logFileSystemAction(message string) {
75+
msg := util.MessageWithEmojiPrefix("✅", message)
76+
di.logs.fileSystemActions = append(di.logs.fileSystemActions, msg)
77+
}
78+
6879
func (cl *categorizedLogs) LogAll(logger output.Logger) {
6980
logger.Info(util.MessageWithEmojiPrefix("📝", "Dependency Manager Actions Summary"))
7081
logger.Info("") // Add a line break after the section
@@ -102,6 +113,7 @@ type DependencyFlags struct {
102113
skipDeployments bool `default:"false" flag:"skip-deployments" info:"Skip adding the dependency to deployments"`
103114
skipAlias bool `default:"false" flag:"skip-alias" info:"Skip prompting for an alias"`
104115
skipUpdatePrompts bool `default:"false" flag:"skip-update-prompts" info:"Skip prompting to update existing dependencies"`
116+
update bool `default:"false" flag:"update" info:"Automatically accept all dependency updates"`
105117
deploymentAccount string `default:"" flag:"deployment-account,d" info:"Account name to use for deployments (skips deployment account prompt)"`
106118
name string `default:"" flag:"name" info:"Import alias name for the dependency (sets canonical field for Cadence import aliasing)"`
107119
}
@@ -125,17 +137,35 @@ type DependencyInstaller struct {
125137
TargetDir string
126138
SkipDeployments bool
127139
SkipAlias bool
140+
SkipUpdatePrompts bool
141+
Update bool
128142
DeploymentAccount string
129143
Name string
130144
logs categorizedLogs
131145
dependencies map[string]config.Dependency
132146
accountAliases map[string]map[string]flowsdk.Address // network -> account -> alias
133147
installCount int // Track number of dependencies installed
134148
pendingPrompts []pendingPrompt // Dependencies that need prompts after tree display
149+
prompter Prompter // Optional: for testing. If nil, uses real prompts
150+
}
151+
152+
type Prompter interface {
153+
GenericBoolPrompt(msg string) (bool, error)
154+
}
155+
156+
type prompter struct{}
157+
158+
func (prompter) GenericBoolPrompt(msg string) (bool, error) {
159+
return prompt.GenericBoolPrompt(msg)
135160
}
136161

137162
// NewDependencyInstaller creates a new instance of DependencyInstaller
138163
func NewDependencyInstaller(logger output.Logger, state *flowkit.State, saveState bool, targetDir string, flags DependencyFlags) (*DependencyInstaller, error) {
164+
// Validate flags: --update and --skip-update-prompts are mutually exclusive
165+
if flags.update && flags.skipUpdatePrompts {
166+
return nil, fmt.Errorf("cannot use both --update and --skip-update-prompts flags together")
167+
}
168+
139169
emulatorGateway, err := gateway.NewGrpcGateway(config.EmulatorNetwork)
140170
if err != nil {
141171
return nil, fmt.Errorf("error creating emulator gateway: %v", err)
@@ -165,12 +195,15 @@ func NewDependencyInstaller(logger output.Logger, state *flowkit.State, saveStat
165195
TargetDir: targetDir,
166196
SkipDeployments: flags.skipDeployments,
167197
SkipAlias: flags.skipAlias,
198+
SkipUpdatePrompts: flags.skipUpdatePrompts,
199+
Update: flags.update,
168200
DeploymentAccount: flags.deploymentAccount,
169201
Name: flags.name,
170202
dependencies: make(map[string]config.Dependency),
171203
logs: categorizedLogs{},
172204
accountAliases: make(map[string]map[string]flowsdk.Address),
173205
pendingPrompts: make([]pendingPrompt, 0),
206+
prompter: prompter{},
174207
}, nil
175208
}
176209

@@ -190,7 +223,6 @@ func (di *DependencyInstaller) Install() error {
190223
// Phase 1: Process all dependencies and display tree (no prompts)
191224
for _, dependency := range *di.State.Dependencies() {
192225
if err := di.processDependency(dependency); err != nil {
193-
di.Logger.Error(fmt.Sprintf("Error processing dependency: %v", err))
194226
return err
195227
}
196228
}
@@ -556,18 +588,20 @@ func (di *DependencyInstaller) fetchDependenciesWithDepth(dependency config.Depe
556588
return nil
557589
}
558590

559-
func (di *DependencyInstaller) contractFileExists(address, contractName string) bool {
591+
func (di *DependencyInstaller) getContractFilePath(address, contractName string) string {
560592
fileName := fmt.Sprintf("%s.cdc", contractName)
561-
path := filepath.Join("imports", address, fileName)
593+
return filepath.Join("imports", address, fileName)
594+
}
562595

596+
func (di *DependencyInstaller) contractFileExists(address, contractName string) bool {
597+
path := di.getContractFilePath(address, contractName)
563598
_, err := di.State.ReaderWriter().Stat(path)
564-
565599
return err == nil
566600
}
567601

568602
func (di *DependencyInstaller) createContractFile(address, contractName, data string) error {
569-
fileName := fmt.Sprintf("%s.cdc", contractName)
570-
path := filepath.Join(di.TargetDir, "imports", address, fileName)
603+
relativePath := di.getContractFilePath(address, contractName)
604+
path := filepath.Join(di.TargetDir, relativePath)
571605
dir := filepath.Dir(path)
572606

573607
if err := di.State.ReaderWriter().MkdirAll(dir, 0755); err != nil {
@@ -581,14 +615,31 @@ func (di *DependencyInstaller) createContractFile(address, contractName, data st
581615
return nil
582616
}
583617

584-
func (di *DependencyInstaller) handleFileSystem(contractAddr, contractName, contractData, networkName string) error {
618+
// verifyLocalFileIntegrity checks if the local file matches the expected hash in flow.json
619+
func (di *DependencyInstaller) verifyLocalFileIntegrity(contractAddr, contractName, expectedHash string) error {
585620
if !di.contractFileExists(contractAddr, contractName) {
586-
if err := di.createContractFile(contractAddr, contractName, contractData); err != nil {
587-
return fmt.Errorf("failed to create contract file: %w", err)
588-
}
621+
return nil // File doesn't exist, nothing to verify
622+
}
623+
624+
filePath := di.getContractFilePath(contractAddr, contractName)
625+
fileContent, err := di.State.ReaderWriter().ReadFile(filePath)
626+
if err != nil {
627+
return fmt.Errorf("failed to read file for integrity check: %w", err)
628+
}
629+
630+
// Calculate hash of existing file
631+
fileHash := sha256.New()
632+
fileHash.Write(fileContent)
633+
existingFileHash := hex.EncodeToString(fileHash.Sum(nil))
589634

590-
msg := util.MessageWithEmojiPrefix("✅️", fmt.Sprintf("Contract %s from %s on %s installed", contractName, contractAddr, networkName))
591-
di.logs.fileSystemActions = append(di.logs.fileSystemActions, msg)
635+
// Compare hashes
636+
if expectedHash != existingFileHash {
637+
return fmt.Errorf(
638+
"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",
639+
contractName,
640+
expectedHash,
641+
existingFileHash,
642+
)
592643
}
593644

594645
return nil
@@ -598,13 +649,17 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency,
598649
networkName := dependency.Source.NetworkName
599650
contractAddr := dependency.Source.Address.String()
600651
contractName := dependency.Source.ContractName
601-
hash := sha256.New()
602-
hash.Write(program.CodeWithUnprocessedImports())
603-
originalContractDataHash := hex.EncodeToString(hash.Sum(nil))
604652

605653
program.ConvertAddressImports()
606654
contractData := string(program.CodeWithUnprocessedImports())
607655

656+
// Calculate hash of converted contract (what gets written to disk)
657+
// This is what we store in flow.json so we can verify file integrity later
658+
// Imported contracts are still checked for consistency by traversing the dependency tree.
659+
hash := sha256.New()
660+
hash.Write([]byte(contractData))
661+
contractDataHash := hex.EncodeToString(hash.Sum(nil))
662+
608663
existingDependency := di.State.Dependencies().ByName(dependency.Name)
609664

610665
// If a dependency by this name already exists and its remote source network or address does not match,
@@ -620,52 +675,100 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency,
620675
}
621676

622677
// Check if remote source version is different from local version
623-
// If it is, defer the prompt until after the tree is displayed
624-
// If no hash, ignore
625-
if existingDependency != nil && existingDependency.Hash != "" && existingDependency.Hash != originalContractDataHash {
626-
// Find existing pending prompt for this contract or create new one
627-
found := false
628-
for i := range di.pendingPrompts {
629-
if di.pendingPrompts[i].contractName == dependency.Name {
630-
di.pendingPrompts[i].needsUpdate = true
631-
di.pendingPrompts[i].updateHash = originalContractDataHash
632-
found = true
633-
break
678+
// Decide what to do: defer prompt, skip (frozen), or auto-update
679+
hashMismatch := existingDependency != nil && existingDependency.Hash != "" && existingDependency.Hash != contractDataHash
680+
681+
if hashMismatch {
682+
// If skip update prompts flag is set, check if we can keep frozen dependencies
683+
if di.SkipUpdatePrompts && di.contractFileExists(contractAddr, contractName) {
684+
// File exists - verify it matches stored hash
685+
if err := di.verifyLocalFileIntegrity(contractAddr, contractName, existingDependency.Hash); err != nil {
686+
// Local file was modified - FAIL
687+
return fmt.Errorf("cannot install with --skip-update-prompts flag when local files have been modified. %w", err)
634688
}
689+
690+
// File exists and matches stored hash - keep using it (frozen at old version)
691+
return nil
635692
}
636-
if !found {
637-
di.pendingPrompts = append(di.pendingPrompts, pendingPrompt{
638-
contractName: dependency.Name,
639-
networkName: networkName,
640-
needsUpdate: true,
641-
updateHash: originalContractDataHash,
642-
})
693+
694+
// If --update flag is set, auto-accept the update (fall through to install)
695+
// If --skip-update-prompts with no file, install from network (fall through to install)
696+
// Otherwise (normal mode), defer prompt until after tree display
697+
if !di.Update && !di.SkipUpdatePrompts {
698+
found := false
699+
for i := range di.pendingPrompts {
700+
if di.pendingPrompts[i].matches(dependency.Name, networkName) {
701+
di.pendingPrompts[i].needsUpdate = true
702+
di.pendingPrompts[i].updateHash = contractDataHash
703+
di.pendingPrompts[i].contractAddr = contractAddr
704+
di.pendingPrompts[i].contractData = contractData
705+
found = true
706+
break
707+
}
708+
}
709+
if !found {
710+
di.pendingPrompts = append(di.pendingPrompts, pendingPrompt{
711+
contractName: dependency.Name,
712+
networkName: networkName,
713+
contractAddr: contractAddr,
714+
contractData: contractData,
715+
needsUpdate: true,
716+
updateHash: contractDataHash,
717+
})
718+
}
719+
return nil
720+
}
721+
}
722+
723+
// Check if file exists and needs repair (out of sync with flow.json)
724+
fileExists := di.contractFileExists(contractAddr, contractName)
725+
fileModified := false
726+
if fileExists {
727+
if err := di.verifyLocalFileIntegrity(contractAddr, contractName, contractDataHash); err != nil {
728+
fileModified = true
643729
}
644-
return nil
645730
}
646731

647-
// Check if this is a new dependency before updating state
732+
// Install or update: new deps, out-of-sync files, or network updates with --update/--skip-update-prompts
648733
isNewDep := di.State.Dependencies().ByName(dependency.Name) == nil
649734

650-
err := di.updateDependencyState(dependency, originalContractDataHash)
735+
err := di.updateDependencyState(dependency, contractDataHash)
651736
if err != nil {
652737
di.Logger.Error(fmt.Sprintf("Error updating state: %v", err))
653738
return err
654739
}
655740

741+
// Log if this was an auto-update (with --update flag) or file repair
742+
if (hashMismatch || fileModified) && di.Update {
743+
msg := util.MessageWithEmojiPrefix("✅", fmt.Sprintf("%s updated to latest version", dependency.Name))
744+
di.logs.stateUpdates = append(di.logs.stateUpdates, msg)
745+
} else if fileModified {
746+
// File repair without --update flag (common after git clone)
747+
msg := util.MessageWithEmojiPrefix("✅", fmt.Sprintf("%s synced", dependency.Name))
748+
di.logs.stateUpdates = append(di.logs.stateUpdates, msg)
749+
}
750+
656751
// Handle additional tasks for new dependencies or when contract file doesn't exist
657-
// This makes sure prompts are collected for new dependencies regardless of whether contract file exists
658-
if isNewDep || !di.contractFileExists(contractAddr, contractName) {
752+
if isNewDep || !fileExists {
659753
err := di.handleAdditionalDependencyTasks(networkName, dependency.Name)
660754
if err != nil {
661755
di.Logger.Error(fmt.Sprintf("Error handling additional dependency tasks: %v", err))
662756
return err
663757
}
664758
}
665759

666-
err = di.handleFileSystem(contractAddr, contractName, contractData, networkName)
667-
if err != nil {
668-
return fmt.Errorf("error handling file system: %w", err)
760+
// Create or overwrite file
761+
shouldWrite := !fileExists || fileModified || (hashMismatch && di.Update)
762+
if !shouldWrite {
763+
return nil
764+
}
765+
766+
if err := di.createContractFile(contractAddr, contractName, contractData); err != nil {
767+
return fmt.Errorf("error creating contract file: %w", err)
768+
}
769+
770+
if !fileExists {
771+
di.logFileSystemAction(fmt.Sprintf("Contract %s from %s on %s installed", contractName, contractAddr, networkName))
669772
}
670773

671774
return nil
@@ -707,7 +810,7 @@ func (di *DependencyInstaller) handleAdditionalDependencyTasks(networkName, cont
707810
// If the contract is not a core contract and the user does not want to skip aliasing, then collect for prompting later
708811
needsAlias := !di.SkipAlias && !util.IsCoreContract(contractName) && !isDefiActionsContract(contractName)
709812

710-
// Only add to pending prompts if we need to prompt for something
813+
// Only add/update pending prompts if we need to prompt for something
711814
if needsDeployment || needsAlias {
712815
di.pendingPrompts = append(di.pendingPrompts, pendingPrompt{
713816
contractName: contractName,
@@ -900,7 +1003,7 @@ func (di *DependencyInstaller) processPendingPrompts() error {
9001003

9011004
setupDeployments := false
9021005
if hasDeployments {
903-
result, err := prompt.GenericBoolPrompt("Do you want to set up deployments for these dependencies?")
1006+
result, err := di.prompter.GenericBoolPrompt("Do you want to set up deployments for these dependencies?")
9041007
if err != nil {
9051008
return err
9061009
}
@@ -909,7 +1012,7 @@ func (di *DependencyInstaller) processPendingPrompts() error {
9091012

9101013
setupAliases := false
9111014
if hasAliases {
912-
result, err := prompt.GenericBoolPrompt("Do you want to set up aliases for these dependencies?")
1015+
result, err := di.prompter.GenericBoolPrompt("Do you want to set up aliases for these dependencies?")
9131016
if err != nil {
9141017
return err
9151018
}
@@ -920,7 +1023,7 @@ func (di *DependencyInstaller) processPendingPrompts() error {
9201023
for _, pending := range di.pendingPrompts {
9211024
if pending.needsUpdate {
9221025
msg := fmt.Sprintf("The latest version of %s is different from the one you have locally. Do you want to update it?", pending.contractName)
923-
shouldUpdate, err := prompt.GenericBoolPrompt(msg)
1026+
shouldUpdate, err := di.prompter.GenericBoolPrompt(msg)
9241027
if err != nil {
9251028
return err
9261029
}
@@ -932,9 +1035,37 @@ func (di *DependencyInstaller) processPendingPrompts() error {
9321035
di.Logger.Error(fmt.Sprintf("Error updating dependency: %v", err))
9331036
return err
9341037
}
1038+
1039+
// Write the updated contract file (force overwrite)
1040+
if err := di.createContractFile(pending.contractAddr, pending.contractName, pending.contractData); err != nil {
1041+
di.Logger.Error(fmt.Sprintf("Error updating contract file: %v", err))
1042+
return fmt.Errorf("failed to update contract file: %w", err)
1043+
}
1044+
9351045
msg := util.MessageWithEmojiPrefix("✅", fmt.Sprintf("%s updated to latest version", pending.contractName))
9361046
di.logs.stateUpdates = append(di.logs.stateUpdates, msg)
9371047
}
1048+
} else {
1049+
// User chose not to update
1050+
// If file doesn't exist, we MUST fail - can't guarantee frozen deps (no way to fetch old version)
1051+
if !di.contractFileExists(pending.contractAddr, pending.contractName) {
1052+
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)
1053+
}
1054+
1055+
// Get the stored hash from flow.json
1056+
dependency := di.State.Dependencies().ByName(pending.contractName)
1057+
if dependency == nil {
1058+
return fmt.Errorf("dependency %s not found in state", pending.contractName)
1059+
}
1060+
1061+
// Verify the existing file's hash matches what's in flow.json to ensure integrity
1062+
if err := di.verifyLocalFileIntegrity(pending.contractAddr, pending.contractName, dependency.Hash); err != nil {
1063+
return err
1064+
}
1065+
1066+
// File exists and hash matches - keep it at current version
1067+
msg := util.MessageWithEmojiPrefix("⏸️", fmt.Sprintf("%s kept at current version", pending.contractName))
1068+
di.logs.stateUpdates = append(di.logs.stateUpdates, msg)
9381069
}
9391070
}
9401071
}

0 commit comments

Comments
 (0)