@@ -59,12 +59,23 @@ type categorizedLogs struct {
5959type 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+
6879func (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
138163func 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
568602func (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