diff --git a/src/elements/author/Author.go b/src/elements/author/Author.go new file mode 100644 index 0000000..521a2f3 --- /dev/null +++ b/src/elements/author/Author.go @@ -0,0 +1,8 @@ +package author + +type Author struct { + Service int `json:"service"` + Id interface{} `json:"id"` + Username string `json:"username"` + AuthorId interface{} `json:"authorId"` +} \ No newline at end of file diff --git a/src/elements/project/DependencyIdentifier.go b/src/elements/project/DependencyIdentifier.go new file mode 100644 index 0000000..935583c --- /dev/null +++ b/src/elements/project/DependencyIdentifier.go @@ -0,0 +1,8 @@ +package project + +type DependencyIdentifier struct { + Version string `json:"version"` + Resource string `json:"resource"` + Release string `json:"release"` + Hash []string `json:"hash"` +} \ No newline at end of file diff --git a/src/elements/project/DownloadableRelease.go b/src/elements/project/DownloadableRelease.go new file mode 100644 index 0000000..c894279 --- /dev/null +++ b/src/elements/project/DownloadableRelease.go @@ -0,0 +1,247 @@ +package project + +import ( + "archive/zip" + "errors" + "github.com/grifpkg/cli/api" + "github.com/grifpkg/cli/elements/urlSuggestion" + "github.com/segmentio/ksuid" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" +) + +type DownloadableRelease struct { + Url string `json:"url"` + Release Release `json:"release"` + Resource Resource `json:"resource"` + Suggestion interface{} +} + +func (downloadableRelease DownloadableRelease) Install(providedName string, forcedVersion bool, skipDependencies bool, parentResource interface{}, mainModule interface{}) (err error){ + + api.LogOne(api.Progress,"installing release") + project, projectLock, err := GetProject() + if err!= nil { + return err + } + + // 0. check if the resource is a dependency, if it is, unmark it + var resolvedDependencies map[string]Resource = map[string]Resource{} + if !skipDependencies { + resolvedDependencies, err = downloadableRelease.Release.GetResolvedDependencies() + if err!= nil { + return err + } + } + + // download + + filePaths, err := downloadableRelease.DownloadFile() + if err != nil { + return err + } + + // install + + _, err = projectLock.AddDependency(providedName, downloadableRelease, resolvedDependencies, filePaths, parentResource, downloadableRelease.Suggestion, nil) + if err != nil { + return err + } + + var versionTag = downloadableRelease.Release.Version + if !forcedVersion { + versionTag = "^" + downloadableRelease.Release.Version + } + if parentResource == nil { + project.Dependencies["@"+downloadableRelease.Resource.Author.Username+"/"+providedName]=versionTag + } + + err = project.Save() + if err != nil { + return err + } + err = projectLock.Save() + if err != nil { + return err + } + + return nil +} + +func (downloadableRelease DownloadableRelease) DownloadFile() (paths []string, err error) { + + // download + api.LogOne(api.Progress,"downloading file") + + resp, err := http.Get(downloadableRelease.Url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + path, err := os.Getwd() + if err != nil { + return nil, err + } + project, _, err := GetProject() + if err!= nil { + return nil, err + } + + addedPath, folderPath := project.GetExpectedFilePath(downloadableRelease.Release.FileName.(string), downloadableRelease.Release.FileExtension.(string), 0) + + finalPath := path+string(os.PathSeparator)+addedPath + mainFilePath := finalPath+downloadableRelease.Release.FileName.(string) + + err = os.Mkdir(finalPath, 0700) + if err != nil && !os.IsExist(err) { + return nil, err + } + out, err := os.Create(mainFilePath) + if err != nil && !os.IsExist(err) { + return nil, err + } + defer out.Close() + _, err = io.Copy(out, resp.Body) + if err != nil { + return nil, err + } + + // unzip if necessary + var movedFiles = make([]string, 0) + if downloadableRelease.Release.FileExtension=="zip" { + movedFiles, err = handleZip(folderPath, mainFilePath) + if err != nil { + return nil, err + } + } + + // return paths + if downloadableRelease.Release.FileExtension=="zip" { + if len(movedFiles)>0 { + return movedFiles, nil + } else { + return nil, errors.New("no files were moved, please, check if the returned zip file was empty or if you are ignoring all contents") + } + } else { + return append(movedFiles, mainFilePath), nil + } +} +func unzip(src string, dest string) ([]string, error) { + + var filenames []string + + r, err := zip.OpenReader(src) + if err != nil { + return filenames, err + } + defer r.Close() + + for _, f := range r.File { + fpath := filepath.Join(dest, f.Name) + if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) { + return filenames, errors.New("illegal file path") + } + filenames = append(filenames, fpath) + if f.FileInfo().IsDir() { + err := os.MkdirAll(fpath, os.ModePerm) + if err != nil { + return nil, err + } + continue + } + if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil { + return filenames, err + } + outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return filenames, err + } + rc, err := f.Open() + if err != nil { + return filenames, err + } + _, err = io.Copy(outFile, rc) + err = outFile.Close() + if err != nil { + return nil, err + } + err = rc.Close() + if err != nil { + return nil, err + } + if err != nil { + return filenames, err + } + } + return filenames, nil +} + +func handleZip(folderPath string, zipPath string) (filePaths []string, err error) { + api.LogOne(api.Progress,"handling zip file") + filePaths = make([]string, 0) + separator := string(os.PathSeparator) + api.LogOne(api.Progress,"creating temporal folder") + tempFolder := folderPath+ksuid.New().String()+separator + _ = os.Mkdir(tempFolder, 0700) + api.LogOne(api.Progress,"unzipping") + _, err = unzip(zipPath, tempFolder) + if err != nil { + return nil, err + } + project, _, err := GetProject() + if err != nil { + return nil, err + } + api.LogOne(api.Progress,"walking through the extracted files") + err = filepath.Walk(tempFolder, + func(path string, info os.FileInfo, err error) error { + if !info.IsDir() { + excluded := false + for _, excludeSchema := range project.ExcludeFiles { + res, _ := regexp.MatchString(excludeSchema, info.Name()) + if res { + excluded = true + break + } + } + if !excluded { + _, filePath := project.GetExpectedFilePath(info.Name(), filepath.Ext(info.Name()), 0) + err = os.Rename(path, filePath) + filePaths = append(filePaths, filePath) + } + } + return nil + }) + api.LogOne(api.Progress,"removing zip file") + err = os.Remove(zipPath) + if err != nil { + return nil, err + } + api.LogOne(api.Progress,"removing temporal folder") + err = os.RemoveAll(tempFolder) + if err != nil { + return nil, err + } + return filePaths, err +} + +func DownloadableReleaseFromSuggestion(suggestion urlSuggestion.UrlSuggestion, release Release, resource interface{}) DownloadableRelease { + var resourceFinal = Resource{} + if resource!=nil{ + resourceFinal = resource.(Resource) + } + return DownloadableRelease{suggestion.Url,Release{release.Id,release.Service,release.ReleaseId,release.Version,release.Creation,suggestion.FileName,extensionFromFileName(suggestion.FileName),0,nil,nil,nil,nil,nil,nil,nil,true, resourceFinal},resourceFinal, suggestion} +} + +func extensionFromFileName(fileName string) interface{} { + parts := strings.Split(fileName,".") + if len(parts) > 1 { + return parts[len(parts)-1] + } + return nil +} \ No newline at end of file diff --git a/src/elements/project/Project.go b/src/elements/project/Project.go new file mode 100644 index 0000000..7a48be0 --- /dev/null +++ b/src/elements/project/Project.go @@ -0,0 +1,330 @@ +package project + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/AlecAivazis/survey/v2" + "github.com/grifpkg/cli/api" + "io/ioutil" + "os" + "strconv" + "strings" +) + +var loadedProject bool = false +var openedProject Project = Project{} +var loadedProjectLock bool = false +var openedProjectLock ProjectLock = ProjectLock{} + +func GetProject() (project *Project, lock *ProjectLock, err error){ + if !loadedProject { + openedProject, err = Load() + if err != nil { + return &openedProject, &openedProjectLock, err + } + loadedProject=true + } + if !loadedProjectLock { + openedProjectLock, err = LoadLock() + if err != nil { + return &openedProject, &openedProjectLock, err + } + loadedProjectLock=true + } + return &openedProject, &openedProjectLock, nil +} + +func (project Project) InstallAll() (release []Release, err error) { + _, lock, err := GetProject() + if err != nil { + return nil, err + } + + api.LogOne(api.Progress, "matching the resources on the project file with the project-lock file") + var dependencyMatch = make(map[string]Dependency, 0) + + for _, dependency := range lock.Dependencies { + if !dependency.InstalledAsDependency { + api.LogOne(api.Progress, "matching "+dependency.Resource.Readable +"with "+dependency.Resource.Resolved) + if _, ok := project.Dependencies["@"+dependency.Author.Readable+"/"+dependency.Resource.Readable]; ok { + dependencyMatch["@"+dependency.Author.Readable+"/"+dependency.Resource.Readable]=dependency + } else { + api.LogOne(api.Progress, "removing orphan resource on the project-lock: "+dependency.Resource.Readable + " ("+dependency.Resource.Resolved+")") + err := lock.remove(dependency.Resource.Resolved) + if err != nil { + return nil, err + } + api.LogOne(api.Info, "removed orphan resource on the project-lock: "+dependency.Resource.Readable + " ("+dependency.Resource.Resolved+")") + } + } + } + + api.LogOne(api.Info, "found "+strconv.Itoa(len(dependencyMatch))+" cached resource(s) out of "+strconv.Itoa(len(project.Dependencies))+" to be installed") + for installString, versionTag := range project.Dependencies { + if _, ok := dependencyMatch[installString]; !ok { + // a dependency was found on the project file but not on the lock file, resolving the resource and adding it to the lock + resourceName, resourceAuthor, version := project.ParseInstallString(installString, versionTag) + api.LogOne(api.Progress, "installing "+resourceName+" ("+versionTag+") (not available on the project lock)") + resource, release, err := project.Install(resourceName, version, resourceAuthor, nil) + if err != nil { + api.LogOne(api.Warn, "error while installing "+resourceName+" ("+versionTag+"): "+err.Error()) + } else { + api.LogOne(api.Success, "installed "+resource.Name+" (as \""+resourceName+"\") ("+release.Version+") (wasn't available on the project lock)") + } + } + } + + for _, dependency := range dependencyMatch { + api.LogOne(api.Progress, "installing "+dependency.Resource.Readable+" ("+dependency.Release.Readable+")") + err := dependency.EnsureInstall() + if err != nil { + api.LogOne(api.Warn, "error while installing "+dependency.Resource.Readable+" ("+dependency.Release.Readable+"): "+err.Error()) + } + api.LogOne(api.Success, "installed "+dependency.Resource.Readable+" ("+dependency.Release.Readable+")") + } + + err = project.Save() + if err != nil { + return nil, err + } + err = lock.Save() + if err != nil { + return nil, err + } + return nil, nil +} + +func (project Project) ParseInstallString(string string, version interface{}) (resourceName string, resourceAuthor interface{}, versionTag interface{}){ + if version == nil || (version != nil && strings.HasPrefix(fmt.Sprintf("%v", version),"^")) { + versionTag=nil + } else { + versionTag=fmt.Sprintf("%v", version) + } + if strings.HasPrefix(string,"@") && strings.Contains(string,"/"){ + resourceAuthor=strings.Split(strings.TrimPrefix(string,"@"),"/")[0] + resourceName=strings.ReplaceAll(string,"@"+fmt.Sprintf("%v", resourceAuthor)+"/","") + } else { + resourceAuthor=nil + resourceName=string + } + return resourceName, resourceAuthor, versionTag +} + +func (project Project) GetExpectedFilePath(fileName string, fileExtension string, dependencyType int) (folderPath string, filePath string){ + var defaultPath string = "" + for _, path := range project.InstallPaths { + path.Path = strings.ReplaceAll(strings.ReplaceAll(path.Path,"./",""),"/",string(os.PathSeparator)) + if path.Extension == fileExtension { + if path.Type == -1 { + defaultPath = path.Path + } else if path.Extension == fileExtension && path.Type == dependencyType { + return path.Path, path.Path+fileName + } + } + } + return defaultPath, defaultPath+fileName +} + +type Project struct { + Name string `json:"name"` + Game string `json:"game"` + Version string `json:"version"` + Software string `json:"software"` + Dependencies map[string]string `json:"dependencies"` + InstallPaths []InstallPath `json:"installPaths"` + ExcludeFiles []string `json:"excludeFiles"` +} + +type InstallPath struct { + Type int `json:"type"` + Extension string `json:"extension"` + Path string `json:"path"` +} + +func getDefaultInstallPaths() []InstallPath { + return []InstallPath{ + { + Type: 0, + Extension: "jar", + Path: "./plugins/", + }, + { + Type: 0, + Extension: "sk", + Path: "./plugins/Skript/scripts/", + }, + { + Type: -1, + Extension: "*", + Path: "./packages/", + }, + } +} + +func (project Project) Install(resourceName string, version interface{}, resourceAuthor interface{}, service interface{}) (resource Resource, release Release, err error){ + resources, err := QueryResources(resourceName, resourceAuthor, service) + if err != nil { + return Resource{}, Release{}, err + } + if len(resources)<=0 { + return Resource{}, Release{}, errors.New("couldn't find a suitable resource") + } + resource = resources[0] + release, err = resource.GetRelease(version, nil) + if err != nil { + return Resource{}, Release{}, err + } + downloadable, err := release.GetDownloadable(nil) + if err != nil { + return Resource{}, Release{}, err + } + err = downloadable.Install(resourceName,version!=nil,false,nil,nil) + if err != nil { + return Resource{}, Release{}, err + } + return resource, release, nil +} + +func (project Project) AddExclude(exclude... string) (excluded []string, err error){ + for _, arg := range exclude { + found:=false + for _, b := range project.ExcludeFiles { + if b == arg { + found=true + break + } + } + if !found { + project.ExcludeFiles = append(project.ExcludeFiles, arg) + } + } + err = project.Save() + if err != nil { + return nil, err + } + return project.ExcludeFiles, err +} + +func getProjectFile(lockFile bool) (string, error){ + path, err := os.Getwd() + var fileName = "project.json" + if lockFile { + fileName="project-lock.json" + } + return path+string(os.PathSeparator)+fileName, err +} + +func createProjectFile(path string) (project Project, err error){ + + project = Default() + api.LogOne(api.Progress,"creating project file") + answers := struct { + Name string + Game string + Version string + Software string + }{} + + api.Ask([]*survey.Question{ + { + Name: "name", + Prompt: &survey.Input{ + Message: "What's the name of this project/server?", + Default: "unnamed", + }, + }, + { + Name: "game", + Prompt: &survey.Select{ + Message: "Please, select a game", + Options: []string{"@minecraft/java"}, + Default: "@minecraft/java", + }, + }, + { + Name: "version", + Prompt: &survey.Input{ + Message: "Which game version is this server running on?", + Default: "latest", + }, + }, + { + Name: "software", + Prompt: &survey.Select{ + Message: "Which server software are you using (if you are using a fork, select the closest parent)", + Options: []string{"@spigotmc/spigot","@spigotmc/bungeecord","@papermc/paper","@papermc/waterfall"}, + Default: "@minecraft/java", + }, + }, + },&answers) + + project.Name=answers.Name + project.Software=answers.Software + project.Game=answers.Game + project.Version=answers.Version + + api.LogOne(api.Progress,"encoding project file") + file, err := json.MarshalIndent(project, "", " ") + if err!=nil { + return Project{},err + } + + api.LogOne(api.Progress,"saving project file") + err = ioutil.WriteFile(path, file, 0644) + if err!=nil { + return Project{},err + } + return project,err +} + +func Load() (project Project, err error) { + api.LogOne(api.Progress,"loading project file") + project = Default() + err = nil + finalPath, err := getProjectFile(false) + if err == nil { + file, err := os.Open(finalPath) + if os.IsNotExist(err){ + err = nil // not an error, file doesn't exist, but we can create one + project, err = createProjectFile(finalPath) + if err!=nil { + return Project{},err + } + } else if err==nil { + api.LogOne(api.Progress,"reading project file") + byteValue, err := ioutil.ReadAll(file) + if err!=nil { + return Project{},err + } + + api.LogOne(api.Progress,"decoding project file") + err = json.Unmarshal(byteValue, &project) + if err!=nil { + return Project{},err + } + } + } + return project, err +} + +func Default() Project { + return Project{ + Name: "unnamed", + Game: "@minecraft/java", + Version: "latest", + Software: "@spigotmc/spigot", + InstallPaths: getDefaultInstallPaths(), + Dependencies: make(map[string]string, 0), + ExcludeFiles: make([]string, 0), + } +} + +func (project Project) Save() (err error){ + file, err := json.MarshalIndent(project, "", "\t") + if err != nil { + return err + } + err = ioutil.WriteFile("project.json", file, 0644) + return err +} \ No newline at end of file diff --git a/src/elements/project/ProjectLock.go b/src/elements/project/ProjectLock.go new file mode 100644 index 0000000..4510801 --- /dev/null +++ b/src/elements/project/ProjectLock.go @@ -0,0 +1,487 @@ +package project + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + "errors" + "github.com/djherbis/times" + "github.com/grifpkg/cli/api" + "github.com/grifpkg/cli/elements/urlSuggestion" + "io/ioutil" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +type ProjectLock struct { + Dependencies []Dependency `json:"dependencies"` // dependency list +} + +func (projectLock *ProjectLock) AddDependency(providedName string, downloadableRelease DownloadableRelease, resolvedDependencies map[string]Resource, filePaths []string, parent interface{}, suggestion interface{}, mainModule interface{}) (dependency Dependency, err error){ + + // 1. check if the dependency is already there + + api.LogOne(api.Progress,"looking for existing dependencies") + var newDependency bool = false + var existingDependency interface{} = nil + for _, dependency := range projectLock.Dependencies { + if dependency.Resource.Resolved==downloadableRelease.Resource.Id { + existingDependency = dependency + break + } + } + if existingDependency==nil { + api.LogOne(api.Progress,"creating dependency") + + // 1.1 if not, create it + + newDependency = true + existingDependency = Dependency{ + Author: CreateCheckableHash(downloadableRelease.Resource.Author.Username,downloadableRelease.Resource.Author.Id.(string)), + Resource: CreateCheckableHash(providedName,downloadableRelease.Resource.Id), + Release: CreateCheckableHash(downloadableRelease.Release.Version,downloadableRelease.Release.Id), + DependencyOf: make([]string, 0), + MainModule: mainModule, + InstalledAsDependency: parent!=nil, + } + } + + // 2. recalculate the dependency tree + api.LogOne(api.Progress,"calculating dependency tree") + + var resolvedDependenciesHashed []CheckableHash = make([]CheckableHash, 0) + var dependencyTreeIds string = downloadableRelease.Resource.Id + for name, resource := range resolvedDependencies { + dependencyChild := CreateCheckableHash(name,resource.Id) + resolvedDependenciesHashed = append(resolvedDependenciesHashed, dependencyChild) + dependencyTreeIds+=resource.Id + // get release from dependency and append it to the dependency list + release, err := resource.GetRelease(nil, nil) + if err != nil { + return Dependency{}, err + } + downloadable, err := release.GetDownloadable(nil) + if err != nil { + return Dependency{}, err + } + err = downloadable.Install(name,false,false,downloadableRelease.Resource, nil) + if err != nil { + return Dependency{}, err + } + } + if !newDependency { + api.LogOne(api.Progress,"recalculating usages") + + // 2.1 if the dependency already existed, check for dropped dependencies + + var dependenciesToRemoveUsageFrom []CheckableHash + for _, existingHashedDependency := range existingDependency.(Dependency).ResolvedDependencies { + for _, hashedDependency := range resolvedDependenciesHashed { + foundDependency := false + if existingHashedDependency.Hash==hashedDependency.Hash { + foundDependency = true + break + } + if !foundDependency { + dependenciesToRemoveUsageFrom = append(dependenciesToRemoveUsageFrom, existingHashedDependency) + } + } + } + + if len(dependenciesToRemoveUsageFrom)>0 { + api.LogOne(api.Progress,"removing dropped usages") + for _, droppedDependency := range dependenciesToRemoveUsageFrom { + err := projectLock.removeUsage(droppedDependency.Resolved, downloadableRelease.Resource.Id) + if err != nil { + return Dependency{}, err + } + } + } + + } + dependencyTreeHash := md5.Sum([]byte(dependencyTreeIds)) + dependency = existingDependency.(Dependency) + + if !newDependency { + if dependency.InstalledAsDependency && parent != nil { + dependency.InstalledAsDependency=false + } + } + + // 3. if the dependency is installed as a child dependency, add the list of parent dependencies using this dependency + + api.LogOne(api.Progress,"recalculating usages on all dependencies") + var dependencyOf []string = dependency.DependencyOf + if parent!=nil { + existsOnUsageList := false + for _, resourceId := range dependencyOf { + if parent.(Resource).Id==resourceId{ + existsOnUsageList = true + break + } + } + if !existsOnUsageList { + dependencyOf = append(dependencyOf, parent.(Resource).Id) + } + } + + // 4. add the suggestion url used if any + + api.LogOne(api.Progress,"specifying fallback suggestion url") + var fallbackSuggestionId interface{} = nil + if suggestion!=nil { + fallbackSuggestionId = suggestion.(urlSuggestion.UrlSuggestion).Id + } + dependency.FallbackSuggestion=fallbackSuggestionId + + // 5. create file-link data + + api.LogOne(api.Progress,"creating file fingerprints") + var fileFingerprints []DependencyFile = make([]DependencyFile, 0) + for _, filePath := range filePaths { + extension := filepath.Ext(filePath) + if strings.HasPrefix(extension, ".") { + extension = strings.TrimPrefix(extension,".") + } + fingerprint, err := fileFingerprint(filePath) + if err != nil { + return Dependency{}, err + } + fileFingerprints = append(fileFingerprints, DependencyFile{ + Fingerprint: fingerprint, + Extension: extension, + }) + } + + // 6. fill the object with all the data generated + + api.LogOne(api.Progress,"filling dependency data") + dependency.ResolvedDependencies=resolvedDependenciesHashed + dependency.DependencyTreeHash=hex.EncodeToString(dependencyTreeHash[:]) + dependency.DependencyOf=dependencyOf + dependency.Fingerprints=fileFingerprints + + // 7. submit the data to the lock file + + api.LogOne(api.Progress,"submitting dependency to the lock file") + if newDependency { + projectLock.Dependencies = append(projectLock.Dependencies, dependency) + } else { + err := projectLock.mod(dependency.Resource.Resolved, dependency) + if err != nil { + return Dependency{}, err + } + } + + return dependency,err +} + +func (projectLock *ProjectLock) deleteFile(fingerprint string, extension string) (err error){ + api.LogOne(api.Progress,"deleting file") + return nil +} + +func (projectLock *ProjectLock) remove(droppedId string) (err error){ + api.LogOne(api.Progress,"removing dependency and looking for usages") + for i, dependency := range projectLock.Dependencies { + if dependency.Resource.Resolved==droppedId { + if dependency.InstalledAsDependency && len(dependency.DependencyOf) > 0 { + return errors.New("this resource is a dependency of another resource") + } + projectLock.Dependencies = append(projectLock.Dependencies[:i], projectLock.Dependencies[i+1:]...) + for _, fingerprint := range dependency.Fingerprints { + err := projectLock.deleteFile(fingerprint.Fingerprint,fingerprint.Extension) + if err != nil { + return err + } + } + return nil + } + } + return errors.New("unknown dependency to remove") +} + +func (projectLock *ProjectLock) removeUsage(droppedId string, parentId string) (err error){ + api.LogOne(api.Progress,"removing usage") + for _, dependency := range projectLock.Dependencies { + if dependency.Resource.Resolved==droppedId { + foundIndex := -1 + for o, parentUsage := range dependency.DependencyOf { + if parentUsage == parentId { + foundIndex = o + break + } + } + if foundIndex > -1 { + dependency.DependencyOf = append(dependency.DependencyOf[:foundIndex], dependency.DependencyOf[foundIndex+1:]...) + if len(dependency.DependencyOf) <= 0 && dependency.InstalledAsDependency { + err := projectLock.mod(droppedId,dependency) + if err != nil { + return err + } + err = projectLock.remove(droppedId) + if err != nil { + return err + } + } else { + err := projectLock.mod(droppedId,dependency) + if err != nil { + return err + } + } + return nil + } else { + return errors.New("the dropped dependency isn't being used by the defined parent dependency") + } + } + } + return errors.New("unknown child dependency") +} + +func (projectLock *ProjectLock) mod(resourceId string, newDependency Dependency) (err error){ + for i, dependency := range projectLock.Dependencies { + if dependency.Resource.Resolved==resourceId { + projectLock.Dependencies[i]=newDependency + return nil + } + } + return errors.New("unknown dependency") +} + +func fileFingerprint(path string) (fingerprint string, err error){ + api.LogOne(api.Progress,"creating fingerprint") + t, err := times.Stat(path) + if err != nil { + return "", err + } + var timeVariant time.Time + if t.HasBirthTime() { + timeVariant = t.BirthTime() + } else if t.HasChangeTime() { + timeVariant = t.ChangeTime() + } else { + return "", errors.New("invalid time variant while generating file fingerprint") + } + fi, err := os.Stat(path) + if err != nil { + return "", err + } + // get the size + size := fi.Size() + fingerprint = strconv.FormatInt(size, 10) + strconv.FormatInt(timeVariant.UnixMicro(), 10) + fingerprintHash := md5.Sum([]byte(fingerprint)) + return hex.EncodeToString(fingerprintHash[:]), nil +} + +type Dependency struct { + Type int `json:"type"` // dependency type, 0 for plugin, 1 for mod, -1 for unknown + Author CheckableHash `json:"author"` // author is technically the most restrictive selector for resource querying, so project.json tries to find the resource by name on matching author names + Resource CheckableHash `json:"resource"` // make sure the readable resource actually IS the resource on the id using a hash + Release CheckableHash `json:"release"` // make sure the readable version actually IS the version on the id using a hash + ResolvedDependencies []CheckableHash `json:"resolvedDependencies"` // the resolved non-optional dependencies as check-able hashes to make sure the installed dependencies are those shown on the readable value + DependencyTreeHash string `json:"dependencyTreeHash"` // hash based on all the resolved dependencies to avoid altering the dependency list solely based on the array + DependencyOf []string `json:"dependencyOf"` // the list of resource ids that list this as a dependency + FallbackSuggestion interface{} `json:"fallbackSuggestion"` // fallback url suggestion + Fingerprints []DependencyFile `json:"files"` // file info + InstalledAsDependency bool `json:"installedAsDependency"` // install mode, false if not a child dependency. if true, it will get deleted if no other dependencies are using it + MainModule interface{} `json:"mainModule"` // check-able hash, this will only be available if this dependency is a sub-module, when uninstalling the core resource, all the sub-modules get removed too +} + +type DependencyFile struct { + Fingerprint string `json:"fingerprint"` + Extension string `json:"extension"` +} + +func (dependency *Dependency) AddParentUsage(parent Dependency){ + for _, existingUsage := range dependency.DependencyOf { + if existingUsage == parent.Resource.Resolved { + return + } + } + dependency.DependencyOf = append(dependency.DependencyOf,parent.Resource.Readable) +} + +func (dependency *Dependency) EnsureInstall() (err error){ + project, lock, err := GetProject() + if err!= nil { + return err + } + + // 1. resolve the file fingerprints for all files on a folder that might contain the file we are looking for + + fingerprints := make(map[string]map[string]string, 0) // [extension][fingerprint]=filename + wantedExtensions := make([]string, 0) + wd, err := os.Getwd() + wd += string(os.PathSeparator) + if err != nil { + if !os.IsNotExist(err) { + return err + } + } + + for _, fingerprint := range dependency.Fingerprints { + found := false + for _, b := range wantedExtensions { + if b == fingerprint.Extension { + found = true + break + } + } + if !found { + wantedExtensions = append(wantedExtensions, fingerprint.Extension) + } + } + for _, extension := range wantedExtensions { + folderPath, _ := project.GetExpectedFilePath("exampleFileName."+extension,extension,0) + fingerprints[extension] = make(map[string]string, 0) + files, err := ioutil.ReadDir(wd+folderPath) + if err != nil { + if !os.IsNotExist(err) { + return err + } + } + for _, file := range files { + if !file.IsDir() && strings.TrimPrefix(filepath.Ext(file.Name()),".") == extension { + fingerprint, err := fileFingerprint(wd+folderPath+file.Name()) + if err != nil { + return err + } + fingerprints[extension][fingerprint]=folderPath+file.Name() + } + } + } + + // 2. check if the fingerprint is present + + missingFiles := false + for _, fingerprint := range dependency.Fingerprints { + foundFingerprint := false + for existingFileFingerprint, _ := range fingerprints[fingerprint.Extension] { + if existingFileFingerprint==fingerprint.Fingerprint { + foundFingerprint=true + break + } + } + if !foundFingerprint { + missingFiles=true + break + } + } + + + // 3. if the files are missing, download them + if missingFiles { + release := dependency.AsRelease() + downloadable, err := release.GetDownloadable(dependency.FallbackSuggestion) + if err != nil { + return err + } + _, err = downloadable.DownloadFile() + if err != nil { + return err + } + } + + // 4. ensure dependencies are installed + for _, resolvedDependency := range dependency.ResolvedDependencies { + subDependency := lock.GetDependency(resolvedDependency.Resolved) + err := subDependency.EnsureInstall() + if err != nil { + return err + } + } + return nil +} + +func (projectLock ProjectLock) GetDependency(id string) (dependency Dependency){ + for _, existingDependency := range projectLock.Dependencies { + if existingDependency.Resource.Resolved == id { + return existingDependency + } + } + return Dependency{} +} + +func (dependency Dependency) AsRelease() (release Release) { + return Release { + Id: dependency.Release.Resolved, + Version: dependency.Release.Readable, + Parent: Resource { + Id: dependency.Resource.Resolved, + Name: dependency.Resource.Readable, + }, + } +} + +type CheckableHash struct { + Readable string `json:"name"` // readable, user-input value + Resolved string `json:"id"` // resolved, actual value + Hash string `json:"hash"` // readable+resolved +} + +func CreateCheckableHash(readableValue string, resolvedValue string) (checkableHash CheckableHash){ + hash := md5.Sum([]byte(readableValue+resolvedValue)) + return CheckableHash{ + Readable: readableValue, + Resolved: resolvedValue, + Hash: hex.EncodeToString(hash[:]), + } +} + + +func LoadLock() (projectLock ProjectLock, err error) { + api.LogOne(api.Progress,"loading project-lock file") + projectLock = ProjectLock{ + Dependencies: make([]Dependency, 0), + } + err = nil + finalPath, err := getProjectFile(true) + if err == nil { + file, err := os.Open(finalPath) + if os.IsNotExist(err){ + err = nil // not an error, file doesn't exist, but we can create one + + api.LogOne(api.Progress,"encoding project-lock file") + file, err := json.MarshalIndent(projectLock, "", " ") + if err!=nil { + return ProjectLock{},err + } + + api.LogOne(api.Progress,"saving project-lock file") + err = ioutil.WriteFile(finalPath, file, 0644) + if err!=nil { + return ProjectLock{},err + } + if err!=nil { + return ProjectLock{},err + } + } else if err==nil { + api.LogOne(api.Progress,"reading project-lock file") + byteValue, err := ioutil.ReadAll(file) + if err!=nil { + return ProjectLock{},err + } + + api.LogOne(api.Progress,"decoding project-lock file") + err = json.Unmarshal(byteValue, &projectLock) + if err!=nil { + return ProjectLock{},err + } + } + } else { + return ProjectLock{},err + } + return projectLock, err +} + +func (projectLock ProjectLock) Save() (err error){ + file, err := json.MarshalIndent(projectLock, "", "\t") + if err != nil { + return err + } + err = ioutil.WriteFile("project-lock.json", file, 0644) + return err +} diff --git a/src/elements/project/Release.go b/src/elements/project/Release.go new file mode 100644 index 0000000..b304704 --- /dev/null +++ b/src/elements/project/Release.go @@ -0,0 +1,169 @@ +package project + +import ( + "encoding/json" + "errors" + "github.com/AlecAivazis/survey/v2" + "github.com/grifpkg/cli/api" + "github.com/grifpkg/cli/elements/session" + "github.com/grifpkg/cli/elements/urlSuggestion" + "io" + "strings" +) + +type Release struct { + Id string `json:"id"` + Service int `json:"service"` + ReleaseId string `json:"releaseId"` + Version string `json:"version"` + Creation int `json:"creation"` + FileName interface{} `json:"fileName"` + FileExtension interface{} `json:"fileExtension"` + FileSize interface{} `json:"fileSize"` + ManifestName interface{} `json:"manifestName"` + ManifestAuthors interface{} `json:"manifestAuthors"` + ManifestVersion interface{} `json:"manifestVersion"` + ManifestMain interface{} `json:"manifestMain"` + ManifestVersionAPI interface{} `json:"manifestVersionAPI"` + ManifestDependencies interface{} `json:"manifestDependencies"` + ManifestOptionalDependencies interface{} `json:"manifestOptionalDependencies"` + HasSuggestions interface{} `json:"hasSuggestions"` + Parent interface{} +} + +func (release Release) GetResolvedDependencies() (dependencies map[string]Resource, err error){ + api.LogOne(api.Progress,"resolving dependencies") + var resolvedDependencies map[string]Resource = map[string]Resource{} + if release.ManifestDependencies != nil { + for _, dependency := range release.ManifestDependencies.([]interface{}) { + dependencyName := dependency.(string) + resources, err := QueryResources(dependencyName, nil, nil) + if err != nil { + return nil, err + } + if len(resources)<=0 { + return nil, errors.New("no suitable resource found while resolving the dependency '"+dependencyName+"'") + } + resolvedDependencies[dependencyName] = resources[0] + } + } + return resolvedDependencies, nil +} + +func (release Release) ListSuggestions() (suggestions []urlSuggestion.UrlSuggestion, err error){ + api.LogOne(api.Progress, "querying url suggestions") + request, err := api.Request("resource/release/suggestion/list/", map[string]string{ + "release": release.Id, + }, nil) + if err!=nil{ + return []urlSuggestion.UrlSuggestion{}, nil + } + suggestions = make([]urlSuggestion.UrlSuggestion,0) + err = json.NewDecoder(request).Decode(&suggestions) + return suggestions, err +} + +func (release Release) GetDownloadable(suggestionIdFallback interface{}) (downloadableRelease DownloadableRelease, err error) { + api.LogOne(api.Progress, "requesting downloadable release") + downloadableRelease = DownloadableRelease{} + if release.HasSuggestions!=nil { + + if release.HasSuggestions == true { + suggestionList, err := release.ListSuggestions() + if err!=nil { + return DownloadableRelease{}, nil + } + var selectedSuggestion = urlSuggestion.UrlSuggestion{} + + var optionList []string + for _, suggestion := range suggestionList { + if suggestion.Id==suggestionIdFallback { + api.LogOne(api.Progress, "a predefined url suggestion was found on the suggestion list, building suggestion based on that one") + selectedSuggestion=suggestion + break + } + optionList = append(optionList, suggestion.Url + " ("+suggestion.Id+")") + } + + if (selectedSuggestion == urlSuggestion.UrlSuggestion{}) { + api.LogOne(api.Progress, "there was no predefined url suggestion for this release, asking for one") + + // no suggestion match a fallback suggestion, therefore we ask for a new one + optionList = append(optionList, "suggest another URL") + optionList = append(optionList, "skip") + + answers := struct { + Selection string + }{} + + api.Ask([]*survey.Question{ + { + Name: "selection", + Prompt: &survey.Select{ + Message: "this resource is hosted externally, here is a list of suggested download URLs, note these URLs are NOT verified and may not point to the desired target, UAYOR", + Options: optionList, + }, + }, + }, &answers) + + if strings.HasPrefix(answers.Selection,"suggest another URL"){ + return DownloadableRelease{}, errors.New("suggest a download URL here: https://grifpkg.com/suggest/"+release.Parent.(Resource).Id) + } else if strings.HasPrefix(answers.Selection,"skip") { + return DownloadableRelease{}, errors.New("skipped release") + } else { + api.LogOne(api.Progress, "matching selected suggestion to a suggestion object") + spacedParts := strings.Split(answers.Selection," ") + var resultSuggestionId = "" + resultSuggestionId = strings.ReplaceAll(spacedParts[len(spacedParts)-1],"(","") + resultSuggestionId = strings.ReplaceAll(resultSuggestionId,")","") + selectedSuggestion = urlSuggestion.UrlSuggestion{} + for _, suggestion := range suggestionList { + if suggestion.Id==resultSuggestionId { + selectedSuggestion=suggestion + break + } + } + if (selectedSuggestion == urlSuggestion.UrlSuggestion{}) { + return DownloadableRelease{}, errors.New("url suggestion selection mismatch") + } + } + } + + // notify usage + err = selectedSuggestion.Use() + if err != nil { + return DownloadableRelease{}, errors.New("couldn't register url suggestion usage") + } + + api.LogOne(api.Progress, "building url suggestion") + downloadableRelease = DownloadableReleaseFromSuggestion(selectedSuggestion,release,release.Parent) + } else { + return DownloadableRelease{}, errors.New("this release is hosted externally and no valid download URLs have been suggested, please, suggest a download URL for this release here: https://grifpkg.com/suggest/"+release.Parent.(Resource).Id) + } + } else { + + // gets downloadable release + + var request interface{} = nil + if release.Parent != nil && release.Parent.(Resource).Paid { + request, err = api.Request("resource/release/download/", map[string]string{ + "release": release.Id, + }, session.GetHash()) + } else { + request, err = api.Request("resource/release/download/", map[string]string{ + "release": release.Id, + }, nil) + } + if err != nil { + return DownloadableRelease{}, err + } + downloadableRelease = DownloadableRelease{} + err = json.NewDecoder(request.(io.Reader)).Decode(&downloadableRelease) + if err != nil { + return DownloadableRelease{}, err + } + + } + + return downloadableRelease, err +} \ No newline at end of file diff --git a/src/elements/project/Resource.go b/src/elements/project/Resource.go new file mode 100644 index 0000000..d3ca028 --- /dev/null +++ b/src/elements/project/Resource.go @@ -0,0 +1,69 @@ +package project + +import ( + "encoding/json" + "github.com/grifpkg/cli/api" + "github.com/grifpkg/cli/elements/author" +) + +type Resource struct { + Id string `json:"id"` + Service int `json:"service"` + ResourceId string `json:"resourceId"` + Paid bool `json:"paid"` + Name string `json:"name"` + Author author.Author `json:"author"` + Downloads int `json:"downloads"` + Ratings int `json:"ratings"` + Rating float64 `json:"rating"` + Description string `json:"description"` +} + + +func QueryResources(name string, author interface{}, service interface{}) ([]Resource, error) { + api.LogOne(api.Progress, "querying resources") + var err error = nil + var data = make(map[string]string, 0) + data["name"]=name + if author!=nil { + data["author"]=author.(string) + } + if service!=nil { + data["service"]= "0" // TODO fix service + } + request, err := api.Request("resource/query/", data, nil) + resourceList := make([]Resource,0) + err = json.NewDecoder(request).Decode(&resourceList) + return resourceList, err +} + +func (resource Resource) GetReleases() (releases []Release, err error){ + api.LogOne(api.Progress, "getting releases") + request, err := api.Request("resource/release/list/", map[string]string{ + "resource":resource.Id, + }, nil) + + releaseList := make([]Release,0) + err = json.NewDecoder(request).Decode(&releaseList) + for _, release := range releaseList { + release.Parent=resource + } + return releaseList, err +} + +func (resource Resource) GetRelease(version interface{}, id interface{}) (releases Release, err error){ + api.LogOne(api.Progress, "getting release") + var data map[string]string = make(map[string]string) + data["resource"] = resource.Id + if version!=nil { + data["version"] = version.(string) + } + if id!=nil { + data["release"] = id.(string) + } + request, err := api.Request("resource/release/get/", data, nil) + release := Release{} + release.Parent=resource + err = json.NewDecoder(request).Decode(&release) + return release, err +} \ No newline at end of file diff --git a/src/elements/urlSuggestion/UrlSuggestion.go b/src/elements/urlSuggestion/UrlSuggestion.go new file mode 100644 index 0000000..b6dad22 --- /dev/null +++ b/src/elements/urlSuggestion/UrlSuggestion.go @@ -0,0 +1,27 @@ +package urlSuggestion + +import ( + "github.com/grifpkg/cli/api" + "github.com/grifpkg/cli/elements/account" +) + +type UrlSuggestion struct { + Id string `json:"id"` + SuggestedBy account.Account `json:"suggestedBy"` + ApprovedBy interface{} `json:"approvedBy"` + Url string `json:"url"` + UrlSchema string `json:"urlSchema"` + JsonURL string `json:"jsonURL"` + JsonPath string `json:"jsonPath"` + FileName string `json:"fileName"` + Creation int `json:"creation"` + Uses int `json:"uses"` +} + +func (suggestion UrlSuggestion) Use() error{ + api.LogOne(api.Progress, "registering url suggestion use") + _, err := api.Request("resource/release/suggestion/use/", map[string]string{ + "suggestion": suggestion.Id, + }, nil) + return err +}