From 2e5d176c0f50152b0f0993ba2276856c90e7ea59 Mon Sep 17 00:00:00 2001 From: knqyf263 Date: Sat, 11 Mar 2017 13:54:33 +0900 Subject: [PATCH] initial commit --- .gitignore | 2 + README.md | 9 +++- cmd/configure.go | 23 +++++++++ cmd/edit.go | 25 ++++++++++ cmd/exec.go | 37 +++++++++++++++ cmd/list.go | 48 +++++++++++++++++++ cmd/new.go | 78 +++++++++++++++++++++++++++++++ cmd/root.go | 85 ++++++++++++++++++++++++++++++++++ cmd/search.go | 47 +++++++++++++++++++ cmd/sync.go | 113 +++++++++++++++++++++++++++++++++++++++++++++ cmd/util.go | 67 +++++++++++++++++++++++++++ config/config.go | 108 +++++++++++++++++++++++++++++++++++++++++++ main.go | 21 +++++++++ scripts/package.sh | 21 +++++++++ snippet.toml | 39 ++++++++++++++++ snippet/snippet.go | 47 +++++++++++++++++++ 16 files changed, 768 insertions(+), 2 deletions(-) create mode 100644 cmd/configure.go create mode 100644 cmd/edit.go create mode 100644 cmd/exec.go create mode 100644 cmd/list.go create mode 100644 cmd/new.go create mode 100644 cmd/root.go create mode 100644 cmd/search.go create mode 100644 cmd/sync.go create mode 100644 cmd/util.go create mode 100644 config/config.go create mode 100644 main.go create mode 100755 scripts/package.sh create mode 100644 snippet.toml create mode 100644 snippet/snippet.go diff --git a/.gitignore b/.gitignore index daf913b..0b87c13 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ _testmain.go *.exe *.test *.prof +out +pkg diff --git a/README.md b/README.md index 74657d4..8fdb206 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,7 @@ -# pet -pet +# pet : CLI Snippet Manager + +[![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://github.com/knqyf263/pet/blob/master/LICENSE) + + +Simple command-line snippet manager, written in Go + diff --git a/cmd/configure.go b/cmd/configure.go new file mode 100644 index 0000000..ca0edab --- /dev/null +++ b/cmd/configure.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "github.com/knqyf263/pet/config" + "github.com/spf13/cobra" +) + +// configureCmd represents the configure command +var configureCmd = &cobra.Command{ + Use: "configure", + Short: "Edit config file", + Long: `Edit config file (default: opened by vim)`, + RunE: configure, +} + +func configure(cmd *cobra.Command, args []string) (err error) { + editor := config.Conf.General.Editor + return editFile(editor, configFile) +} + +func init() { + RootCmd.AddCommand(configureCmd) +} diff --git a/cmd/edit.go b/cmd/edit.go new file mode 100644 index 0000000..1b2439a --- /dev/null +++ b/cmd/edit.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "github.com/knqyf263/pet/config" + "github.com/spf13/cobra" +) + +// editCmd represents the edit command +var editCmd = &cobra.Command{ + Use: "edit", + Short: "Edit snippet file", + Long: `Edit snippet file (default: opened by vim)`, + RunE: edit, +} + +func edit(cmd *cobra.Command, args []string) (err error) { + editor := config.Conf.General.Editor + snippetFile := config.Conf.General.SnippetFile + + return editFile(editor, snippetFile) +} + +func init() { + RootCmd.AddCommand(editCmd) +} diff --git a/cmd/exec.go b/cmd/exec.go new file mode 100644 index 0000000..8c72200 --- /dev/null +++ b/cmd/exec.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/knqyf263/pet/config" + "github.com/spf13/cobra" +) + +// execCmd represents the exec command +var execCmd = &cobra.Command{ + Use: "exec", + Short: "Run the selected commands", + Long: `Run the selected commands directly`, + RunE: execute, +} + +func execute(cmd *cobra.Command, args []string) (err error) { + var options []string + commands, err := filter(options) + if err != nil { + return err + } + command := strings.Join(commands, "; ") + if config.Flag.Debug { + fmt.Printf("Command: %s\n", command) + } + return run(command, os.Stdin, os.Stdout) +} + +func init() { + RootCmd.AddCommand(execCmd) + execCmd.Flags().StringVarP(&config.Flag.Query, "query", "q", "", + `Initial value for query`) +} diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..15b5841 --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + + "github.com/fatih/color" + "github.com/knqyf263/pet/config" + "github.com/knqyf263/pet/snippet" + runewidth "github.com/mattn/go-runewidth" + "github.com/spf13/cobra" +) + +const ( + column = 40 +) + +// listCmd represents the list command +var listCmd = &cobra.Command{ + Use: "list", + Short: "Show all snippets", + Long: `Show all snippets`, + RunE: list, +} + +func list(cmd *cobra.Command, args []string) error { + var snippets snippet.Snippets + if err := snippets.Load(); err != nil { + return err + } + + col := config.Conf.General.Column + if col == 0 { + col = column + } + + for _, snippet := range snippets.Snippets { + description := runewidth.FillRight(runewidth.Truncate(snippet.Description, col, "..."), col) + command := runewidth.Truncate(snippet.Command, 100-4-col, "...") + + fmt.Fprintf(color.Output, "%s : %s\n", + color.GreenString(description), color.YellowString(command)) + } + return nil +} + +func init() { + RootCmd.AddCommand(listCmd) +} diff --git a/cmd/new.go b/cmd/new.go new file mode 100644 index 0000000..dafe675 --- /dev/null +++ b/cmd/new.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "bufio" + "errors" + "fmt" + "os" + "strings" + + "github.com/fatih/color" + "github.com/knqyf263/pet/snippet" + "github.com/spf13/cobra" +) + +// newCmd represents the new command +var newCmd = &cobra.Command{ + Use: "new COMMAND", + Short: "Create a new snippet", + Long: `Create a new snippet (default: $HOME/.config/pet/snippet.toml)`, + RunE: new, +} + +func scan(message string) (string, error) { + fmt.Print(message) + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return "", errors.New("canceled") + } + if scanner.Err() != nil { + return "", scanner.Err() + } + return scanner.Text(), nil +} + +func new(cmd *cobra.Command, args []string) (err error) { + var command string + var description string + + var snippets snippet.Snippets + if err := snippets.Load(); err != nil { + return err + } + + if len(args) > 0 { + command = strings.Join(args, " ") + fmt.Printf("%s %s\n", color.YellowString("Command:"), command) + } else { + command, err = scan(color.YellowString("Command: ")) + if err != nil { + return err + } + } + description, err = scan(color.GreenString("Description: ")) + if err != nil { + return err + } + + for _, s := range snippets.Snippets { + if s.Description == description { + return fmt.Errorf("Snippet [%s] already exists", description) + } + } + + newSnippet := snippet.SnippetInfo{ + Description: description, + Command: command, + } + snippets.Snippets = append(snippets.Snippets, newSnippet) + if err = snippets.Save(); err != nil { + return err + } + + return nil +} + +func init() { + RootCmd.AddCommand(newCmd) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..c90803f --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,85 @@ +// Copyright © 2017 Teppei Fukuda +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/knqyf263/pet/config" + "github.com/spf13/cobra" +) + +const ( + version = "0.0.1" +) + +var configFile string + +// RootCmd represents the base command when called without any subcommands +var RootCmd = &cobra.Command{ + Use: "pet", + Short: "Simple command-line snippet manager.", + Long: `pet - Simple command-line snippet manager.`, +} + +// Execute adds all child commands to the root command sets flags appropriately. +func Execute() { + if err := RootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(-1) + } +} + +func init() { + cobra.OnInitialize(initConfig) + RootCmd.AddCommand(versionCmd) + + RootCmd.PersistentFlags().StringVar(&configFile, "config", "", "config file (default is $HOME/.config/pet/config.toml)") + RootCmd.PersistentFlags().BoolVarP(&config.Flag.Debug, "debug", "", false, "debug mode") +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number", + Long: `Print the version number`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("pet version %s\n", version) + }, +} + +// initConfig reads in config file and ENV variables if set. +func initConfig() { + if configFile == "" { + dir, err := config.GetDefaultConfigDir() + if err != nil { + fmt.Fprintf(os.Stderr, "%v", err) + os.Exit(1) + } + configFile = filepath.Join(dir, "config.toml") + } + + if err := config.Conf.Load(configFile); err != nil { + fmt.Fprintf(os.Stderr, "%v", err) + os.Exit(1) + } +} diff --git a/cmd/search.go b/cmd/search.go new file mode 100644 index 0000000..03b87ed --- /dev/null +++ b/cmd/search.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/knqyf263/pet/config" + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh/terminal" +) + +var delimiter string + +// searchCmd represents the search command +var searchCmd = &cobra.Command{ + Use: "search", + Short: "Search snippets", + Long: `Search snippets interactively (default filtering tool: peco)`, + RunE: search, +} + +func search(cmd *cobra.Command, args []string) (err error) { + flag := config.Flag + + var options []string + if flag.Query != "" { + options = append(options, fmt.Sprintf("--query %s", flag.Query)) + } + commands, err := filter(options) + if err != nil { + return err + } + + fmt.Print(strings.Join(commands, flag.Delimiter)) + if terminal.IsTerminal(1) { + fmt.Print("\n") + } + return nil +} + +func init() { + RootCmd.AddCommand(searchCmd) + searchCmd.Flags().StringVarP(&config.Flag.Query, "query", "q", "", + `Initial value for query`) + searchCmd.Flags().StringVarP(&config.Flag.Delimiter, "delimiter", "d", "; ", + `Use delim as the command delimiter character`) +} diff --git a/cmd/sync.go b/cmd/sync.go new file mode 100644 index 0000000..e790aa3 --- /dev/null +++ b/cmd/sync.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/BurntSushi/toml" + "github.com/google/go-github/github" + "github.com/knqyf263/pet/config" + "github.com/knqyf263/pet/snippet" + "github.com/spf13/cobra" + "golang.org/x/oauth2" +) + +// syncCmd represents the sync command +var syncCmd = &cobra.Command{ + Use: "sync", + Short: "Sync snippets", + Long: `Sync snippets with gist`, + RunE: sync, +} + +func sync(cmd *cobra.Command, args []string) (err error) { + if config.Conf.Gist.AccessToken == "" { + return fmt.Errorf(`access_token is empty. +Go https://github.com/settings/tokens/new and create access_token (only need "gist" scope). +Write access_token in config file (pet configure). + `) + } + + if config.Flag.Upload { + return upload() + } + return download() +} + +func githubClient() *github.Client { + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: config.Conf.Gist.AccessToken}, + ) + tc := oauth2.NewClient(oauth2.NoContext, ts) + client := github.NewClient(tc) + return client +} + +func upload() (err error) { + ctx := context.Background() + + var snippets snippet.Snippets + if err := snippets.Load(); err != nil { + return err + } + + body, err := snippets.ToString() + if err != nil { + return err + } + + client := githubClient() + gist := github.Gist{ + Description: github.String("description"), + Public: github.Bool(true), + Files: map[github.GistFilename]github.GistFile{ + github.GistFilename(config.Conf.Gist.FileName): github.GistFile{ + Content: github.String(body), + }, + }, + } + + gistID := config.Conf.Gist.GistID + if gistID == "" { + var retGist *github.Gist + retGist, _, err = client.Gists.Create(ctx, &gist) + if err != nil { + return err + } + fmt.Printf("Gist ID: %s\n", retGist.GetID()) + } else { + _, _, err = client.Gists.Edit(ctx, gistID, &gist) + if err != nil { + return err + } + } + fmt.Println("Upload success") + return nil +} + +func download() error { + if config.Conf.Gist.GistID == "" { + return fmt.Errorf("Gist ID is empty") + } + ctx := context.Background() + client := githubClient() + resGist, _, err := client.Gists.Get(ctx, config.Conf.Gist.GistID) + if err != nil { + return fmt.Errorf("Failed to download gist: %v", err) + } + content := resGist.Files[github.GistFilename(config.Conf.Gist.FileName)].Content + + var snippets snippet.Snippets + toml.Decode(*content, &snippets) + if err := snippets.Save(); err != nil { + return err + } + fmt.Println("Download success") + return nil +} + +func init() { + RootCmd.AddCommand(syncCmd) + syncCmd.Flags().BoolVarP(&config.Flag.Upload, "upload", "u", false, + `Upload snippets to gist`) +} diff --git a/cmd/util.go b/cmd/util.go new file mode 100644 index 0000000..2d447be --- /dev/null +++ b/cmd/util.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + + "github.com/knqyf263/pet/config" + "github.com/knqyf263/pet/snippet" +) + +func editFile(command, file string) error { + command += " " + file + return run(command, os.Stdin, os.Stdout) +} + +func run(command string, r io.Reader, w io.Writer) error { + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/c", command) + } else { + cmd = exec.Command("sh", "-c", command) + } + cmd.Stderr = os.Stderr + cmd.Stdout = w + cmd.Stdin = r + return cmd.Run() +} + +func filter(options []string) (commands []string, err error) { + var snippets snippet.Snippets + if err := snippets.Load(); err != nil { + return commands, fmt.Errorf("Load snippet failed: %v", err) + } + + snippetTexts := map[string]snippet.SnippetInfo{} + var text string + for _, s := range snippets.Snippets { + t := fmt.Sprintf("[%s] %s", s.Description, s.Command) + snippetTexts[t] = s + text += t + "\n" + } + + var buf bytes.Buffer + selectCmd := fmt.Sprintf("%s %s", + config.Conf.General.SelectCmd, strings.Join(options, " ")) + err = run(selectCmd, strings.NewReader(text), &buf) + if err != nil { + return nil, nil + } + + if buf.Len() == 0 { + return commands, errors.New("No line is selected") + } + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + + for _, line := range lines { + snippetInfo := snippetTexts[line] + commands = append(commands, fmt.Sprint(snippetInfo.Command)) + } + return commands, nil +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..a1f5658 --- /dev/null +++ b/config/config.go @@ -0,0 +1,108 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/BurntSushi/toml" +) + +// Conf is global config variable +var Conf Config + +// Config is a struct of config +type Config struct { + General GeneralConfig + Gist GistConfig +} + +type GeneralConfig struct { + SnippetFile string `toml:"snippetfile"` + Editor string `toml:"editor"` + Column int `toml:"column"` + SelectCmd string `toml:"selectcmd"` +} + +type GistConfig struct { + FileName string `toml:"file_name"` + AccessToken string `toml:"access_token"` + GistID string `toml:"gist_id"` +} + +// Flag is global flag variable +var Flag FlagConfig + +// FlagConfig is a struct of flag +type FlagConfig struct { + Debug bool + Query string + Delimiter string + Upload bool +} + +func (cfg *Config) Load(file string) error { + _, err := os.Stat(file) + if err == nil { + _, err := toml.DecodeFile(file, cfg) + if err != nil { + return err + } + cfg.General.SnippetFile = expandPath(cfg.General.SnippetFile) + return nil + } + + if !os.IsNotExist(err) { + return err + } + f, err := os.Create(file) + if err != nil { + return err + } + + dir, _ := GetDefaultConfigDir() + cfg.General.SnippetFile = filepath.Join(dir, "snippet.toml") + _, err = os.Create(cfg.General.SnippetFile) + if err != nil { + return err + } + + cfg.General.Editor = os.Getenv("EDITOR") + if cfg.General.Editor == "" { + cfg.General.Editor = "vim" + } + cfg.General.Column = 40 + cfg.General.SelectCmd = "peco" + + cfg.Gist.FileName = "pet-snippet.toml" + + return toml.NewEncoder(f).Encode(cfg) +} + +func GetDefaultConfigDir() (dir string, err error) { + if runtime.GOOS == "windows" { + dir = os.Getenv("APPDATA") + if dir == "" { + dir = filepath.Join(os.Getenv("USERPROFILE"), "Application Data", "pet") + } + dir = filepath.Join(dir, "pet") + } else { + dir = filepath.Join(os.Getenv("HOME"), ".config", "pet") + } + if err := os.MkdirAll(dir, 0700); err != nil { + return "", fmt.Errorf("cannot create directory: %v", err) + } + return dir, nil +} + +func expandPath(s string) string { + if len(s) >= 2 && s[0] == '~' && os.IsPathSeparator(s[1]) { + if runtime.GOOS == "windows" { + s = filepath.Join(os.Getenv("USERPROFILE"), s[2:]) + } else { + s = filepath.Join(os.Getenv("HOME"), s[2:]) + } + } + return os.Expand(s, os.Getenv) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..7654373 --- /dev/null +++ b/main.go @@ -0,0 +1,21 @@ +// Copyright © 2017 Teppei Fukuda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import "github.com/knqyf263/pet/cmd" + +func main() { + cmd.Execute() +} diff --git a/scripts/package.sh b/scripts/package.sh new file mode 100755 index 0000000..277c1b0 --- /dev/null +++ b/scripts/package.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +set -e +set -x + +VERSION=$(grep "version = " cmd/root.go | sed -E 's/.*"(.+)"$/\1/') +REPO="pet" + +rm -rf ./out/ +gox --osarch "darwin/386 darwin/amd64 linux/386 linux/amd64" -output="./out/${REPO}_${VERSION}_{{.OS}}_{{.Arch}}/{{.Dir}}" + +rm -rf ./pkg/ +mkdir ./pkg + +for PLATFORM in $(find ./out -mindepth 1 -maxdepth 1 -type d); do + PLATFORM_NAME=$(basename ${PLATFORM}) + + pushd ${PLATFORM} + zip ../../pkg/${PLATFORM_NAME}.zip ./* + popd +done diff --git a/snippet.toml b/snippet.toml new file mode 100644 index 0000000..0831464 --- /dev/null +++ b/snippet.toml @@ -0,0 +1,39 @@ +[[snippets]] + description = "I have an apple" + command = "comm" + +[[snippets]] + description = "hoge" + command = "cat" + +[[snippets]] + description = "teset" + command = "hoge fuga 1 2 | aba" + +[[snippets]] + description = "show files" + command = "ls -la" + +[[snippets]] + description = "count the number of files" + command = "ls | wc -l" + +[[snippets]] + description = "" + command = "echo" + +[[snippets]] + description = "Confirm expiration date of ssl certificate" + command = "echo | openssl s_client -connect example.com:443 2>/dev/null |openssl x509 -dates -noout" + +[[snippets]] + description = "SS有効期限を確認する" + command = "echo | openssl s_client -connect example.com:443 2>/dev/null |openssl x509 -dates -noout" + +[[snippets]] + description = "ls" + command = "ls" + +[[snippets]] + description = "ls2" + command = "ls" diff --git a/snippet/snippet.go b/snippet/snippet.go new file mode 100644 index 0000000..ff17f04 --- /dev/null +++ b/snippet/snippet.go @@ -0,0 +1,47 @@ +package snippet + +import ( + "bytes" + "fmt" + "os" + + "github.com/BurntSushi/toml" + "github.com/knqyf263/pet/config" +) + +type Snippets struct { + Snippets []SnippetInfo `toml:"snippets"` +} + +type SnippetInfo struct { + Description string `toml:"description"` + Command string `toml:"command"` +} + +// Load reads toml file. +func (snippets *Snippets) Load() error { + snippetFile := config.Conf.General.SnippetFile + if _, err := toml.DecodeFile(snippetFile, snippets); err != nil { + return fmt.Errorf("Failed to load snippet file. %v", err) + } + return nil +} + +func (snippets *Snippets) Save() error { + snippetFile := config.Conf.General.SnippetFile + f, err := os.Create(snippetFile) + defer f.Close() + if err != nil { + return fmt.Errorf("Failed to save snippet file. err: %s", err) + } + return toml.NewEncoder(f).Encode(snippets) +} + +func (snippets *Snippets) ToString() (string, error) { + var buffer bytes.Buffer + err := toml.NewEncoder(&buffer).Encode(snippets) + if err != nil { + return "", fmt.Errorf("Failed to convert struct to TOML string: %v", err) + } + return buffer.String(), nil +}