diff --git a/GUIDE.md b/GUIDE.md index 72d51b7..a385e3a 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -1,148 +1,9 @@ -# Write.as CLI User Guide +# Write.as / WriteFreely CLI User Guide -The Write.as Command-Line Interface (CLI) is a cross-platform tool for publishing text to [Write.as](https://write.as) and its other sites, like [Paste.as](https://paste.as). It is designed to be simple, scriptable, do one job (publishing) well, and work as you'd expect with other command-line tools. +**This has been split into two user guides:** -Write.as is a text-publishing service that protects your privacy. There's no sign up required to publish, but if you do sign up, you can access posts across devices and compile collections of them in what most people would call a "blog". +## Write.as CLI +See full usage documentation on our [writeas-cli User Guide](https://github.com/writeas/writeas-cli/blob/master/cmd/writeas/GUIDE.md). -## Uses - -These are a few common uses for `writeas`. If you get stuck or want to know more, run `writeas [command] --help`. If you still have questions, [ask us](https://write.as/contact). - -### Overview - -``` - writeas [global options] command [command options] [arguments...] - -COMMANDS: - post Alias for default action: create post from stdin - new Compose a new post from the command-line and publish - publish Publish a file to Write.as - delete Delete a post - update Update (overwrite) a post - get Read a raw post - add Add an existing post locally - posts List all of your posts - claim Claim local unsynced posts - blogs List blogs - auth Authenticate with Write.as - logout Log out of Write.as - help, h Shows a list of commands or help for one command - -GLOBAL OPTIONS: - -c value, -b value Optional blog to post to - --tor, -t Perform action on Tor hidden service - --tor-port value Use a different port to connect to Tor (default: 9150) - --code Specifies this post is code - --md Returns post URL with Markdown enabled - --verbose, -v Make the operation more talkative - --font value Sets post font to given value (default: "mono") - --lang value Sets post language to given ISO 639-1 language code - --user-agent value Sets the User-Agent for API requests - --help, -h show help - --version, -V print the version -``` - -#### Share something - -By default, `writeas` creates a post with a `monospace` typeface that doesn't word wrap (scrolls horizontally). It will return a single line with a URL, and automatically copy that URL to the clipboard: - -```bash -$ echo "Hello world!" | writeas -https://write.as/aaaazzzzzzzza -``` - -This is generally more useful for posting terminal output or code, like so (the `--code` flag turns on syntax highlighting): - -macOS / Linux: `cat writeas/cli.go | writeas --code` - -Windows: `type writeas/cli.go | writeas.exe --code` - -#### Output a post - -This outputs any Write.as post with the given ID. - -```bash -$ writeas get aaaazzzzzzzza -Hello world! -``` - -#### Authenticate - -This will authenticate with write.as and store the user access token locally, until you explicitly logout. -```bash -$ writeas auth username -Password: ************ -``` - -#### List all blogs - -This will output a list of the authenticated user's blogs. -```bash -$ writeas blogs -Alias Title -user An Example Blog -dev My Dev Log -``` - -#### List posts - -This lists all anonymous posts you've published. If authenticated, it will include posts on your account as well as any local / unclaimed posts. - -Pass the `--url` flag to show the list with full post URLs, and the `--md` flag to return URLs with Markdown enabled. - -To see post IDs with their Edit Tokens pass the `--v` flag. - -```bash -$ writeas posts -aaaazzzzzzzza - -$ writeas posts -url -https://write.as/aaaazzzzzzzza - -$ writeas posts -v -ID Token -aaaazzzzzzzza dhuieoj23894jhf984hdfs9834hdf84j -``` - -#### Delete a post - -This permanently deletes a post you own. - -```bash -$ writeas delete aaaazzzzzzzza -``` - -#### Update a post - -This completely overwrites an existing post you own. - -```bash -$ echo "See you later!" | writeas update aaaazzzzzzzza -``` - -#### Claim a post - -This moves an unsynced local post to a draft on your account. You will need to authenticate first. -```bash -$ writeas claim aaaazzzzzzzza -``` - -### Composing posts - -If you simply have a penchant for never leaving your keyboard, `writeas` is great for composing new posts from the command-line. Just use the `new` subcommand. - -`writeas new` will open your favorite command-line editor, as specified by your `WRITEAS_EDITOR` or `EDITOR` environment variables (in that order), falling back to `vim` on OS X / *nix. - -Customize your post's appearance with the `--font` flag: - -| Argument | Appearance (Typeface) | Word Wrap? | -| -------- | --------------------- | ---------- | -| `sans` | Sans-serif (Open Sans) | Yes | -| `serif` | Serif (Lora) | Yes | -| `wrap` | Monospace | Yes | -| `mono` | Monospace | No | -| `code` | Syntax-highlighted monospace | No | - -Put it all together, e.g. publish with a sans-serif font: `writeas new --font sans` - -If you're publishing Markdown, supply the `--md` flag to get a URL back that will render Markdown, e.g.: `writeas new --font sans --md` +## WriteFreely CLI +See full usage documentation on our [wf-cli User Guide](https://github.com/writeas/writeas-cli/blob/master/cmd/wf/GUIDE.md). diff --git a/README.md b/README.md index 149ea10..1e76ed6 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,18 @@ -writeas-cli -=========== +writeas-cli / wf-cli +==================== ![GPL](https://img.shields.io/github/license/writeas/writeas-cli.svg) [![Go Report Card](https://goreportcard.com/badge/github.com/writeas/writeas-cli)](https://goreportcard.com/report/github.com/writeas/writeas-cli) [![#writeas on freenode](https://img.shields.io/badge/freenode-%23writeas-blue.svg)](http://webchat.freenode.net/?channels=writeas) [![Discuss on our forum](https://img.shields.io/discourse/https/discuss.write.as/users.svg?label=forum)](https://discuss.write.as/c/development) -Command line interface for [Write.as](https://write.as). Works on Windows, macOS, and Linux. +Command line utility for publishing to [Write.as](https://write.as) and any other [WriteFreely](https://writefreely.org) instance. Works on Windows, macOS, and Linux. ## Features -* Publish anonymously to Write.as -* Authenticate with a Write.as account +* Authenticate with a Write.as / WriteFreely account +* Publish anonymous posts or drafts to Write.as or WriteFreely, respectively * A stable, easy back-end for your [GUI app](https://write.as/apps/desktop) or desktop-based workflow -* Compatible with our [Tor hidden service](http://writeas7pm7rcdqg.onion/) -* Locally keeps track of any posts you make -* Update and delete posts, anonymous and authenticated +* Compatible with the [Write.as Tor hidden service](http://writeas7pm7rcdqg.onion/) +* Update and delete posts * Fetch any post by ID -* Add anonymous post credentials (like for one published with the [Android app](https://play.google.com/store/apps/details?id=com.abunchtell.writeas)) for editing +* ...and more, depending on which client you're using (see respective READMEs for more) ## Installing The easiest way to get the CLI is to download a pre-built executable for your OS. @@ -23,74 +22,19 @@ The easiest way to get the CLI is to download a pre-built executable for your OS Get the latest version for your operating system as a standalone executable. -**Windows**
-Download the [64-bit](https://github.com/writeas/writeas-cli/releases/download/v2.0.0/writeas_2.0.0_windows_amd64.zip) or [32-bit](https://github.com/writeas/writeas-cli/releases/download/v2.0.0/writeas_2.0.0_windows_386.zip) executable and put it somewhere in your `%PATH%`. +**Write.as CLI**
+See the [writeas-cli README](https://github.com/writeas/writeas-cli/blob/master/cmd/writeas#readme) -**macOS**
-Download the [64-bit](https://github.com/writeas/writeas-cli/releases/download/v2.0.0/writeas_2.0.0_darwin_amd64.zip) executable and put it somewhere in your `$PATH`, like `/usr/local/bin`. - -**Debian-based Linux**
-```bash -sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys DBE07445 -sudo add-apt-repository "deb http://updates.writeas.org xenial main" -sudo apt-get update && sudo apt-get install writeas-cli -``` - -**Linux (other)**
-Download the [64-bit](https://github.com/writeas/writeas-cli/releases/download/v2.0.0/writeas_2.0.0_linux_amd64.tar.gz) or [32-bit](https://github.com/writeas/writeas-cli/releases/download/v2.0.0/writeas_2.0.0_linux_386.tar.gz) executable and put it somewhere in your `$PATH`, like `/usr/local/bin`. - -### Go get it -```bash -go get github.com/writeas/writeas-cli/cmd/writeas -``` - -Once this finishes, you'll see `writeas` or `writeas.exe` inside `$GOPATH/bin/`. - -## Upgrading - -To upgrade the CLI, download and replace the executable you downloaded before. - -If you previously installed with `go get`, run it again with the `-u` option. - -```bash -go get -u github.com/writeas/writeas-cli/cmd/writeas -``` +**WriteFreely CLI**
+See the [wf-cli README](https://github.com/writeas/writeas-cli/blob/master/cmd/wf#readme) ## Usage -See full usage documentation on our [User Guide](GUIDE.md). - -``` - writeas [global options] command [command options] [arguments...] - -COMMANDS: - post Alias for default action: create post from stdin - new Compose a new post from the command-line and publish - publish Publish a file to Write.as - delete Delete a post - update Update (overwrite) a post - get Read a raw post - add Add an existing post locally - posts List all of your posts - blogs List blogs - claim Claim local unsynced posts - auth Authenticate with Write.as - logout Log out of Write.as - help, h Shows a list of commands or help for one command - -GLOBAL OPTIONS: - -c value, -b value Optional blog to post to - --tor, -t Perform action on Tor hidden service - --tor-port value Use a different port to connect to Tor (default: 9150) - --code Specifies this post is code - --md Returns post URL with Markdown enabled - --verbose, -v Make the operation more talkative - --font value Sets post font to given value (default: "mono") - --lang value Sets post language to given ISO 639-1 language code - --user-agent value Sets the User-Agent for API requests - --help, -h show help - --version, -V print the version -``` +**Write.as CLI**
+See full usage documentation on our [writeas-cli User Guide](https://github.com/writeas/writeas-cli/blob/master/cmd/writeas/GUIDE.md). + +**WriteFreely CLI**
+See full usage documentation on our [wf-cli User Guide](https://github.com/writeas/writeas-cli/blob/master/cmd/wf/GUIDE.md). ## Contributing to the CLI @@ -104,4 +48,4 @@ We're available on [several channels](https://write.as/contact), and prefer our ### Reporting Issues -If you believe you have found a bug in the CLI or its documentation, file an issue on this repo. If you're not sure if it's a bug or not, [reach out to us](https://write.as/contact) in one way or another. Be sure to provide the version of the CLI (with `writeas --version`) in your report. +If you believe you have found a bug in the CLI or its documentation, file an issue on this repo. If you're not sure if it's a bug or not, [reach out to us](https://write.as/contact) in one way or another. Be sure to provide the version of the CLI (with `writeas --version` or `wf --version`) in your report. diff --git a/api/api.go b/api/api.go index baa138b..dd550c0 100644 --- a/api/api.go +++ b/api/api.go @@ -2,44 +2,70 @@ package api import ( "fmt" - "path/filepath" + "strings" "github.com/atotto/clipboard" writeas "github.com/writeas/go-writeas/v2" "github.com/writeas/web-core/posts" "github.com/writeas/writeas-cli/config" - "github.com/writeas/writeas-cli/fileutils" + "github.com/writeas/writeas-cli/executable" "github.com/writeas/writeas-cli/log" cli "gopkg.in/urfave/cli.v1" ) -func newClient(c *cli.Context, authRequired bool) (*writeas.Client, error) { +func HostURL(c *cli.Context) string { + host := c.GlobalString("host") + if host == "" { + return "" + } + insecure := c.Bool("insecure") + if parts := strings.Split(host, "://"); len(parts) > 1 { + host = parts[1] + } + scheme := "https://" + if insecure { + scheme = "http://" + } + return scheme + host +} + +func newClient(c *cli.Context) (*writeas.Client, error) { var client *writeas.Client - if config.IsTor(c) { - client = writeas.NewTorClient(config.TorPort(c)) - } else { - if config.IsDev() { - client = writeas.NewDevClient() + var clientConfig writeas.Config + cfg, err := config.LoadConfig(config.UserDataDir(c.App.ExtraInfo()["configDir"])) + if err != nil { + return nil, fmt.Errorf("Failed to load configuration file: %v", err) + } + if host := HostURL(c); host != "" { + clientConfig.URL = host + "/api" + } else if cfg.Default.Host != "" && cfg.Default.User != "" { + if parts := strings.Split(cfg.Default.Host, "://"); len(parts) > 1 { + clientConfig.URL = cfg.Default.Host + "/api" } else { - client = writeas.NewClient() + clientConfig.URL = "https://" + cfg.Default.Host + "/api" } + } else if config.IsDev() { + clientConfig.URL = config.DevBaseURL + "/api" + } else if c.App.Name == "writeas" { + clientConfig.URL = config.WriteasBaseURL + "/api" + } else { + return nil, fmt.Errorf("Must supply a host. Example: %s --host example.com %s", executable.Name(), c.Command.Name) } - client.UserAgent = config.UserAgent(c) - // TODO: load user into var shared across the app - u, _ := config.LoadUser(config.UserDataDir(c.App.ExtraInfo()["configDir"])) - if u != nil { - client.SetToken(u.AccessToken) - } else if authRequired { - return nil, fmt.Errorf("Not currently logged in. Authenticate with: writeas auth ") + if config.IsTor(c) { + clientConfig.URL = config.TorURL(c) + clientConfig.TorPort = config.TorPort(c) } + client = writeas.NewClientWith(clientConfig) + client.UserAgent = config.UserAgent(c) + return client, nil } // DoFetch retrieves the Write.as post with the given friendlyID, // optionally via the Tor hidden service. func DoFetch(c *cli.Context, friendlyID string) error { - cl, err := newClient(c, false) + cl, err := newClient(c) if err != nil { return err } @@ -59,11 +85,18 @@ func DoFetch(c *cli.Context, friendlyID string) error { // DoFetchPosts retrieves all remote posts for the // authenticated user func DoFetchPosts(c *cli.Context) ([]writeas.Post, error) { - cl, err := newClient(c, true) + cl, err := newClient(c) if err != nil { return nil, fmt.Errorf("%v", err) } + u, _ := config.LoadUser(c) + if u != nil { + cl.SetToken(u.AccessToken) + } else { + return nil, fmt.Errorf("Not currently logged in. Authenticate with: " + executable.Name() + " auth ") + } + posts, err := cl.GetUserPosts() if err != nil { return nil, err @@ -75,11 +108,18 @@ func DoFetchPosts(c *cli.Context) ([]writeas.Post, error) { // DoPost creates a Write.as post, returning an error if it was // unsuccessful. func DoPost(c *cli.Context, post []byte, font string, encrypt, code bool) (*writeas.Post, error) { - cl, err := newClient(c, false) + cl, err := newClient(c) if err != nil { return nil, fmt.Errorf("%v", err) } + u, _ := config.LoadUser(c) + if u != nil { + cl.SetToken(u.AccessToken) + } else { + return nil, fmt.Errorf("Not currently logged in. Authenticate with: " + executable.Name() + " auth ") + } + pp := &writeas.PostParams{ Font: config.GetFont(code, font), Collection: config.Collection(c), @@ -93,14 +133,22 @@ func DoPost(c *cli.Context, post []byte, font string, encrypt, code bool) (*writ return nil, fmt.Errorf("Unable to post: %v", err) } + cfg, err := config.LoadConfig(config.UserDataDir(c.App.ExtraInfo()["configDir"])) + if err != nil { + return nil, fmt.Errorf("Couldn't check for config file: %v", err) + } var url string if p.Collection != nil { url = p.Collection.URL + p.Slug } else { - if config.IsTor(c) { - url = config.TorBaseURL + if host := HostURL(c); host != "" { + url = host + } else if cfg.Default.Host != "" { + url = cfg.Default.Host } else if config.IsDev() { url = config.DevBaseURL + } else if config.IsTor(c) { + url = config.TorBaseURL } else { url = config.WriteasBaseURL } @@ -119,7 +167,7 @@ func DoPost(c *cli.Context, post []byte, font string, encrypt, code bool) (*writ // Copy URL to clipboard err = clipboard.WriteAll(string(url)) if err != nil { - log.Errorln("writeas: Didn't copy to clipboard: %s", err) + log.Errorln(executable.Name()+": Didn't copy to clipboard: %s", err) } else { log.Info(c, "Copied to clipboard.") } @@ -133,7 +181,7 @@ func DoPost(c *cli.Context, post []byte, font string, encrypt, code bool) (*writ // DoFetchCollections retrieves a list of the currently logged in users // collections. func DoFetchCollections(c *cli.Context) ([]RemoteColl, error) { - cl, err := newClient(c, true) + cl, err := newClient(c) if err != nil { if config.Debug() { log.ErrorlnQuit("could not create client: %v", err) @@ -141,6 +189,13 @@ func DoFetchCollections(c *cli.Context) ([]RemoteColl, error) { return nil, fmt.Errorf("Couldn't create new client") } + u, _ := config.LoadUser(c) + if u != nil { + cl.SetToken(u.AccessToken) + } else { + return nil, fmt.Errorf("Not currently logged in. Authenticate with: " + executable.Name() + " auth ") + } + colls, err := cl.GetUserCollections() if err != nil { if config.Debug() { @@ -165,11 +220,20 @@ func DoFetchCollections(c *cli.Context) ([]RemoteColl, error) { // DoUpdate updates the given post on Write.as. func DoUpdate(c *cli.Context, post []byte, friendlyID, token, font string, code bool) error { - cl, err := newClient(c, false) + cl, err := newClient(c) if err != nil { return fmt.Errorf("%v", err) } + if token == "" { + u, _ := config.LoadUser(c) + if u != nil { + cl.SetToken(u.AccessToken) + } else { + return fmt.Errorf("You must either provide and edit token or log in to delete a post.") + } + } + params := writeas.PostParams{} params.Title, params.Content = posts.ExtractTitle(string(post)) if lang := config.Language(c, false); lang != "" { @@ -191,11 +255,20 @@ func DoUpdate(c *cli.Context, post []byte, friendlyID, token, font string, code // DoDelete deletes the given post on Write.as, and removes any local references func DoDelete(c *cli.Context, friendlyID, token string) error { - cl, err := newClient(c, false) + cl, err := newClient(c) if err != nil { return fmt.Errorf("%v", err) } + if token == "" { + u, _ := config.LoadUser(c) + if u != nil { + cl.SetToken(u.AccessToken) + } else { + return fmt.Errorf("You must either provide and edit token or log in to delete a post.") + } + } + err = cl.DeletePost(friendlyID, token) if err != nil { if config.Debug() { @@ -204,13 +277,13 @@ func DoDelete(c *cli.Context, friendlyID, token string) error { return fmt.Errorf("Post doesn't exist, or bad edit token given.") } - RemovePost(c.App.ExtraInfo()["configDir"], friendlyID) + RemovePost(c, friendlyID) return nil } func DoLogIn(c *cli.Context, username, password string) error { - cl, err := newClient(c, false) + cl, err := newClient(c) if err != nil { return fmt.Errorf("%v", err) } @@ -223,7 +296,7 @@ func DoLogIn(c *cli.Context, username, password string) error { return err } - err = config.SaveUser(config.UserDataDir(c.App.ExtraInfo()["configDir"]), u) + err = config.SaveUser(c, u) if err != nil { return err } @@ -232,11 +305,18 @@ func DoLogIn(c *cli.Context, username, password string) error { } func DoLogOut(c *cli.Context) error { - cl, err := newClient(c, true) + cl, err := newClient(c) if err != nil { return fmt.Errorf("%v", err) } + u, _ := config.LoadUser(c) + if u != nil { + cl.SetToken(u.AccessToken) + } else if c.App.Name == "writeas" { + return fmt.Errorf("Not currently logged in. Authenticate with: " + executable.Name() + " auth ") + } + err = cl.LogOut() if err != nil { if config.Debug() { @@ -245,11 +325,6 @@ func DoLogOut(c *cli.Context) error { return err } - // Delete local user data - err = fileutils.DeleteFile(filepath.Join(config.UserDataDir(c.App.ExtraInfo()["configDir"]), config.UserFile)) - if err != nil { - return err - } - - return nil + // delete local user file + return config.DeleteUser(c) } diff --git a/api/posts.go b/api/posts.go index 77f82a1..033cac3 100644 --- a/api/posts.go +++ b/api/posts.go @@ -12,6 +12,7 @@ import ( writeas "github.com/writeas/go-writeas/v2" "github.com/writeas/writeas-cli/config" + "github.com/writeas/writeas-cli/executable" "github.com/writeas/writeas-cli/fileutils" "github.com/writeas/writeas-cli/log" cli "gopkg.in/urfave/cli.v1" @@ -42,7 +43,11 @@ type RemotePost struct { } func AddPost(c *cli.Context, id, token string) error { - f, err := os.OpenFile(filepath.Join(config.UserDataDir(c.App.ExtraInfo()["configDir"]), postsFile), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600) + hostDir, err := config.HostDirectory(c) + if err != nil { + return fmt.Errorf("Error checking for host directory: %v", err) + } + f, err := os.OpenFile(filepath.Join(config.UserDataDir(c.App.ExtraInfo()["configDir"]), hostDir, postsFile), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600) if err != nil { return fmt.Errorf("Error creating local posts list: %s", err) } @@ -60,10 +65,18 @@ func AddPost(c *cli.Context, id, token string) error { // ClaimPost adds a local post to the authenticated user's account and deletes // the local reference func ClaimPosts(c *cli.Context, localPosts *[]Post) (*[]writeas.ClaimPostResult, error) { - cl, err := newClient(c, true) + cl, err := newClient(c) if err != nil { return nil, err } + + u, _ := config.LoadUser(c) + if u != nil { + cl.SetToken(u.AccessToken) + } else { + return nil, fmt.Errorf("Not currently logged in. Authenticate with: " + executable.Name() + " auth ") + } + postsToClaim := make([]writeas.OwnedPostParams, len(*localPosts)) for i, post := range *localPosts { postsToClaim[i] = writeas.OwnedPostParams{ @@ -76,7 +89,8 @@ func ClaimPosts(c *cli.Context, localPosts *[]Post) (*[]writeas.ClaimPostResult, } func TokenFromID(c *cli.Context, id string) string { - post := fileutils.FindLine(filepath.Join(config.UserDataDir(c.App.ExtraInfo()["configDir"]), postsFile), id) + hostDir, _ := config.HostDirectory(c) + post := fileutils.FindLine(filepath.Join(config.UserDataDir(c.App.ExtraInfo()["configDir"]), hostDir, postsFile), id) if post == "" { return "" } @@ -89,12 +103,15 @@ func TokenFromID(c *cli.Context, id string) string { return parts[1] } -func RemovePost(path, id string) { - fileutils.RemoveLine(filepath.Join(config.UserDataDir(path), postsFile), id) +func RemovePost(c *cli.Context, id string) { + hostDir, _ := config.HostDirectory(c) + fullPath := filepath.Join(config.UserDataDir(c.App.ExtraInfo()["configDir"]), hostDir, postsFile) + fileutils.RemoveLine(fullPath, id) } func GetPosts(c *cli.Context) *[]Post { - lines := fileutils.ReadData(filepath.Join(config.UserDataDir(c.App.ExtraInfo()["configDir"]), postsFile)) + hostDir, _ := config.HostDirectory(c) + lines := fileutils.ReadData(filepath.Join(config.UserDataDir(c.App.ExtraInfo()["configDir"]), hostDir, postsFile)) posts := []Post{} diff --git a/api/sync.go b/api/sync.go index b093be9..e57a31b 100644 --- a/api/sync.go +++ b/api/sync.go @@ -8,6 +8,7 @@ import ( "path/filepath" "github.com/writeas/writeas-cli/config" + "github.com/writeas/writeas-cli/executable" "github.com/writeas/writeas-cli/fileutils" "github.com/writeas/writeas-cli/log" cli "gopkg.in/urfave/cli.v1" @@ -25,14 +26,21 @@ func CmdPull(c *cli.Context) error { } // Create posts directory if needed if cfg.Posts.Directory == "" { - syncSetUp(c.App.ExtraInfo()["configDir"], cfg) + syncSetUp(c, cfg) } - cl, err := newClient(c, true) + cl, err := newClient(c) if err != nil { return err } + u, _ := config.LoadUser(c) + if u != nil { + cl.SetToken(u.AccessToken) + } else { + return fmt.Errorf("Not currently logged in. Authenticate with: " + executable.Name() + " auth ") + } + // Fetch posts posts, err := cl.GetUserPosts() if err != nil { @@ -79,10 +87,10 @@ func CmdPull(c *cli.Context) error { return nil } -func syncSetUp(path string, cfg *config.UserConfig) error { +func syncSetUp(c *cli.Context, cfg *config.Config) error { // Get user information and fail early (before we make the user do // anything), if we're going to - u, err := config.LoadUser(config.UserDataDir(path)) + u, err := config.LoadUser(c) if err != nil { return err } @@ -118,7 +126,7 @@ func syncSetUp(path string, cfg *config.UserConfig) error { // Save preference cfg.Posts.Directory = dir - err = config.SaveConfig(config.UserDataDir(path), cfg) + err = config.SaveConfig(config.UserDataDir(c.App.ExtraInfo()["configDir"]), cfg) if err != nil { if config.Debug() { log.Errorln("Unable to save config: %s", err) diff --git a/cmd/wf/.gitignore b/cmd/wf/.gitignore new file mode 100644 index 0000000..89b7a51 --- /dev/null +++ b/cmd/wf/.gitignore @@ -0,0 +1 @@ +wf \ No newline at end of file diff --git a/cmd/wf/GUIDE.md b/cmd/wf/GUIDE.md new file mode 100644 index 0000000..48e4615 --- /dev/null +++ b/cmd/wf/GUIDE.md @@ -0,0 +1,164 @@ +# WriteFreely CLI User Guide + +The WriteFreely Command-Line Interface (CLI) is a cross-platform tool for publishing text to any [WriteFreely](https://writefreely.org) instance. It is designed to be simple, scriptable, do one job (publishing) well, and work as you'd expect with other command-line tools. + +WriteFreely is the software behind [Write.as](https://write.as). While the WriteFreely CLI supports publishing to Write.as, we recommend using the dedicated [Write.as CLI](https://github.com/writeas/writeas-cli/tree/master/cmd/writeas#readme) to get the full features of the platform, including anonymous publishing. + +**The WriteFreely CLI is compatible with WriteFreely v0.11 or later.** + +## Uses + +These are a few common uses for `wf`. If you get stuck or want to know more, run `wf [command] --help`. If you still have questions, [ask us](https://write.as/contact). + +### Overview + +``` + wf [global options] command [command options] [arguments...] + +COMMANDS: + post Alias for default action: create post from stdin + new Compose a new post from the command-line and publish + publish Publish a file + delete Delete a post + update Update (overwrite) a post + get Read a raw post + posts List all of your posts + blogs List blogs + auth Authenticate with a WriteFreely instance + logout Log out of a WriteFreely instance + help, h Shows a list of commands or help for one command + +GLOBAL OPTIONS: + -c value, -b value Optional blog to post to + --insecure Send request insecurely. + --tor, -t Perform action on Tor hidden service + --tor-port value Use a different port to connect to Tor (default: 9150) + --code Specifies this post is code + --verbose, -v Make the operation more talkative + --font value Sets post font to given value (default: "mono") + --lang value Sets post language to given ISO 639-1 language code + --user-agent value Sets the User-Agent for API requests + --host value, -H value Use the given WriteFreely instance hostname + --user value, -u value Use the given account username + --help, -h show help + --version, -V print the version +``` + +#### Authenticate + +To use the WriteFreely CLI, you'll first need to authenticate with the WriteFreely instance you want to interact with. + +You may authenticate with as many WriteFreely instances and accounts as you want. But the first account you authenticate with will automatically be set as the default instance to operate on, so you don't have to supply `--host` and `--user` with every command. + +```bash +$ wf --host pencil.writefree.ly auth username +Password: ************ +``` + +In this example, you'll be authenticated as the user **username** on the WriteFreely instance **https://pencil.writefree.ly**. + +#### Choosing an account + +To select the WriteFreely instance and account you want to interact with, supply the `--host` and `--user` flags at the beginning of your `wf` command, e.g.: + +``` +$ wf --host pencil.writefree.ly --user username +``` + +If you're authenticated with only one account on any given WriteFreely instance, you only need to supply the `--host`, and `wf` will automatically use the correct account. E.g.: + +``` +$ wf --host pencil.writefree.ly +``` + +If a default account is set in `~/.writefreely/config.ini` and you want to use it, you don't need to supply any additional arguments. E.g.: + +``` +$ wf +``` + +#### Share something + +By default, `wf` creates a post with a `monospace` typeface that doesn't word wrap (scrolls horizontally). It will return a single line with a URL, and automatically copy that URL to the clipboard. + +```bash +$ echo "Hello world!" | wf +https://pencil.writefree.ly/aaaaazzzzz +``` + +This is generally more useful for posting terminal output or code, like so (the `--code` flag turns on syntax highlighting): + +macOS / Linux: `cat cmd/wf/cli.go | wf --code` + +Windows: `type cmd/wf/cli.go | wf.exe --code` + +#### Output a post + +This outputs any WriteFreely post with the given ID. + +```bash +$ wf get aaaaazzzzz +Hello world! +``` + +#### List all blogs + +This will output a list of the authenticated user's blogs. +```bash +$ wf blogs +Alias Title +user An Example Blog +dev My Dev Log +``` + +#### List posts + +This lists all draft posts you've published. + +Pass the `--url` flag to show the list with full post URLs. + +```bash +$ wf posts +aaaaazzzzz + +$ wf posts -url +https://pencil.writefree.ly/aaaaazzzzz + +$ wf posts +ID +aaaaazzzzz +``` + +#### Delete a post + +This permanently deletes a post with the given ID. + +```bash +$ wf delete aaaaazzzzz +``` + +#### Update a post + +This completely overwrites an existing post with the given ID. + +```bash +$ echo "See you later!" | wf update aaaaazzzzz +``` + +### Composing posts + +If you simply have a penchant for never leaving your keyboard, `wf` is great for composing new posts from the command-line. Just use the `new` subcommand. + +`wf new` will open your favorite command-line editor, as specified by your `WRITEAS_EDITOR` or `EDITOR` environment variables (in that order), falling back to `vim` on OS X / *nix. + +Customize your post's appearance with the `--font` flag: + +| Argument | Appearance (Typeface) | Word Wrap? | +| -------- | --------------------- | ---------- | +| `sans` | Sans-serif (Open Sans) | Yes | +| `serif` | Serif (Lora) | Yes | +| `wrap` | Monospace | Yes | +| `mono` | Monospace | No | +| `code` | Syntax-highlighted monospace | No | + +Put it all together, e.g. publish with a sans-serif font: `wf new --font sans` diff --git a/cmd/wf/README.md b/cmd/wf/README.md new file mode 100644 index 0000000..64be198 --- /dev/null +++ b/cmd/wf/README.md @@ -0,0 +1,95 @@ +wf-cli +====== +![GPL](https://img.shields.io/github/license/writeas/writeas-cli.svg) [![Go Report Card](https://goreportcard.com/badge/github.com/writeas/writeas-cli)](https://goreportcard.com/report/github.com/writeas/writeas-cli) [![#writeas on freenode](https://img.shields.io/badge/freenode-%23writeas-blue.svg)](http://webchat.freenode.net/?channels=writeas) [![Discuss on our forum](https://img.shields.io/discourse/https/discuss.write.as/users.svg?label=forum)](https://discuss.write.as/c/development) + +Command line utility for publishing to any [WriteFreely](https://writefreely.org) instance. Works on Windows, macOS, and Linux. + +**The WriteFreely CLI is compatible with WriteFreely v0.11 or later.** + +## Features + +* Authenticate with any WriteFreely instance +* Publish drafts +* Manage multiple WriteFreely accounts on multiple instances +* A stable, easy back-end for your GUI app or desktop-based workflow +* Locally keeps track of any posts you make +* Update and delete posts +* Fetch any post by ID + +## Installing +The easiest way to get the CLI is to download a pre-built executable for your OS. + +### Download +[![Latest release](https://img.shields.io/github/release/writeas/writeas-cli.svg)](https://github.com/writeas/writeas-cli/releases/latest) ![Total downloads](https://img.shields.io/github/downloads/writeas/writeas-cli/total.svg) + +Get the latest version for your operating system as a standalone executable. + +**Windows**
+Download the [64-bit](https://github.com/writeas/writeas-cli/releases/download/v2.0.0/wf_2.0.0_windows_amd64.zip) or [32-bit](https://github.com/writeas/writeas-cli/releases/download/v2.0.0/wf_2.0.0_windows_386.zip) executable and put it somewhere in your `%PATH%`. + +**macOS**
+Download the [64-bit](https://github.com/writeas/writeas-cli/releases/download/v2.0.0/wf_2.0.0_darwin_amd64.zip) executable and put it somewhere in your `$PATH`, like `/usr/local/bin`. + +**Debian-based Linux**
+```bash +sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys DBE07445 +sudo add-apt-repository "deb http://updates.writeas.org xenial main" +sudo apt-get update && sudo apt-get install wf-cli +``` + +**Linux (other)**
+Download the [64-bit](https://github.com/writeas/writeas-cli/releases/download/v2.0.0/wf_2.0.0_linux_amd64.tar.gz) or [32-bit](https://github.com/writeas/writeas-cli/releases/download/v2.0.0/wf_2.0.0_linux_386.tar.gz) executable and put it somewhere in your `$PATH`, like `/usr/local/bin`. + +### Go get it +```bash +go get github.com/writeas/writeas-cli/cmd/wf +``` + +Once this finishes, you'll see `wf` or `wf.exe` inside `$GOPATH/bin/`. + +## Upgrading + +To upgrade the CLI, download and replace the executable you downloaded before. + +If you previously installed with `go get`, run it again with the `-u` option. + +```bash +go get -u github.com/writeas/writeas-cli/cmd/wf +``` + +## Usage + +See full usage documentation on our [User Guide](https://github.com/writeas/writeas-cli/blob/master/cmd/wf/GUIDE.md). + +``` + wf [global options] command [command options] [arguments...] + +COMMANDS: + post Alias for default action: create post from stdin + new Compose a new post from the command-line and publish + publish Publish a file + delete Delete a post + update Update (overwrite) a post + get Read a raw post + posts List draft posts + blogs List blogs + accounts List all currently logged in accounts + auth Authenticate with a WriteFreely instance + logout Log out of a WriteFreely instance + help, h Shows a list of commands or help for one command + +GLOBAL OPTIONS: + -c value, -b value Optional blog to post to + --tor, -t Perform action on Tor hidden service + --tor-port value Use a different port to connect to Tor (default: 9150) + --code Specifies this post is code + --md Returns post URL with Markdown enabled + --verbose, -v Make the operation more talkative + --font value Sets post font to given value (default: "mono") + --lang value Sets post language to given ISO 639-1 language code + --user-agent value Sets the User-Agent for API requests + --host value, -H value Operate against a custom hostname + --user value, -u value Use authenticated user, other than default + --help, -h show help + --version, -V print the version +``` diff --git a/cmd/wf/commands.go b/cmd/wf/commands.go new file mode 100644 index 0000000..c55157c --- /dev/null +++ b/cmd/wf/commands.go @@ -0,0 +1,276 @@ +package main + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + "text/tabwriter" + + "github.com/hashicorp/go-multierror" + "github.com/writeas/writeas-cli/api" + "github.com/writeas/writeas-cli/commands" + "github.com/writeas/writeas-cli/config" + "github.com/writeas/writeas-cli/executable" + "github.com/writeas/writeas-cli/log" + cli "gopkg.in/urfave/cli.v1" +) + +func requireAuth(f cli.ActionFunc, action string) cli.ActionFunc { + return func(c *cli.Context) error { + // check for logged in users when host is provided without user + if c.GlobalIsSet("host") && !c.GlobalIsSet("user") { + // multiple users should display a list + if num, users, err := usersLoggedIn(c); num > 1 && err == nil { + return cli.NewExitError(fmt.Sprintf("Multiple logged in users, please use '-u' or '-user' to specify one of:\n%s", strings.Join(users, ", ")), 1) + } else if num == 1 && err == nil { + // single user found for host should be set as user flag so LoadUser can + // succeed, and notify the client + if err := c.GlobalSet("user", users[0]); err != nil { + return cli.NewExitError(fmt.Sprintf("Failed to set user flag for only logged in user at host %s: %v", users[0], err), 1) + } + log.Info(c, "Host specified without user flag, using logged in user: %s\n", users[0]) + } else if err != nil { + return cli.NewExitError(fmt.Sprintf("Failed to check for logged in users: %v", err), 1) + } + } else if !c.GlobalIsSet("host") && !c.GlobalIsSet("user") { + // check for global configured pair host/user + cfg, err := config.LoadConfig(config.UserDataDir(c.App.ExtraInfo()["configDir"])) + if err != nil { + return cli.NewExitError(fmt.Sprintf("Failed to load config from file: %v", err), 1) + // set flags if found + } + // set flags if both were found in config + if cfg.Default.Host != "" && cfg.Default.User != "" { + err = c.GlobalSet("host", cfg.Default.Host) + if err != nil { + return cli.NewExitError(fmt.Sprintf("Failed to set host from global config: %v", err), 1) + } + err = c.GlobalSet("user", cfg.Default.User) + if err != nil { + return cli.NewExitError(fmt.Sprintf("Failed to set user from global config: %v", err), 1) + } + } else { + num, err := totalUsersLoggedIn(c) + if err != nil { + return cli.NewExitError(fmt.Sprintf("Failed to check for logged in users: %v", err), 1) + } else if num > 0 { + return cli.NewExitError("You are authenticated, but have no default user/host set. Supply -user and -host flags.", 1) + } + } + } + u, err := config.LoadUser(c) + if err != nil { + return cli.NewExitError(fmt.Sprintf("Couldn't load user: %v", err), 1) + } + if u == nil { + return cli.NewExitError("You must be authenticated to "+action+".\nLog in first with: "+executable.Name()+" auth ", 1) + } + + return f(c) + } +} + +// usersLoggedIn checks for logged in users for the set host flag +// it returns the number of users and a slice of usernames +func usersLoggedIn(c *cli.Context) (int, []string, error) { + path, err := config.UserHostDir(c) + if err != nil { + return 0, nil, err + } + dir, err := os.Open(path) + if err != nil { + return 0, nil, err + } + contents, err := dir.Readdir(0) + if err != nil { + return 0, nil, err + } + var names []string + for _, file := range contents { + if file.IsDir() { + // stat user.json + if _, err := os.Stat(filepath.Join(path, file.Name(), "user.json")); err == nil { + names = append(names, file.Name()) + } + } + } + return len(names), names, nil +} + +// totalUsersLoggedIn checks for logged in users for any host +// it returns the number of users and an error if any +func totalUsersLoggedIn(c *cli.Context) (int, error) { + path := config.UserDataDir(c.App.ExtraInfo()["configDir"]) + dir, err := os.Open(path) + if err != nil { + return 0, err + } + contents, err := dir.Readdir(0) + if err != nil { + return 0, err + } + count := 0 + for _, file := range contents { + if file.IsDir() { + subDir, err := os.Open(filepath.Join(path, file.Name())) + if err != nil { + return 0, err + } + subContents, err := subDir.Readdir(0) + if err != nil { + return 0, err + } + for _, subFile := range subContents { + if subFile.IsDir() { + if _, err := os.Stat(filepath.Join(path, file.Name(), subFile.Name(), "user.json")); err == nil { + count++ + } + } + } + } + } + return count, nil +} + +func cmdAuth(c *cli.Context) error { + err := commands.CmdAuth(c) + if err != nil { + return err + } + + // Get the username from the command, just like commands.CmdAuth does + username := c.Args().Get(0) + + // Update config if this is user's first auth + cfg, err := config.LoadConfig(config.UserDataDir(c.App.ExtraInfo()["configDir"])) + if err != nil { + log.Errorln("Not saving config. Unable to load config: %s", err) + return err + } + if cfg.Default.Host == "" && cfg.Default.User == "" { + // This is user's first auth, so save defaults + cfg.Default.Host = api.HostURL(c) + cfg.Default.User = username + err = config.SaveConfig(config.UserDataDir(c.App.ExtraInfo()["configDir"]), cfg) + if err != nil { + log.Errorln("Not saving config. Unable to save config: %s", err) + return err + } + fmt.Printf("Set %s on %s as default account.\n", username, c.GlobalString("host")) + } + + return nil +} + +func cmdLogOut(c *cli.Context) error { + err := commands.CmdLogOut(c) + if err != nil { + return err + } + + // Remove this from config if it's the default account + cfg, err := config.LoadConfig(config.UserDataDir(c.App.ExtraInfo()["configDir"])) + if err != nil { + log.Errorln("Not updating config. Unable to load: %s", err) + return err + } + username, err := config.CurrentUser(c) + if err != nil { + log.Errorln("Not updating config. Unable to load current user: %s", err) + return err + } + reqHost := api.HostURL(c) + if reqHost == "" { + // No --host given, so we're using the default host + reqHost = cfg.Default.Host + } + if cfg.Default.Host == reqHost && cfg.Default.User == username { + // We're logging out of default username + host, so remove from config file + cfg.Default.Host = "" + cfg.Default.User = "" + err = config.SaveConfig(config.UserDataDir(c.App.ExtraInfo()["configDir"]), cfg) + if err != nil { + log.Errorln("Not updating config. Unable to save config: %s", err) + return err + } + } + + return nil +} + +func cmdAccounts(c *cli.Context) error { + // get user config dir + userDir := config.UserDataDir(c.App.ExtraInfo()["configDir"]) + // load defaults + cfg, err := config.LoadConfig(userDir) + if err != nil { + return cli.NewExitError("Could not load default user configuration", 1) + } + defaultUser := cfg.Default.User + defaultHost := cfg.Default.Host + if parts := strings.Split(defaultHost, "://"); len(parts) > 1 { + defaultHost = parts[1] + } + // get each host dir + files, err := ioutil.ReadDir(userDir) + if err != nil { + return cli.NewExitError("Could not read user configuration directory", 1) + } + // accounts will be a slice of slices of string. the first string in + // a subslice should always be the hostname + accounts := [][]string{} + for _, file := range files { + if file.IsDir() { + dirName := file.Name() + // get each user in host dir + users, err := usersFromDir(filepath.Join(userDir, dirName)) + if err != nil { + log.Info(c, "Failed to get users from %s: %v", dirName, err) + continue + } + if len(users) != 0 { + // append the slice of users as a new slice in accounts w/ the host prepended + accounts = append(accounts, append([]string{dirName}, users...)) + } + } + } + + // print out all logged in accounts + tw := tabwriter.NewWriter(os.Stdout, 10, 2, 2, ' ', tabwriter.TabIndent) + if len(accounts) == 0 && (c.Bool("v") || c.Bool("verbose") || c.GlobalBool("v") || c.GlobalBool("verbose")) { + fmt.Fprintf(tw, "%s\t", "No authenticated accounts found.\n") + } + for _, userList := range accounts { + host := userList[0] + for _, username := range userList[1:] { + if host == defaultHost && username == defaultUser { + fmt.Fprintf(tw, "[%s]\t%s (default)\n", host, username) + continue + } + fmt.Fprintf(tw, "[%s]\t%s\n", host, username) + } + } + return tw.Flush() +} + +func usersFromDir(path string) ([]string, error) { + users := make([]string, 0, 4) + files, err := ioutil.ReadDir(path) + if err != nil { + return nil, err + } + var errs error + for _, file := range files { + if file.IsDir() { + _, err := os.Stat(filepath.Join(path, file.Name(), "user.json")) + if err != nil { + err = multierror.Append(errs, err) + continue + } + users = append(users, file.Name()) + } + } + return users, errs +} diff --git a/cmd/wf/config_nix.go b/cmd/wf/config_nix.go new file mode 100644 index 0000000..ddc1bff --- /dev/null +++ b/cmd/wf/config_nix.go @@ -0,0 +1,5 @@ +// +build !windows + +package main + +const configDir = ".writefreely" diff --git a/cmd/wf/config_win.go b/cmd/wf/config_win.go new file mode 100644 index 0000000..1673fa1 --- /dev/null +++ b/cmd/wf/config_win.go @@ -0,0 +1,5 @@ +// +build windows + +package main + +const configDir = "WriteFreely" diff --git a/cmd/wf/flags.go b/cmd/wf/flags.go new file mode 100644 index 0000000..8df2a85 --- /dev/null +++ b/cmd/wf/flags.go @@ -0,0 +1,16 @@ +package main + +import ( + "gopkg.in/urfave/cli.v1" +) + +var flags = []cli.Flag{ + cli.StringFlag{ + Name: "host, H", + Usage: "Use the given WriteFreely instance hostname", + }, + cli.StringFlag{ + Name: "user, u", + Usage: "Use the given account username", + }, +} diff --git a/cmd/wf/main.go b/cmd/wf/main.go new file mode 100644 index 0000000..fc516c3 --- /dev/null +++ b/cmd/wf/main.go @@ -0,0 +1,250 @@ +package main + +import ( + "os" + + "github.com/writeas/writeas-cli/commands" + "github.com/writeas/writeas-cli/config" + cli "gopkg.in/urfave/cli.v1" +) + +func main() { + appInfo := map[string]string{ + "configDir": configDir, + "version": "1.0", + } + config.DirMustExist(config.UserDataDir(appInfo["configDir"])) + cli.VersionFlag = cli.BoolFlag{ + Name: "version, V", + Usage: "print the version", + } + + // Run the app + app := cli.NewApp() + app.Name = "wf" + app.Version = appInfo["version"] + app.Usage = "Publish to any WriteFreely instance from the command-line." + // TODO: who is the author? the contributors? link to GH? + app.Authors = []cli.Author{ + { + Name: "Write.as", + Email: "hello@write.as", + }, + } + app.ExtraInfo = func() map[string]string { + return appInfo + } + app.Action = requireAuth(commands.CmdPost, "publish") + app.Flags = append(config.PostFlags, flags...) + app.Commands = []cli.Command{ + { + Name: "post", + Usage: "Alias for default action: create post from stdin", + Action: requireAuth(commands.CmdPost, "publish"), + Flags: config.PostFlags, + Description: `Create a new post on WriteFreely from stdin. + + Use the --code flag to indicate that the post should use syntax + highlighting. Or use the --font [value] argument to set the post's + appearance, where [value] is mono, monospace (default), wrap (monospace + font with word wrapping), serif, or sans.`, + }, + { + Name: "new", + Usage: "Compose a new post from the command-line and publish", + Description: `An alternative to piping data to the program. + + On Windows, this will use 'copy con' to start reading what you input from the + prompt. Press F6 or Ctrl-Z then Enter to end input. + On *nix, this will use the best available text editor, starting with the + value set to the WRITEAS_EDITOR or EDITOR environment variable, or vim, or + finally nano. + + Use the --code flag to indicate that the post should use syntax + highlighting. Or use the --font [value] argument to set the post's + appearance, where [value] is mono, monospace (default), wrap (monospace + font with word wrapping), serif, or sans. + + If posting fails for any reason, 'wf' will show you the temporary file + location and how to pipe it to 'wf' to retry.`, + Action: requireAuth(commands.CmdNew, "publish"), + Flags: config.PostFlags, + }, + { + Name: "publish", + Usage: "Publish a file", + Action: requireAuth(commands.CmdPublish, "publish"), + Flags: config.PostFlags, + }, + { + Name: "delete", + Usage: "Delete a post", + Action: requireAuth(commands.CmdDelete, "delete a post"), + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "tor, t", + Usage: "Delete via Tor hidden service", + }, + cli.IntFlag{ + Name: "tor-port", + Usage: "Use a different port to connect to Tor", + Value: 9150, + }, + cli.BoolFlag{ + Name: "verbose, v", + Usage: "Make the operation more talkative", + }, + }, + }, + { + Name: "update", + Usage: "Update (overwrite) a post", + Action: requireAuth(commands.CmdUpdate, "update a post"), + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "tor, t", + Usage: "Update via Tor hidden service", + }, + cli.IntFlag{ + Name: "tor-port", + Usage: "Use a different port to connect to Tor", + Value: 9150, + }, + cli.BoolFlag{ + Name: "code", + Usage: "Specifies this post is code", + }, + cli.StringFlag{ + Name: "font", + Usage: "Sets post font to given value", + }, + cli.BoolFlag{ + Name: "verbose, v", + Usage: "Make the operation more talkative", + }, + }, + }, + { + Name: "get", + Usage: "Read a raw post", + Action: commands.CmdGet, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "tor, t", + Usage: "Get from Tor hidden service", + }, + cli.IntFlag{ + Name: "tor-port", + Usage: "Use a different port to connect to Tor", + Value: 9150, + }, + cli.BoolFlag{ + Name: "verbose, v", + Usage: "Make the operation more talkative", + }, + }, + }, + { + Name: "posts", + Usage: "List draft posts", + Action: requireAuth(commands.CmdListPosts, "view posts"), + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "id", + Usage: "Show list with post IDs (default)", + }, + cli.BoolFlag{ + Name: "url", + Usage: "Show list with URLs", + }, + cli.BoolFlag{ + Name: "verbose, v", + Usage: "Show verbose post listing", + }, + }, + }, { + Name: "blogs", + Usage: "List blogs", + Action: requireAuth(commands.CmdCollections, "view blogs"), + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "tor, t", + Usage: "Authenticate via Tor hidden service", + }, + cli.IntFlag{ + Name: "tor-port", + Usage: "Use a different port to connect to Tor", + Value: 9150, + }, + cli.BoolFlag{ + Name: "url", + Usage: "Show list with URLs", + }, + }, + }, { + Name: "accounts", + Usage: "List all currently logged in accounts", + Action: cmdAccounts, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "verbose, v", + Usage: "Make the operation more talkative", + }, + }, + }, { + Name: "auth", + Usage: "Authenticate with a WriteFreely instance", + Action: cmdAuth, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "tor, t", + Usage: "Authenticate via Tor hidden service", + }, + cli.IntFlag{ + Name: "tor-port", + Usage: "Use a different port to connect to Tor", + Value: 9150, + }, + cli.BoolFlag{ + Name: "verbose, v", + Usage: "Make the operation more talkative", + }, + }, + }, + { + Name: "logout", + Usage: "Log out of a WriteFreely instance", + Action: requireAuth(cmdLogOut, "logout"), + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "tor, t", + Usage: "Authenticate via Tor hidden service", + }, + cli.IntFlag{ + Name: "tor-port", + Usage: "Use a different port to connect to Tor", + Value: 9150, + }, + cli.BoolFlag{ + Name: "verbose, v", + Usage: "Make the operation more talkative", + }, + }, + }, + } + + cli.CommandHelpTemplate = `NAME: + {{.Name}} - {{.Usage}} + +USAGE: + wf {{.Name}}{{if .Flags}} [command options]{{end}} [arguments...]{{if .Description}} + +DESCRIPTION: + {{.Description}}{{end}}{{if .Flags}} + +OPTIONS: + {{range .Flags}}{{.}} + {{end}}{{ end }} +` + app.Run(os.Args) +} diff --git a/cmd/writeas/GUIDE.md b/cmd/writeas/GUIDE.md new file mode 100644 index 0000000..fae366e --- /dev/null +++ b/cmd/writeas/GUIDE.md @@ -0,0 +1,149 @@ +# Write.as CLI User Guide + +The Write.as Command-Line Interface (CLI) is a cross-platform tool for publishing text to [Write.as](https://write.as) and its other sites, like [Paste.as](https://paste.as). It is designed to be simple, scriptable, do one job (publishing) well, and work as you'd expect with other command-line tools. + +Write.as is a text-publishing service that protects your privacy. There's no sign up required to publish, but if you do sign up, you can access posts across devices and compile collections of them in what most people would call a "blog". + +## Uses + +These are a few common uses for `writeas`. If you get stuck or want to know more, run `writeas [command] --help`. If you still have questions, [ask us](https://write.as/contact). + +### Overview + +``` + writeas [global options] command [command options] [arguments...] + +COMMANDS: + post Alias for default action: create post from stdin + new Compose a new post from the command-line and publish + publish Publish a file to Write.as + delete Delete a post + update Update (overwrite) a post + get Read a raw post + add Add an existing post locally + posts List all of your posts + claim Claim local unsynced posts + blogs List blogs + claim Claim local unsynced posts + auth Authenticate with Write.as + logout Log out of Write.as + help, h Shows a list of commands or help for one command + +GLOBAL OPTIONS: + -c value, -b value Optional blog to post to + --tor, -t Perform action on Tor hidden service + --tor-port value Use a different port to connect to Tor (default: 9150) + --code Specifies this post is code + --md Returns post URL with Markdown enabled + --verbose, -v Make the operation more talkative + --font value Sets post font to given value (default: "mono") + --lang value Sets post language to given ISO 639-1 language code + --user-agent value Sets the User-Agent for API requests + --help, -h show help + --version, -V print the version +``` + +#### Share something + +By default, `writeas` creates a post with a `monospace` typeface that doesn't word wrap (scrolls horizontally). It will return a single line with a URL, and automatically copy that URL to the clipboard: + +```bash +$ echo "Hello world!" | writeas +https://write.as/aaaazzzzzzzza +``` + +This is generally more useful for posting terminal output or code, like so (the `--code` flag turns on syntax highlighting): + +macOS / Linux: `cat writeas/cli.go | writeas --code` + +Windows: `type writeas/cli.go | writeas.exe --code` + +#### Output a post + +This outputs any Write.as post with the given ID. + +```bash +$ writeas get aaaazzzzzzzza +Hello world! +``` + +#### Authenticate + +This will authenticate with write.as and store the user access token locally, until you explicitly logout. +```bash +$ writeas auth username +Password: ************ +``` + +#### List all blogs + +This will output a list of the authenticated user's blogs. +```bash +$ writeas blogs +Alias Title +user An Example Blog +dev My Dev Log +``` + +#### List posts + +This lists all anonymous posts you've published. If authenticated, it will include posts on your account as well as any local / unclaimed posts. + +Pass the `--url` flag to show the list with full post URLs, and the `--md` flag to return URLs with Markdown enabled. + +To see post IDs with their Edit Tokens pass the `--v` flag. + +```bash +$ writeas posts +aaaazzzzzzzza + +$ writeas posts -url +https://write.as/aaaazzzzzzzza + +$ writeas posts -v +ID Token +aaaazzzzzzzza dhuieoj23894jhf984hdfs9834hdf84j +``` + +#### Delete a post + +This permanently deletes a post you own. + +```bash +$ writeas delete aaaazzzzzzzza +``` + +#### Update a post + +This completely overwrites an existing post you own. + +```bash +$ echo "See you later!" | writeas update aaaazzzzzzzza +``` + +#### Claim a post + +This moves an unsynced local post to a draft on your account. You will need to authenticate first. +```bash +$ writeas claim aaaazzzzzzzza +``` + +### Composing posts + +If you simply have a penchant for never leaving your keyboard, `writeas` is great for composing new posts from the command-line. Just use the `new` subcommand. + +`writeas new` will open your favorite command-line editor, as specified by your `WRITEAS_EDITOR` or `EDITOR` environment variables (in that order), falling back to `vim` on OS X / *nix. + +Customize your post's appearance with the `--font` flag: + +| Argument | Appearance (Typeface) | Word Wrap? | +| -------- | --------------------- | ---------- | +| `sans` | Sans-serif (Open Sans) | Yes | +| `serif` | Serif (Lora) | Yes | +| `wrap` | Monospace | Yes | +| `mono` | Monospace | No | +| `code` | Syntax-highlighted monospace | No | + +Put it all together, e.g. publish with a sans-serif font: `writeas new --font sans` + +If you're publishing Markdown, supply the `--md` flag to get a URL back that will render Markdown, e.g.: `writeas new --font sans --md` diff --git a/cmd/writeas/README.md b/cmd/writeas/README.md new file mode 100644 index 0000000..63cc7d4 --- /dev/null +++ b/cmd/writeas/README.md @@ -0,0 +1,95 @@ +writeas-cli +=========== +![GPL](https://img.shields.io/github/license/writeas/writeas-cli.svg) [![Go Report Card](https://goreportcard.com/badge/github.com/writeas/writeas-cli)](https://goreportcard.com/report/github.com/writeas/writeas-cli) [![#writeas on freenode](https://img.shields.io/badge/freenode-%23writeas-blue.svg)](http://webchat.freenode.net/?channels=writeas) [![Discuss on our forum](https://img.shields.io/discourse/https/discuss.write.as/users.svg?label=forum)](https://discuss.write.as/c/development) + +Command line utility for publishing to [Write.as](https://write.as). Works on Windows, macOS, and Linux. + +## Features + +* Publish anonymously to Write.as +* Authenticate with a Write.as account +* A stable, easy back-end for your [GUI app](https://write.as/apps/desktop) or desktop-based workflow +* Compatible with our [Tor hidden service](http://writeas7pm7rcdqg.onion/) +* Locally keeps track of any posts you make +* Update and delete posts, anonymous and authenticated +* Fetch any post by ID +* Add anonymous post credentials (like for one published with the [Android app](https://play.google.com/store/apps/details?id=com.abunchtell.writeas)) for editing + +## Installing +The easiest way to get the CLI is to download a pre-built executable for your OS. + +### Download +[![Latest release](https://img.shields.io/github/release/writeas/writeas-cli.svg)](https://github.com/writeas/writeas-cli/releases/latest) ![Total downloads](https://img.shields.io/github/downloads/writeas/writeas-cli/total.svg) + +Get the latest version for your operating system as a standalone executable. + +**Windows**
+Download the [64-bit](https://github.com/writeas/writeas-cli/releases/download/v2.0.0/writeas_2.0.0_windows_amd64.zip) or [32-bit](https://github.com/writeas/writeas-cli/releases/download/v2.0.0/writeas_2.0.0_windows_386.zip) executable and put it somewhere in your `%PATH%`. + +**macOS**
+Download the [64-bit](https://github.com/writeas/writeas-cli/releases/download/v2.0.0/writeas_2.0.0_darwin_amd64.zip) executable and put it somewhere in your `$PATH`, like `/usr/local/bin`. + +**Debian-based Linux**
+```bash +sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys DBE07445 +sudo add-apt-repository "deb http://updates.writeas.org xenial main" +sudo apt-get update && sudo apt-get install writeas-cli +``` + +**Linux (other)**
+Download the [64-bit](https://github.com/writeas/writeas-cli/releases/download/v2.0.0/writeas_2.0.0_linux_amd64.tar.gz) or [32-bit](https://github.com/writeas/writeas-cli/releases/download/v2.0.0/writeas_2.0.0_linux_386.tar.gz) executable and put it somewhere in your `$PATH`, like `/usr/local/bin`. + +### Go get it +```bash +go get github.com/writeas/writeas-cli/cmd/writeas +``` + +Once this finishes, you'll see `writeas` or `writeas.exe` inside `$GOPATH/bin/`. + +## Upgrading + +To upgrade the CLI, download and replace the executable you downloaded before. + +If you previously installed with `go get`, run it again with the `-u` option. + +```bash +go get -u github.com/writeas/writeas-cli/cmd/writeas +``` + +## Usage + +See full usage documentation on our [User Guide](https://github.com/writeas/writeas-cli/blob/master/cmd/writeas/GUIDE.md). + +``` + writeas [global options] command [command options] [arguments...] + +COMMANDS: + post Alias for default action: create post from stdin + new Compose a new post from the command-line and publish + publish Publish a file to Write.as + delete Delete a post + update Update (overwrite) a post + get Read a raw post + add Add an existing post locally + posts List all of your posts + blogs List blogs + claim Claim local unsynced posts + auth Authenticate with Write.as + logout Log out of Write.as + help, h Shows a list of commands or help for one command + +GLOBAL OPTIONS: + -c value, -b value Optional blog to post to + --tor, -t Perform action on Tor hidden service + --tor-port value Use a different port to connect to Tor (default: 9150) + --code Specifies this post is code + --md Returns post URL with Markdown enabled + --verbose, -v Make the operation more talkative + --font value Sets post font to given value (default: "mono") + --lang value Sets post language to given ISO 639-1 language code + --user-agent value Sets the User-Agent for API requests + --host value, -H value Operate against a custom hostname + --user value, -u value Use authenticated user, other than default + --help, -h show help + --version, -V print the version +``` diff --git a/cmd/writeas/config_nix.go b/cmd/writeas/config_nix.go index 6b3cb86..6c0ed02 100644 --- a/cmd/writeas/config_nix.go +++ b/cmd/writeas/config_nix.go @@ -2,6 +2,4 @@ package main -var appInfo = map[string]string{ - "configDir": ".writeas", -} +const configDir = ".writeas" diff --git a/cmd/writeas/config_win.go b/cmd/writeas/config_win.go index 9a7eea1..43d2bca 100644 --- a/cmd/writeas/config_win.go +++ b/cmd/writeas/config_win.go @@ -2,6 +2,4 @@ package main -var appInfo = map[string]string{ - "configDir": "Write.as", -} +const configDir = "Write.as" diff --git a/cmd/writeas/flags.go b/cmd/writeas/flags.go new file mode 100644 index 0000000..5fdaf8a --- /dev/null +++ b/cmd/writeas/flags.go @@ -0,0 +1,13 @@ +package main + +import ( + "gopkg.in/urfave/cli.v1" +) + +var flags = []cli.Flag{ + cli.StringFlag{ + Name: "user, u", + Hidden: true, + Value: "user", + }, +} diff --git a/cmd/writeas/main.go b/cmd/writeas/main.go index eec4500..e9c1019 100644 --- a/cmd/writeas/main.go +++ b/cmd/writeas/main.go @@ -5,12 +5,15 @@ import ( "github.com/writeas/writeas-cli/commands" "github.com/writeas/writeas-cli/config" - "github.com/writeas/writeas-cli/log" cli "gopkg.in/urfave/cli.v1" ) func main() { - initialize(appInfo["configDir"]) + appInfo := map[string]string{ + "configDir": configDir, + "version": "2.0", + } + config.DirMustExist(config.UserDataDir(appInfo["configDir"])) cli.VersionFlag = cli.BoolFlag{ Name: "version, V", Usage: "print the version", @@ -19,7 +22,7 @@ func main() { // Run the app app := cli.NewApp() app.Name = "writeas" - app.Version = config.Version + app.Version = appInfo["version"] app.Usage = "Publish text quickly" app.Authors = []cli.Author{ { @@ -31,7 +34,7 @@ func main() { return appInfo } app.Action = commands.CmdPost - app.Flags = config.PostFlags + app.Flags = append(config.PostFlags, flags...) app.Commands = []cli.Command{ { Name: "post", @@ -274,18 +277,3 @@ OPTIONS: ` app.Run(os.Args) } - -func initialize(dataDirName string) { - // Ensure we have a data directory to use - if !config.DataDirExists(dataDirName) { - err := config.CreateDataDir(dataDirName) - if err != nil { - if config.Debug() { - panic(err) - } else { - log.Errorln("Error creating data directory: %s", err) - return - } - } - } -} diff --git a/commands/commands.go b/commands/commands.go index a275e91..29ddc21 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -4,11 +4,13 @@ import ( "fmt" "io/ioutil" "os" + "strings" "text/tabwriter" "github.com/howeyc/gopass" "github.com/writeas/writeas-cli/api" "github.com/writeas/writeas-cli/config" + "github.com/writeas/writeas-cli/executable" "github.com/writeas/writeas-cli/log" cli "gopkg.in/urfave/cli.v1" ) @@ -67,7 +69,7 @@ func CmdNew(c *cli.Context) error { func CmdPublish(c *cli.Context) error { filename := c.Args().Get(0) if filename == "" { - return cli.NewExitError("usage: writeas publish ", 1) + return cli.NewExitError("usage: "+executable.Name()+" publish ", 1) } content, err := ioutil.ReadFile(filename) if err != nil { @@ -92,16 +94,16 @@ func CmdDelete(c *cli.Context) error { friendlyID := c.Args().Get(0) token := c.Args().Get(1) if friendlyID == "" { - return cli.NewExitError("usage: writeas delete []", 1) + return cli.NewExitError("usage: "+executable.Name()+" delete []", 1) } - u, _ := config.LoadUser(config.UserDataDir(c.App.ExtraInfo()["configDir"])) + u, _ := config.LoadUser(c) if token == "" { // Search for the token locally token = api.TokenFromID(c, friendlyID) if token == "" && u == nil { log.Errorln("Couldn't find an edit token locally. Did you create this post here?") - log.ErrorlnQuit("If you have an edit token, use: writeas delete %s ", friendlyID) + log.ErrorlnQuit("If you have an edit token, use: "+executable.Name()+" delete %s ", friendlyID) } } @@ -124,16 +126,16 @@ func CmdUpdate(c *cli.Context) error { friendlyID := c.Args().Get(0) token := c.Args().Get(1) if friendlyID == "" { - return cli.NewExitError("usage: writeas update []", 1) + return cli.NewExitError("usage: "+executable.Name()+" update []", 1) } - u, _ := config.LoadUser(config.UserDataDir(c.App.ExtraInfo()["configDir"])) + u, _ := config.LoadUser(c) if token == "" { // Search for the token locally token = api.TokenFromID(c, friendlyID) if token == "" && u == nil { log.Errorln("Couldn't find an edit token locally. Did you create this post here?") - log.ErrorlnQuit("If you have an edit token, use: writeas update %s ", friendlyID) + log.ErrorlnQuit("If you have an edit token, use: "+executable.Name()+" update %s ", friendlyID) } } @@ -155,7 +157,7 @@ func CmdUpdate(c *cli.Context) error { func CmdGet(c *cli.Context) error { friendlyID := c.Args().Get(0) if friendlyID == "" { - return cli.NewExitError("usage: writeas get ", 1) + return cli.NewExitError("usage: "+executable.Name()+" get ", 1) } if config.IsTor(c) { @@ -175,7 +177,7 @@ func CmdAdd(c *cli.Context) error { friendlyID := c.Args().Get(0) token := c.Args().Get(1) if friendlyID == "" || token == "" { - return cli.NewExitError("usage: writeas add ", 1) + return cli.NewExitError("usage: "+executable.Name()+" add ", 1) } err := api.AddPost(c, friendlyID, token) @@ -192,7 +194,7 @@ func CmdListPosts(c *cli.Context) error { posts := api.GetPosts(c) - u, _ := config.LoadUser(config.UserDataDir(c.App.ExtraInfo()["configDir"])) + u, _ := config.LoadUser(c) if u != nil { if config.IsTor(c) { log.Info(c, "Getting posts via hidden service...") @@ -205,7 +207,11 @@ func CmdListPosts(c *cli.Context) error { } if len(remotePosts) > 0 { - fmt.Println("Anonymous Posts") + if c.App.Name == "wf" { + fmt.Println("Draft Posts") + } else { + fmt.Println("Anonymous Posts") + } if details { identifier := "URL" if ids || !urls { @@ -261,9 +267,28 @@ func CmdListPosts(c *cli.Context) error { } func getPostURL(c *cli.Context, slug string) string { - base := config.WriteasBaseURL - if config.IsDev() { - base = config.DevBaseURL + var base string + if c.App.Name == "writeas" { + if config.IsDev() { + base = config.DevBaseURL + } else { + base = config.WriteasBaseURL + } + } else { + if host := api.HostURL(c); host != "" { + base = host + } else { + // TODO handle error, or load config globally, see T601 + // https://phabricator.write.as/T601 + cfg, _ := config.LoadConfig(config.UserDataDir(c.App.ExtraInfo()["configDir"])) + if cfg.Default.Host != "" && cfg.Default.User != "" { + if parts := strings.Split(cfg.Default.Host, "://"); len(parts) > 1 { + base = cfg.Default.Host + } else { + base = "https://" + cfg.Default.Host + } + } + } } ext := "" // Output URL in requested format @@ -274,12 +299,12 @@ func getPostURL(c *cli.Context, slug string) string { } func CmdCollections(c *cli.Context) error { - u, err := config.LoadUser(config.UserDataDir(c.App.ExtraInfo()["configDir"])) + u, err := config.LoadUser(c) if err != nil { return cli.NewExitError(fmt.Sprintf("couldn't load config: %v", err), 1) } if u == nil { - return cli.NewExitError("You must be authenticated to view collections.\nLog in first with: writeas auth ", 1) + return cli.NewExitError("You must be authenticated to view collections.\nLog in first with: "+executable.Name()+" auth ", 1) } if config.IsTor(c) { log.Info(c, "Getting blogs via hidden service...") @@ -309,12 +334,12 @@ func CmdCollections(c *cli.Context) error { } func CmdClaim(c *cli.Context) error { - u, err := config.LoadUser(config.UserDataDir(c.App.ExtraInfo()["configDir"])) + u, err := config.LoadUser(c) if err != nil { return cli.NewExitError(fmt.Sprintf("couldn't load config: %v", err), 1) } if u == nil { - return cli.NewExitError("You must be authenticated to claim local posts.\nLog in first with: writeas auth ", 1) + return cli.NewExitError("You must be authenticated to claim local posts.\nLog in first with: "+executable.Name()+" auth ", 1) } localPosts := api.GetPosts(c) @@ -348,7 +373,7 @@ func CmdClaim(c *cli.Context) error { log.Info(c, "%sOK", status) okCount++ // only delete local if successful - api.RemovePost(c.App.ExtraInfo()["configDir"], id) + api.RemovePost(c, id) } } log.Info(c, "%d claimed, %d failed", okCount, errCount) @@ -356,19 +381,31 @@ func CmdClaim(c *cli.Context) error { } func CmdAuth(c *cli.Context) error { + username := c.Args().Get(0) + if username == "" && c.GlobalIsSet("user") { + username = c.GlobalString("user") + } // Check configuration - u, err := config.LoadUser(config.UserDataDir(c.App.ExtraInfo()["configDir"])) + u, err := config.LoadUser(c) if err != nil { return cli.NewExitError(fmt.Sprintf("couldn't load config: %v", err), 1) } - if u != nil && u.AccessToken != "" { - return cli.NewExitError("You're already authenticated as "+u.User.Username+". Log out with: writeas logout", 1) + if u != nil && u.AccessToken != "" && username == u.User.Username { + return cli.NewExitError("You're already authenticated as "+u.User.Username, 1) } // Validate arguments and get password - username := c.Args().Get(0) if username == "" { - return cli.NewExitError("usage: writeas auth ", 1) + cfg, err := config.LoadConfig(config.UserDataDir(c.App.ExtraInfo()["configDir"])) + if err != nil { + return cli.NewExitError(fmt.Sprintf("Failed to load config: %v", err), 1) + } + if cfg.Default.Host != "" && cfg.Default.User != "" { + username = cfg.Default.User + fmt.Printf("No user provided, using default user %s for host %s...\n", cfg.Default.User, cfg.Default.Host) + } else { + return cli.NewExitError("usage: "+executable.Name()+" auth ", 1) + } } fmt.Print("Password: ") diff --git a/config/config.go b/config/config.go index 4c9c59d..6ded22b 100644 --- a/config/config.go +++ b/config/config.go @@ -8,32 +8,43 @@ import ( ) const ( - UserConfigFile = "config.ini" + // ConfigFile is the full filename for application configuration files + ConfigFile = "config.ini" ) type ( + // APIConfig is not currently used APIConfig struct { } + // PostsConfig stores the directory for the user post cache PostsConfig struct { Directory string `ini:"directory"` } - UserConfig struct { - API APIConfig `ini:"api"` - Posts PostsConfig `ini:"posts"` + // DefaultConfig stores the default host and user to authenticate with + DefaultConfig struct { + Host string `ini:"host"` + User string `ini:"user"` + } + + // Config represents the entire base configuration + Config struct { + API APIConfig `ini:"api"` + Default DefaultConfig `ini:"default"` + Posts PostsConfig `ini:"posts"` } ) -func LoadConfig(dataDir string) (*UserConfig, error) { +func LoadConfig(dataDir string) (*Config, error) { // TODO: load config to var shared across app - cfg, err := ini.LooseLoad(filepath.Join(dataDir, UserConfigFile)) + cfg, err := ini.LooseLoad(filepath.Join(dataDir, ConfigFile)) if err != nil { return nil, err } // Parse INI file - uc := &UserConfig{} + uc := &Config{} err = cfg.MapTo(uc) if err != nil { return nil, err @@ -41,14 +52,14 @@ func LoadConfig(dataDir string) (*UserConfig, error) { return uc, nil } -func SaveConfig(dataDir string, uc *UserConfig) error { +func SaveConfig(dataDir string, uc *Config) error { cfg := ini.Empty() err := ini.ReflectFrom(cfg, uc) if err != nil { return err } - return cfg.SaveTo(filepath.Join(dataDir, UserConfigFile)) + return cfg.SaveTo(filepath.Join(dataDir, ConfigFile)) } var editors = []string{"WRITEAS_EDITOR", "EDITOR"} diff --git a/config/directories.go b/config/directories.go index 8373835..e2a68f3 100644 --- a/config/directories.go +++ b/config/directories.go @@ -5,16 +5,36 @@ import ( "path/filepath" "github.com/writeas/writeas-cli/fileutils" + "github.com/writeas/writeas-cli/log" ) +// UserDataDir returns a platform specific directory under the user's home +// directory func UserDataDir(dataDirName string) string { return filepath.Join(parentDataDir(), dataDirName) } -func DataDirExists(dataDirName string) bool { - return fileutils.Exists(UserDataDir(dataDirName)) +func dataDirExists(dataDirName string) bool { + return fileutils.Exists(dataDirName) } -func CreateDataDir(dataDirName string) error { - return os.Mkdir(UserDataDir(dataDirName), 0700) +func createDataDir(dataDirName string) error { + return os.Mkdir(dataDirName, 0700) +} + +// DirMustExist checks for a directory, creates it if not found and either +// panics or logs and error depending on the status of Debug +func DirMustExist(dataDirName string) { + // Ensure we have a data directory to use + if !dataDirExists(dataDirName) { + err := createDataDir(dataDirName) + if err != nil { + if Debug() { + panic(err) + } else { + log.Errorln("Error creating data directory: %s", err) + return + } + } + } } diff --git a/config/files_nix.go b/config/files_nix.go index 0c10f04..13fb338 100644 --- a/config/files_nix.go +++ b/config/files_nix.go @@ -7,6 +7,7 @@ import ( "os/exec" homedir "github.com/mitchellh/go-homedir" + "github.com/writeas/writeas-cli/executable" ) const ( @@ -39,5 +40,5 @@ func EditPostCmd(fname string) *exec.Cmd { } func MessageRetryCompose(fname string) string { - return fmt.Sprintf("To retry this post, run:\n cat %s | writeas", fname) + return fmt.Sprintf("To retry this post, run:\n cat %s | %s", fname, executable.Name()) } diff --git a/config/files_win.go b/config/files_win.go index 026b803..db2f459 100644 --- a/config/files_win.go +++ b/config/files_win.go @@ -6,6 +6,8 @@ import ( "fmt" "os" "os/exec" + + "github.com/writeas/writeas-cli/executable" ) const ( @@ -22,5 +24,5 @@ func EditPostCmd(fname string) *exec.Cmd { } func MessageRetryCompose(fname string) string { - return fmt.Sprintf("To retry this post, run:\n type %s | writeas.exe", fname) + return fmt.Sprintf("To retry this post, run:\n type %s | %s", fname, executable.Name()) } diff --git a/config/flags.go b/config/flags.go index 2c17e23..195eb8d 100644 --- a/config/flags.go +++ b/config/flags.go @@ -12,6 +12,10 @@ var PostFlags = []cli.Flag{ Value: "", }, cli.BoolFlag{ + Name: "insecure", + Usage: "Send request insecurely.", + }, + cli.BoolFlag{ Name: "tor, t", Usage: "Perform action on Tor hidden service", }, diff --git a/config/options.go b/config/options.go index c3bffb5..9a163a6 100644 --- a/config/options.go +++ b/config/options.go @@ -1,6 +1,8 @@ package config import ( + "strings" + "github.com/cloudfoundry/jibber_jabber" "github.com/writeas/writeas-cli/log" cli "gopkg.in/urfave/cli.v1" @@ -8,8 +10,8 @@ import ( // Application constants. const ( - Version = "2.0" - defaultUserAgent = "writeas-cli v" + Version + writeasUserAgent = "writeas-cli v" + wfUserAgent = "wf-cli v" // Defaults for posts on Write.as. DefaultFont = PostFontMono WriteasBaseURL = "https://write.as" @@ -19,11 +21,16 @@ const ( ) func UserAgent(c *cli.Context) string { + client := wfUserAgent + if c.App.Name == "writeas" { + client = writeasUserAgent + } + ua := c.String("user-agent") if ua == "" { - return defaultUserAgent + return client + c.App.ExtraInfo()["version"] } - return ua + " (" + defaultUserAgent + ")" + return ua + " (" + client + c.App.ExtraInfo()["version"] + ")" } func IsTor(c *cli.Context) bool { @@ -37,6 +44,18 @@ func TorPort(c *cli.Context) int { return torPort } +func TorURL(c *cli.Context) string { + flagHost := c.String("host") + if flagHost != "" && strings.HasSuffix(flagHost, "onion") { + return flagHost + } + cfg, _ := LoadConfig(c.App.ExtraInfo()["configDir"]) + if cfg != nil && cfg.Default.Host != "" && strings.HasSuffix(cfg.Default.Host, "onion") { + return cfg.Default.Host + } + return TorBaseURL +} + func Language(c *cli.Context, auto bool) string { if l := c.String("lang"); l != "" { return l @@ -62,3 +81,28 @@ func Collection(c *cli.Context) string { } return "" } + +// HostDirectory returns the sub directory string for the host. Order of +// precedence is a host flag if any, then the configured default, if any +func HostDirectory(c *cli.Context) (string, error) { + cfg, err := LoadConfig(UserDataDir(c.App.ExtraInfo()["configDir"])) + if err != nil { + return "", err + } + // flag takes precedence over defaults + if hostFlag := c.GlobalString("host"); hostFlag != "" { + if parts := strings.Split(hostFlag, "://"); len(parts) > 1 { + return parts[1], nil + } + return hostFlag, nil + } + + if cfg.Default.Host != "" && cfg.Default.User != "" { + if parts := strings.Split(cfg.Default.Host, "://"); len(parts) > 1 { + return parts[1], nil + } + return cfg.Default.Host, nil + } + + return "", nil +} diff --git a/config/user.go b/config/user.go index 21dd8f9..1a90c55 100644 --- a/config/user.go +++ b/config/user.go @@ -7,12 +7,23 @@ import ( writeas "github.com/writeas/go-writeas/v2" "github.com/writeas/writeas-cli/fileutils" + "gopkg.in/urfave/cli.v1" ) -const UserFile = "user.json" - -func LoadUser(dataDir string) (*writeas.AuthUser, error) { - fname := filepath.Join(dataDir, UserFile) +func LoadUser(c *cli.Context) (*writeas.AuthUser, error) { + dir, err := UserHostDir(c) + if err != nil { + return nil, err + } + DirMustExist(dir) + username, err := CurrentUser(c) + if err != nil { + return nil, err + } + if username == "user" { + username = "" + } + fname := filepath.Join(dir, username, "user.json") userJSON, err := ioutil.ReadFile(fname) if err != nil { if !fileutils.Exists(fname) { @@ -31,17 +42,130 @@ func LoadUser(dataDir string) (*writeas.AuthUser, error) { return u, nil } -func SaveUser(dataDir string, u *writeas.AuthUser) error { +func DeleteUser(c *cli.Context) error { + dir, err := UserHostDir(c) + if err != nil { + return err + } + + username, err := CurrentUser(c) + if err != nil { + return err + } + + if username == "user" { + username = "" + } + + // Delete user data + err = fileutils.DeleteFile(filepath.Join(dir, username, "user.json")) + if err != nil { + return err + } + + // Do additional cleanup in wf-cli + if c.App.Name == "wf" { + // Delete user dir if it's empty + userEmpty, err := fileutils.IsEmpty(filepath.Join(dir, username)) + if err != nil { + return err + } + if !userEmpty { + return nil + } + err = fileutils.DeleteFile(filepath.Join(dir, username)) + if err != nil { + return err + } + + // Delete host dir if it's empty + hostEmpty, err := fileutils.IsEmpty(dir) + if err != nil { + return err + } + if !hostEmpty { + return nil + } + err = fileutils.DeleteFile(dir) + if err != nil { + return err + } + } + + return nil +} + +func SaveUser(c *cli.Context, u *writeas.AuthUser) error { // Marshal struct into pretty-printed JSON userJSON, err := json.MarshalIndent(u, "", " ") if err != nil { return err } + dir, err := UserHostDir(c) + if err != nil { + return err + } // Save file - err = ioutil.WriteFile(filepath.Join(dataDir, UserFile), userJSON, 0600) + username, err := CurrentUser(c) + if err != nil { + return err + } + if username != "user" { + dir = filepath.Join(dir, u.User.Username) + } + DirMustExist(dir) + err = ioutil.WriteFile(filepath.Join(dir, "user.json"), userJSON, 0600) if err != nil { return err } return nil } + +// UserHostDir returns the path to the user data directory with the host based +// subpath if the host flag is set +func UserHostDir(c *cli.Context) (string, error) { + dataDir := UserDataDir(c.App.ExtraInfo()["configDir"]) + hostDir, err := HostDirectory(c) + if err != nil { + return "", err + } + return filepath.Join(dataDir, hostDir), nil +} + +// CurrentUser returns the username of the user taking action in the current +// cli.Context. +func CurrentUser(c *cli.Context) (string, error) { + if c.App.Name == "writeas" { + return "user", nil + } + // Use user flag value + if c.GlobalString("user") != "" { + return c.GlobalString("user"), nil + } + + // Load host-level config, if host flag is set + hostDir, err := UserHostDir(c) + if err != nil { + return "", err + } + cfg, err := LoadConfig(hostDir) + if err != nil { + return "", err + } + if cfg.Default.User == "" { + // Load app-level config + globalCFG, err := LoadConfig(UserDataDir(c.App.ExtraInfo()["configDir"])) + if err != nil { + return "", err + } + // only use global defaults when both are set and no host flag + if globalCFG.Default.User != "" && + globalCFG.Default.Host != "" && + !c.GlobalIsSet("host") { + cfg = globalCFG + } + } + + return cfg.Default.User, nil +} diff --git a/executable/executable.go b/executable/executable.go new file mode 100644 index 0000000..697f419 --- /dev/null +++ b/executable/executable.go @@ -0,0 +1,13 @@ +// Package executable holds utility functions that assist both CLI executables, +// writeas and wf. +package executable + +import ( + "os" + "path" +) + +func Name() string { + n := os.Args[0] + return path.Base(n) +} diff --git a/fileutils/fileutils.go b/fileutils/fileutils.go index 7e4c354..eda996b 100644 --- a/fileutils/fileutils.go +++ b/fileutils/fileutils.go @@ -3,6 +3,7 @@ package fileutils import ( "bufio" "fmt" + "io" "os" "strings" ) @@ -109,3 +110,18 @@ func FindLine(p, startsWith string) string { func DeleteFile(p string) error { return os.Remove(p) } + +// IsEmpty returns whether or not the given directory is empty +func IsEmpty(d string) (bool, error) { + f, err := os.Open(d) + if err != nil { + return false, err + } + defer f.Close() + + _, err = f.Readdirnames(1) + if err == io.EOF { + return true, nil + } + return false, err +} diff --git a/go.mod b/go.mod index 90d8f91..f03be5d 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ require ( github.com/atotto/clipboard v0.1.1 github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect + github.com/hashicorp/go-multierror v1.0.0 github.com/howeyc/gopass v0.0.0-20170109162249-bf9dde6d0d2c github.com/jtolds/gls v4.2.1+incompatible // indirect github.com/microcosm-cc/bluemonday v1.0.1 // indirect diff --git a/go.sum b/go.sum index 86825a8..449244e 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,10 @@ github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/howeyc/gopass v0.0.0-20170109162249-bf9dde6d0d2c h1:kQWxfPIHVLbgLzphqk3QUflDy9QdksZR4ygR807bpy0= github.com/howeyc/gopass v0.0.0-20170109162249-bf9dde6d0d2c/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= diff --git a/log/logging.go b/log/logging.go index a514a7c..42da556 100644 --- a/log/logging.go +++ b/log/logging.go @@ -10,7 +10,7 @@ import ( // Info logs general diagnostic messages, shown only when the -v or --verbose // flag is provided. func Info(c *cli.Context, s string, p ...interface{}) { - if c.Bool("v") || c.Bool("verbose") { + if c.Bool("v") || c.Bool("verbose") || c.GlobalBool("v") || c.GlobalBool("verbose") { fmt.Fprintf(os.Stderr, s+"\n", p...) } }