diff --git a/Makefile b/Makefile index 8dd91548..b4407f9c 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: runtime -VERSION = $(shell git describe --tags --abbrev=0) +VERSION = $(shell go run tools/build-version.go) HASH = $(shell git rev-parse --short HEAD) DATE = $(shell go run tools/build-date.go) diff --git a/cmd/micro/autocomplete.go b/cmd/micro/autocomplete.go index d0ee3e17..47aeca73 100644 --- a/cmd/micro/autocomplete.go +++ b/cmd/micro/autocomplete.go @@ -149,3 +149,29 @@ func PluginComplete(complete Completion, input string) (chosen string, suggestio } return } + +func PluginCmdComplete(input string) (chosen string, suggestions []string) { + for _, cmd := range []string{"install", "remove", "search", "update", "list"} { + if strings.HasPrefix(cmd, input) { + suggestions = append(suggestions, cmd) + } + } + + if len(suggestions) == 1 { + chosen = suggestions[0] + } + return chosen, suggestions +} + +func PluginNameComplete(input string) (chosen string, suggestions []string) { + for _, pp := range GetAllPluginPackages() { + if strings.HasPrefix(pp.Name, input) { + suggestions = append(suggestions, pp.Name) + } + } + + if len(suggestions) == 1 { + chosen = suggestions[0] + } + return chosen, suggestions +} diff --git a/cmd/micro/command.go b/cmd/micro/command.go index 7d02572d..4adb6948 100644 --- a/cmd/micro/command.go +++ b/cmd/micro/command.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "fmt" "io" "io/ioutil" "os" @@ -40,6 +41,7 @@ var commandActions = map[string]func([]string){ "Help": Help, "Eval": Eval, "ToggleLog": ToggleLog, + "Plugin": PluginCmd, } // InitCommands initializes the default commands @@ -86,6 +88,66 @@ func DefaultCommands() map[string]StrCommand { "help": {"Help", []Completion{HelpCompletion, NoCompletion}}, "eval": {"Eval", []Completion{NoCompletion}}, "log": {"ToggleLog", []Completion{NoCompletion}}, + "plugin": {"Plugin", []Completion{PluginCmdCompletion, PluginNameCompletion}}, + } +} + +// InstallPlugin installs the given plugin by exact name match +func PluginCmd(args []string) { + if len(args) >= 1 { + switch args[0] { + case "install": + for _, plugin := range args[1:] { + pp := GetAllPluginPackages().Get(plugin) + if pp == nil { + messenger.Error("Unknown plugin \"" + plugin + "\"") + } else if !pp.IsInstallable() { + messenger.Error("Plugin \"" + plugin + "\" can not be installed.") + } else { + pp.Install() + } + } + case "remove": + for _, plugin := range args[1:] { + // check if the plugin exists. + for _, lp := range loadedPlugins { + if lp == plugin { + UninstallPlugin(plugin) + continue + } + } + } + case "update": + UpdatePlugins(args[1:]) + case "list": + plugins := GetInstalledVersions(false) + messenger.AddLog("----------------") + messenger.AddLog("The following plugins are currently installed:\n") + for _, p := range plugins { + messenger.AddLog(fmt.Sprintf("%s (%s)", p.pack.Name, p.Version)) + } + messenger.AddLog("----------------") + if len(plugins) > 0 { + if CurView().Type != vtLog { + ToggleLog([]string{}) + } + } + case "search": + plugins := SearchPlugin(args[1:]) + messenger.Message(len(plugins), " plugins found") + for _, p := range plugins { + messenger.AddLog("----------------") + messenger.AddLog(p.String()) + } + messenger.AddLog("----------------") + if len(plugins) > 0 { + if CurView().Type != vtLog { + ToggleLog([]string{}) + } + } + } + } else { + messenger.Error("Not enough arguments") } } diff --git a/cmd/micro/messenger.go b/cmd/micro/messenger.go index a9535b35..36e46889 100644 --- a/cmd/micro/messenger.go +++ b/cmd/micro/messenger.go @@ -192,6 +192,8 @@ const ( CommandCompletion HelpCompletion OptionCompletion + PluginCmdCompletion + PluginNameCompletion ) // Prompt sends the user a message and waits for a response to be typed in @@ -255,6 +257,10 @@ func (m *Messenger) Prompt(prompt, historyType string, completionTypes ...Comple chosen, suggestions = HelpComplete(currentArg) } else if completionType == OptionCompletion { chosen, suggestions = OptionComplete(currentArg) + } else if completionType == PluginCmdCompletion { + chosen, suggestions = PluginCmdComplete(currentArg) + } else if completionType == PluginNameCompletion { + chosen, suggestions = PluginNameComplete(currentArg) } else if completionType < NoCompletion { chosen, suggestions = PluginComplete(completionType, currentArg) } diff --git a/cmd/micro/pluginmanager.go b/cmd/micro/pluginmanager.go new file mode 100644 index 00000000..3f0b5a6f --- /dev/null +++ b/cmd/micro/pluginmanager.go @@ -0,0 +1,606 @@ +package main + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/blang/semver" + "github.com/yuin/gopher-lua" + "github.com/zyedidia/json5/encoding/json5" +) + +var ( + allPluginPackages PluginPackages = nil +) + +// CorePluginName is a plugin dependency name for the micro core. +const CorePluginName = "micro" + +// PluginChannel contains an url to a json list of PluginRepository +type PluginChannel string + +// PluginChannels is a slice of PluginChannel +type PluginChannels []PluginChannel + +// PluginRepository contains an url to json file containing PluginPackages +type PluginRepository string + +// PluginPackage contains the meta-data of a plugin and all available versions +type PluginPackage struct { + Name string + Description string + Author string + Tags []string + Versions PluginVersions +} + +// PluginPackages is a list of PluginPackage instances. +type PluginPackages []*PluginPackage + +// PluginVersion descripes a version of a PluginPackage. Containing a version, download url and also dependencies. +type PluginVersion struct { + pack *PluginPackage + Version semver.Version + Url string + Require PluginDependencies +} + +// PluginVersions is a slice of PluginVersion +type PluginVersions []*PluginVersion + +// PluginDenendency descripes a dependency to another plugin or micro itself. +type PluginDependency struct { + Name string + Range semver.Range +} + +// PluginDependencies is a slice of PluginDependency +type PluginDependencies []*PluginDependency + +func (pp *PluginPackage) String() string { + buf := new(bytes.Buffer) + buf.WriteString("Plugin: ") + buf.WriteString(pp.Name) + buf.WriteRune('\n') + if pp.Author != "" { + buf.WriteString("Author: ") + buf.WriteString(pp.Author) + buf.WriteRune('\n') + } + if pp.Description != "" { + buf.WriteRune('\n') + buf.WriteString(pp.Description) + } + return buf.String() +} + +func fetchAllSources(count int, fetcher func(i int) PluginPackages) PluginPackages { + wgQuery := new(sync.WaitGroup) + wgQuery.Add(count) + + results := make(chan PluginPackages) + + wgDone := new(sync.WaitGroup) + wgDone.Add(1) + var packages PluginPackages + for i := 0; i < count; i++ { + go func(i int) { + results <- fetcher(i) + wgQuery.Done() + }(i) + } + go func() { + packages = make(PluginPackages, 0) + for res := range results { + packages = append(packages, res...) + } + wgDone.Done() + }() + wgQuery.Wait() + close(results) + wgDone.Wait() + return packages +} + +// Fetch retrieves all available PluginPackages from the given channels +func (pc PluginChannels) Fetch() PluginPackages { + return fetchAllSources(len(pc), func(i int) PluginPackages { + return pc[i].Fetch() + }) +} + +// Fetch retrieves all available PluginPackages from the given channel +func (pc PluginChannel) Fetch() PluginPackages { + messenger.AddLog(fmt.Sprintf("Fetching channel: %q", string(pc))) + resp, err := http.Get(string(pc)) + if err != nil { + TermMessage("Failed to query plugin channel:\n", err) + return PluginPackages{} + } + defer resp.Body.Close() + decoder := json5.NewDecoder(resp.Body) + + var repositories []PluginRepository + if err := decoder.Decode(&repositories); err != nil { + TermMessage("Failed to decode channel data:\n", err) + return PluginPackages{} + } + return fetchAllSources(len(repositories), func(i int) PluginPackages { + return repositories[i].Fetch() + }) +} + +// Fetch retrieves all available PluginPackages from the given repository +func (pr PluginRepository) Fetch() PluginPackages { + messenger.AddLog(fmt.Sprintf("Fetching repository: %q", string(pr))) + resp, err := http.Get(string(pr)) + if err != nil { + TermMessage("Failed to query plugin repository:\n", err) + return PluginPackages{} + } + defer resp.Body.Close() + decoder := json5.NewDecoder(resp.Body) + + var plugins PluginPackages + if err := decoder.Decode(&plugins); err != nil { + TermMessage("Failed to decode repository data:\n", err) + return PluginPackages{} + } + return plugins +} + +// UnmarshalJSON unmarshals raw json to a PluginVersion +func (pv *PluginVersion) UnmarshalJSON(data []byte) error { + var values struct { + Version semver.Version + Url string + Require map[string]string + } + + if err := json5.Unmarshal(data, &values); err != nil { + return err + } + pv.Version = values.Version + pv.Url = values.Url + pv.Require = make(PluginDependencies, 0) + + for k, v := range values.Require { + // don't add the dependency if it's the core and + // we have a unknown version number. + // in that case just accept that dependency (which equals to not adding it.) + if k != CorePluginName || !isUnknownCoreVersion() { + if vRange, err := semver.ParseRange(v); err == nil { + pv.Require = append(pv.Require, &PluginDependency{k, vRange}) + } + } + } + return nil +} + +// UnmarshalJSON unmarshals raw json to a PluginPackage +func (pp *PluginPackage) UnmarshalJSON(data []byte) error { + var values struct { + Name string + Description string + Author string + Tags []string + Versions PluginVersions + } + if err := json5.Unmarshal(data, &values); err != nil { + return err + } + pp.Name = values.Name + pp.Description = values.Description + pp.Author = values.Author + pp.Tags = values.Tags + pp.Versions = values.Versions + for _, v := range pp.Versions { + v.pack = pp + } + return nil +} + +// GetAllPluginPackages gets all PluginPackages which may be available. +func GetAllPluginPackages() PluginPackages { + if allPluginPackages == nil { + getOption := func(name string) []string { + data := GetOption(name) + if strs, ok := data.([]string); ok { + return strs + } + if ifs, ok := data.([]interface{}); ok { + result := make([]string, len(ifs)) + for i, urlIf := range ifs { + if url, ok := urlIf.(string); ok { + result[i] = url + } else { + return nil + } + } + return result + } + return nil + } + + channels := PluginChannels{} + for _, url := range getOption("pluginchannels") { + channels = append(channels, PluginChannel(url)) + } + repos := []PluginRepository{} + for _, url := range getOption("pluginrepos") { + repos = append(repos, PluginRepository(url)) + } + allPluginPackages = fetchAllSources(len(repos)+1, func(i int) PluginPackages { + if i == 0 { + return channels.Fetch() + } else { + return repos[i-1].Fetch() + } + }) + } + return allPluginPackages +} + +func (pv PluginVersions) find(ppName string) *PluginVersion { + for _, v := range pv { + if v.pack.Name == ppName { + return v + } + } + return nil +} + +// Len returns the number of pluginversions in this slice +func (pv PluginVersions) Len() int { + return len(pv) +} + +// Swap two entries of the slice +func (pv PluginVersions) Swap(i, j int) { + pv[i], pv[j] = pv[j], pv[i] +} + +// Less returns true if the version at position i is greater then the version at position j (used for sorting) +func (s PluginVersions) Less(i, j int) bool { + return s[i].Version.GT(s[j].Version) +} + +// Match returns true if the package matches a given search text +func (pp PluginPackage) Match(text string) bool { + text = strings.ToLower(text) + for _, t := range pp.Tags { + if strings.ToLower(t) == text { + return true + } + } + if strings.Contains(strings.ToLower(pp.Name), text) { + return true + } + + if strings.Contains(strings.ToLower(pp.Description), text) { + return true + } + + return false +} + +// IsInstallable returns true if the package can be installed. +func (pp PluginPackage) IsInstallable() bool { + _, err := GetAllPluginPackages().Resolve(GetInstalledVersions(true), PluginDependencies{ + &PluginDependency{ + Name: pp.Name, + Range: semver.Range(func(v semver.Version) bool { return true }), + }}) + return err == nil +} + +// SearchPlugin retrieves a list of all PluginPackages which match the given search text and +// could be or are already installed +func SearchPlugin(texts []string) (plugins PluginPackages) { + plugins = make(PluginPackages, 0) + +pluginLoop: + for _, pp := range GetAllPluginPackages() { + for _, text := range texts { + if !pp.Match(text) { + continue pluginLoop + } + } + + if pp.IsInstallable() { + plugins = append(plugins, pp) + } + } + return +} + +func isUnknownCoreVersion() bool { + _, err := semver.ParseTolerant(Version) + return err != nil +} + +func newStaticPluginVersion(name, version string) *PluginVersion { + vers, err := semver.ParseTolerant(version) + + if err != nil { + if vers, err = semver.ParseTolerant("0.0.0-" + version); err != nil { + vers = semver.MustParse("0.0.0-unknown") + } + } + pl := &PluginPackage{ + Name: name, + } + pv := &PluginVersion{ + pack: pl, + Version: vers, + } + pl.Versions = PluginVersions{pv} + return pv +} + +// GetInstalledVersions returns a list of all currently installed plugins including an entry for +// micro itself. This can be used to resolve dependencies. +func GetInstalledVersions(withCore bool) PluginVersions { + result := PluginVersions{} + if withCore { + result = append(result, newStaticPluginVersion(CorePluginName, Version)) + } + + for _, name := range loadedPlugins { + version := GetInstalledPluginVersion(name) + if pv := newStaticPluginVersion(name, version); pv != nil { + result = append(result, pv) + } + } + + return result +} + +// GetInstalledPluginVersion returns the string of the exported VERSION variable of a loaded plugin +func GetInstalledPluginVersion(name string) string { + plugin := L.GetGlobal(name) + if plugin != lua.LNil { + version := L.GetField(plugin, "VERSION") + if str, ok := version.(lua.LString); ok { + return string(str) + + } + } + return "" +} + +func (pv *PluginVersion) DownloadAndInstall() error { + messenger.AddLog(fmt.Sprintf("Downloading %q (%s) from %q", pv.pack.Name, pv.Version, pv.Url)) + resp, err := http.Get(pv.Url) + if err != nil { + return err + } + defer resp.Body.Close() + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + zipbuf := bytes.NewReader(data) + z, err := zip.NewReader(zipbuf, zipbuf.Size()) + if err != nil { + return err + } + targetDir := filepath.Join(configDir, "plugins", pv.pack.Name) + dirPerm := os.FileMode(0755) + if err = os.MkdirAll(targetDir, dirPerm); err != nil { + return err + } + + // Check if all files in zip are in the same directory. + // this might be the case if the plugin zip contains the whole plugin dir + // instead of its content. + var prefix string + allPrefixed := false + for i, f := range z.File { + parts := strings.Split(f.Name, "/") + if i == 0 { + prefix = parts[0] + } else if parts[0] != prefix { + allPrefixed = false + break + } else { + // switch to true since we have at least a second file + allPrefixed = true + } + } + + for _, f := range z.File { + parts := strings.Split(f.Name, "/") + if allPrefixed { + parts = parts[1:] + } + + targetName := filepath.Join(targetDir, filepath.Join(parts...)) + if f.FileInfo().IsDir() { + if err := os.MkdirAll(targetName, dirPerm); err != nil { + return err + } + } else { + content, err := f.Open() + if err != nil { + return err + } + defer content.Close() + if target, err := os.Create(targetName); err != nil { + return err + } else { + defer target.Close() + if _, err = io.Copy(target, content); err != nil { + return err + } + } + } + } + return nil +} + +func (pl PluginPackages) Get(name string) *PluginPackage { + for _, p := range pl { + if p.Name == name { + return p + } + } + return nil +} + +func (pl PluginPackages) GetAllVersions(name string) PluginVersions { + result := make(PluginVersions, 0) + p := pl.Get(name) + if p != nil { + for _, v := range p.Versions { + result = append(result, v) + } + } + return result +} + +func (req PluginDependencies) Join(other PluginDependencies) PluginDependencies { + m := make(map[string]*PluginDependency) + for _, r := range req { + m[r.Name] = r + } + for _, o := range other { + cur, ok := m[o.Name] + if ok { + m[o.Name] = &PluginDependency{ + o.Name, + o.Range.AND(cur.Range), + } + } else { + m[o.Name] = o + } + } + result := make(PluginDependencies, 0, len(m)) + for _, v := range m { + result = append(result, v) + } + return result +} + +func (all PluginPackages) Resolve(selectedVersions PluginVersions, open PluginDependencies) (PluginVersions, error) { + if len(open) == 0 { + return selectedVersions, nil + } + currentRequirement, stillOpen := open[0], open[1:] + if currentRequirement != nil { + if selVersion := selectedVersions.find(currentRequirement.Name); selVersion != nil { + if currentRequirement.Range(selVersion.Version) { + return all.Resolve(selectedVersions, stillOpen) + } + return nil, fmt.Errorf("unable to find a matching version for \"%s\"", currentRequirement.Name) + } else { + availableVersions := all.GetAllVersions(currentRequirement.Name) + sort.Sort(availableVersions) + + for _, version := range availableVersions { + if currentRequirement.Range(version.Version) { + resolved, err := all.Resolve(append(selectedVersions, version), stillOpen.Join(version.Require)) + + if err == nil { + return resolved, nil + } + } + } + return nil, fmt.Errorf("unable to find a matching version for \"%s\"", currentRequirement.Name) + } + } else { + return selectedVersions, nil + } +} + +func (versions PluginVersions) install() { + anyInstalled := false + currentlyInstalled := GetInstalledVersions(true) + + for _, sel := range versions { + if sel.pack.Name != CorePluginName { + shouldInstall := true + if pv := currentlyInstalled.find(sel.pack.Name); pv != nil { + if pv.Version.NE(sel.Version) { + messenger.AddLog(fmt.Sprint("Uninstalling %q", sel.pack.Name)) + UninstallPlugin(sel.pack.Name) + } else { + shouldInstall = false + } + } + + if shouldInstall { + if err := sel.DownloadAndInstall(); err != nil { + messenger.Error(err) + return + } + anyInstalled = true + } + } + } + if anyInstalled { + messenger.Message("One or more plugins installed. Please restart micro.") + } else { + messenger.AddLog("Nothing to install / update") + } +} + +// UninstallPlugin deletes the plugin folder of the given plugin +func UninstallPlugin(name string) { + if err := os.RemoveAll(filepath.Join(configDir, "plugins", name)); err != nil { + messenger.Error(err) + } +} + +func (pl PluginPackage) Install() { + selected, err := GetAllPluginPackages().Resolve(GetInstalledVersions(true), PluginDependencies{ + &PluginDependency{ + Name: pl.Name, + Range: semver.Range(func(v semver.Version) bool { return true }), + }}) + if err != nil { + TermMessage(err) + return + } + selected.install() +} + +func UpdatePlugins(plugins []string) { + // if no plugins are specified, update all installed plugins. + if len(plugins) == 0 { + plugins = loadedPlugins + } + + messenger.AddLog("Checking for plugin updates") + microVersion := PluginVersions{ + newStaticPluginVersion(CorePluginName, Version), + } + + var updates = make(PluginDependencies, 0) + for _, name := range plugins { + pv := GetInstalledPluginVersion(name) + r, err := semver.ParseRange(">=" + pv) // Try to get newer versions. + if err == nil { + updates = append(updates, &PluginDependency{ + Name: name, + Range: r, + }) + } + } + + selected, err := GetAllPluginPackages().Resolve(microVersion, updates) + if err != nil { + TermMessage(err) + return + } + selected.install() +} diff --git a/cmd/micro/pluginmanager_test.go b/cmd/micro/pluginmanager_test.go new file mode 100644 index 00000000..25e2203f --- /dev/null +++ b/cmd/micro/pluginmanager_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "github.com/blang/semver" + "testing" + + "github.com/zyedidia/json5/encoding/json5" +) + +func TestDependencyResolving(t *testing.T) { + js := ` +[{ + "Name": "Foo", + "Versions": [{ "Version": "1.0.0" }, { "Version": "1.5.0" },{ "Version": "2.0.0" }] +}, { + "Name": "Bar", + "Versions": [{ "Version": "1.0.0", "Require": {"Foo": ">1.0.0 <2.0.0"} }] +}, { + "Name": "Unresolvable", + "Versions": [{ "Version": "1.0.0", "Require": {"Foo": "<=1.0.0", "Bar": ">0.0.0"} }] + }] +` + var all PluginPackages + err := json5.Unmarshal([]byte(js), &all) + if err != nil { + t.Error(err) + } + selected, err := all.Resolve(PluginVersions{}, PluginDependencies{ + &PluginDependency{"Bar", semver.MustParseRange(">=1.0.0")}, + }) + + check := func(name, version string) { + v := selected.find(name) + expected := semver.MustParse(version) + if v == nil { + t.Errorf("Failed to resolve %s", name) + } else if expected.NE(v.Version) { + t.Errorf("%s resolved in wrong version got %s", name, v) + } + } + + if err != nil { + t.Error(err) + } else { + check("Foo", "1.5.0") + check("Bar", "1.0.0") + } + + selected, err = all.Resolve(PluginVersions{}, PluginDependencies{ + &PluginDependency{"Unresolvable", semver.MustParseRange(">0.0.0")}, + }) + if err == nil { + t.Error("Unresolvable package resolved:", selected) + } +} diff --git a/cmd/micro/settings.go b/cmd/micro/settings.go index 41d26449..de31c951 100644 --- a/cmd/micro/settings.go +++ b/cmd/micro/settings.go @@ -192,6 +192,10 @@ func DefaultGlobalSettings() map[string]interface{} { "syntax": true, "tabsize": float64(4), "tabstospaces": false, + "pluginchannels": []string{ + "https://www.boombuler.de/channel.json", + }, + "pluginrepos": []string{}, } } diff --git a/tools/build-version.go b/tools/build-version.go new file mode 100644 index 00000000..a8e68d01 --- /dev/null +++ b/tools/build-version.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/blang/semver" +) + +func getTag(match ...string) (string, *semver.PRVersion) { + args := append([]string{ + "describe", "--tags", + }, match...) + if tag, err := exec.Command("git", args...).Output(); err != nil { + return "", nil + } else { + tagParts := strings.Split(string(tag), "-") + if len(tagParts) == 3 { + if ahead, err := semver.NewPRVersion(tagParts[1]); err == nil { + return tagParts[0], &ahead + } + } + + return tagParts[0], nil + } +} + +func main() { + // Find the last vX.X.X Tag and get how many builds we are ahead of it. + versionStr, ahead := getTag("--match", "v*") + version, err := semver.ParseTolerant(versionStr) + if err != nil { + // no version tag found so just return what ever we can find. + fmt.Println(getTag()) + return + } + // Get the tag of the current revision. + tag, _ := getTag("--exact-match") + if tag == versionStr { + // Seems that we are going to build a release. + // So the version number should already be correct. + fmt.Println(version.String()) + return + } + + // If we don't have any tag assume "dev" + if tag == "" { + tag = "dev" + } + // Get the most likely next version: + version.Patch = version.Patch + 1 + + if pr, err := semver.NewPRVersion(tag); err == nil { + // append the tag as pre-release name + version.Pre = append(version.Pre, pr) + } + + if ahead != nil { + // if we know how many commits we are ahead of the last release, append that too. + version.Pre = append(version.Pre, *ahead) + } + + fmt.Println(version.String()) +}