Sfoglia il codice sorgente

Merge pull request #335 from binwiederhier/done

WIP: ntfy publish --pid $PID ...
Philipp C. Heckel 3 anni fa
parent
commit
4e29216b5f
10 ha cambiato i file con 333 aggiunte e 89 eliminazioni
  1. 126 24
      cmd/publish.go
  2. 8 0
      cmd/publish_darwin.go
  3. 8 0
      cmd/publish_linux.go
  4. 67 0
      cmd/publish_test.go
  5. 10 0
      cmd/publish_windows.go
  6. 22 8
      docs/deprecations.md
  7. 1 0
      docs/releases.md
  8. 65 0
      docs/subscribe/cli.md
  9. 20 32
      util/util.go
  10. 6 25
      util/util_test.go

+ 126 - 24
cmd/publish.go

@@ -5,11 +5,14 @@ import (
 	"fmt"
 	"github.com/urfave/cli/v2"
 	"heckel.io/ntfy/client"
+	"heckel.io/ntfy/log"
 	"heckel.io/ntfy/util"
 	"io"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"strings"
+	"time"
 )
 
 func init() {
@@ -20,6 +23,7 @@ var flagsPublish = append(
 	flagsDefault,
 	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
 	&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
+	&cli.StringFlag{Name: "message", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MESSAGE"}, Usage: "message body"},
 	&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
 	&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
 	&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
@@ -30,6 +34,8 @@ var flagsPublish = append(
 	&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
 	&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
 	&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
+	&cli.IntFlag{Name: "wait-pid", Aliases: []string{"pid"}, EnvVars: []string{"NTFY_WAIT_PID"}, Usage: "wait until PID exits before publishing"},
+	&cli.BoolFlag{Name: "wait-cmd", Aliases: []string{"cmd", "done"}, EnvVars: []string{"NTFY_WAIT_CMD"}, Usage: "run command and wait until it finishes before publishing"},
 	&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
 	&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
 	&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
@@ -37,14 +43,16 @@ var flagsPublish = append(
 )
 
 var cmdPublish = &cli.Command{
-	Name:      "publish",
-	Aliases:   []string{"pub", "send", "trigger"},
-	Usage:     "Send message via a ntfy server",
-	UsageText: "ntfy publish [OPTIONS..] TOPIC [MESSAGE]\nNTFY_TOPIC=.. ntfy publish [OPTIONS..] -P [MESSAGE]",
-	Action:    execPublish,
-	Category:  categoryClient,
-	Flags:     flagsPublish,
-	Before:    initLogFunc,
+	Name:    "publish",
+	Aliases: []string{"pub", "send", "trigger"},
+	Usage:   "Send message via a ntfy server",
+	UsageText: `ntfy publish [OPTIONS..] TOPIC [MESSAGE...]
+ntfy publish [OPTIONS..] --wait-cmd COMMAND...
+NTFY_TOPIC=.. ntfy publish [OPTIONS..] -P [MESSAGE...]`,
+	Action:   execPublish,
+	Category: categoryClient,
+	Flags:    flagsPublish,
+	Before:   initLogFunc,
 	Description: `Publish a message to a ntfy server.
 
 Examples:
@@ -59,8 +67,10 @@ Examples:
   ntfy pub --attach="http://some.tld/file.zip" files      # Send ZIP archive from URL as attachment
   ntfy pub --file=flower.jpg flowers 'Nice!'              # Send image.jpg as attachment
   ntfy pub -u phil:mypass secret Psst                     # Publish with username/password
+  ntfy pub --wait-pid 1234 mytopic                        # Wait for process 1234 to exit before publishing
+  ntfy pub --wait-cmd mytopic rsync -av ./ /tmp/a         # Run command and publish after it completes
   NTFY_USER=phil:mypass ntfy pub secret Psst              # Use env variables to set username/password
-  NTFY_TOPIC=mytopic ntfy pub -P "some message""          # Use NTFY_TOPIC variable as topic 
+  NTFY_TOPIC=mytopic ntfy pub -P "some message"           # Use NTFY_TOPIC variable as topic 
   cat flower.jpg | ntfy pub --file=- flowers 'Nice!'      # Same as above, send image.jpg as attachment
   ntfy trigger mywebhook                                  # Sending without message, useful for webhooks
  
@@ -88,22 +98,11 @@ func execPublish(c *cli.Context) error {
 	user := c.String("user")
 	noCache := c.Bool("no-cache")
 	noFirebase := c.Bool("no-firebase")
-	envTopic := c.Bool("env-topic")
 	quiet := c.Bool("quiet")
-	var topic, message string
-	if envTopic {
-		topic = os.Getenv("NTFY_TOPIC")
-		if c.NArg() > 0 {
-			message = strings.Join(c.Args().Slice(), " ")
-		}
-	} else {
-		if c.NArg() < 1 {
-			return errors.New("must specify topic, type 'ntfy publish --help' for help")
-		}
-		topic = c.Args().Get(0)
-		if c.NArg() > 1 {
-			message = strings.Join(c.Args().Slice()[1:], " ")
-		}
+	pid := c.Int("wait-pid")
+	topic, message, command, err := parseTopicMessageCommand(c)
+	if err != nil {
+		return err
 	}
 	var options []client.PublishOption
 	if title != "" {
@@ -156,6 +155,21 @@ func execPublish(c *cli.Context) error {
 		}
 		options = append(options, client.WithBasicAuth(user, pass))
 	}
+	if pid > 0 {
+		newMessage, err := waitForProcess(pid)
+		if err != nil {
+			return err
+		} else if message == "" {
+			message = newMessage
+		}
+	} else if len(command) > 0 {
+		newMessage, err := runAndWaitForCommand(command)
+		if err != nil {
+			return err
+		} else if message == "" {
+			message = newMessage
+		}
+	}
 	var body io.Reader
 	if file == "" {
 		body = strings.NewReader(message)
@@ -188,3 +202,91 @@ func execPublish(c *cli.Context) error {
 	}
 	return nil
 }
+
+// parseTopicMessageCommand reads the topic and the remaining arguments from the context.
+
+// There are a few cases to consider:
+//   ntfy publish <topic> [<message>]
+//   ntfy publish --wait-cmd <topic> <command>
+//   NTFY_TOPIC=.. ntfy publish [<message>]
+//   NTFY_TOPIC=.. ntfy publish --wait-cmd <command>
+func parseTopicMessageCommand(c *cli.Context) (topic string, message string, command []string, err error) {
+	var args []string
+	topic, args, err = parseTopicAndArgs(c)
+	if err != nil {
+		return
+	}
+	if c.Bool("wait-cmd") {
+		if len(args) == 0 {
+			err = errors.New("must specify command when --wait-cmd is passed, type 'ntfy publish --help' for help")
+			return
+		}
+		command = args
+	} else {
+		message = strings.Join(args, " ")
+	}
+	if c.String("message") != "" {
+		message = c.String("message")
+	}
+	return
+}
+
+func parseTopicAndArgs(c *cli.Context) (topic string, args []string, err error) {
+	envTopic := c.Bool("env-topic")
+	if envTopic {
+		fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mDeprecation notice: The --env-topic/-P flag will be removed in July 2022, see https://ntfy.sh/docs/deprecations/ for details.\x1b[0m")
+		topic = os.Getenv("NTFY_TOPIC")
+		if topic == "" {
+			return "", nil, errors.New("when --env-topic is passed, must define NTFY_TOPIC environment variable")
+		}
+		return topic, remainingArgs(c, 0), nil
+	}
+	if c.NArg() < 1 {
+		return "", nil, errors.New("must specify topic, type 'ntfy publish --help' for help")
+	}
+	return c.Args().Get(0), remainingArgs(c, 1), nil
+}
+
+func remainingArgs(c *cli.Context, fromIndex int) []string {
+	if c.NArg() > fromIndex {
+		return c.Args().Slice()[fromIndex:]
+	}
+	return []string{}
+}
+
+func waitForProcess(pid int) (message string, err error) {
+	if !processExists(pid) {
+		return "", fmt.Errorf("process with PID %d not running", pid)
+	}
+	start := time.Now()
+	log.Debug("Waiting for process with PID %d to exit", pid)
+	for processExists(pid) {
+		time.Sleep(500 * time.Millisecond)
+	}
+	runtime := time.Since(start).Round(time.Millisecond)
+	log.Debug("Process with PID %d exited after %s", pid, runtime)
+	return fmt.Sprintf("Process with PID %d exited after %s", pid, runtime), nil
+}
+
+func runAndWaitForCommand(command []string) (message string, err error) {
+	prettyCmd := util.QuoteCommand(command)
+	log.Debug("Running command: %s", prettyCmd)
+	start := time.Now()
+	cmd := exec.Command(command[0], command[1:]...)
+	if log.IsTrace() {
+		cmd.Stdout = os.Stdout
+		cmd.Stderr = os.Stderr
+	}
+	err = cmd.Run()
+	runtime := time.Since(start).Round(time.Millisecond)
+	if err != nil {
+		if exitError, ok := err.(*exec.ExitError); ok {
+			log.Debug("Command failed after %s (exit code %d): %s", runtime, exitError.ExitCode(), prettyCmd)
+			return fmt.Sprintf("Command failed after %s (exit code %d): %s", runtime, exitError.ExitCode(), prettyCmd), nil
+		}
+		// Hard fail when command does not exist or could not be properly launched
+		return "", fmt.Errorf("command failed: %s, error: %s", prettyCmd, err.Error())
+	}
+	log.Debug("Command succeeded after %s: %s", runtime, prettyCmd)
+	return fmt.Sprintf("Command succeeded after %s: %s", runtime, prettyCmd), nil
+}

+ 8 - 0
cmd/publish_darwin.go

@@ -0,0 +1,8 @@
+package cmd
+
+import "syscall"
+
+func processExists(pid int) bool {
+	err := syscall.Kill(pid, syscall.Signal(0))
+	return err == nil
+}

+ 8 - 0
cmd/publish_linux.go

@@ -0,0 +1,8 @@
+package cmd
+
+import "syscall"
+
+func processExists(pid int) bool {
+	err := syscall.Kill(pid, syscall.Signal(0))
+	return err == nil
+}

+ 67 - 0
cmd/publish_test.go

@@ -5,7 +5,11 @@ import (
 	"github.com/stretchr/testify/require"
 	"heckel.io/ntfy/test"
 	"heckel.io/ntfy/util"
+	"os"
+	"os/exec"
+	"strconv"
 	"testing"
+	"time"
 )
 
 func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
@@ -70,3 +74,66 @@ func TestCLI_Publish_All_The_Things(t *testing.T) {
 	require.Equal(t, int64(0), m.Attachment.Expires)
 	require.Equal(t, "", m.Attachment.Type)
 }
+
+func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
+	s, port := test.StartServer(t)
+	defer test.StopServer(t, s, port)
+	topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port)
+
+	// Test: sleep 0.5
+	sleep := exec.Command("sleep", "0.5")
+	require.Nil(t, sleep.Start())
+	go sleep.Wait() // Must be called to release resources
+	start := time.Now()
+	app, _, stdout, _ := newTestApp()
+	require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-pid", strconv.Itoa(sleep.Process.Pid), topic}))
+	m := toMessage(t, stdout.String())
+	require.True(t, time.Since(start) >= 500*time.Millisecond)
+	require.Regexp(t, `Process with PID \d+ exited after `, m.Message)
+
+	// Test: PID does not exist
+	app, _, _, _ = newTestApp()
+	err := app.Run([]string{"ntfy", "publish", "--wait-pid", "1234567", topic})
+	require.Error(t, err)
+	require.Equal(t, "process with PID 1234567 not running", err.Error())
+
+	// Test: Successful command (exit 0)
+	start = time.Now()
+	app, _, stdout, _ = newTestApp()
+	require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "sleep", "0.5"}))
+	m = toMessage(t, stdout.String())
+	require.True(t, time.Since(start) >= 500*time.Millisecond)
+	require.Contains(t, m.Message, `Command succeeded after `)
+	require.Contains(t, m.Message, `: sleep 0.5`)
+
+	// Test: Failing command (exit 1)
+	app, _, stdout, _ = newTestApp()
+	require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "/bin/false", "false doesn't care about its args"}))
+	m = toMessage(t, stdout.String())
+	require.Contains(t, m.Message, `Command failed after `)
+	require.Contains(t, m.Message, `(exit code 1): /bin/false "false doesn't care about its args"`, m.Message)
+
+	// Test: Non-existing command (hard fail!)
+	app, _, _, _ = newTestApp()
+	err = app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "does-not-exist-no-really", "really though"})
+	require.Error(t, err)
+	require.Equal(t, `command failed: does-not-exist-no-really "really though", error: exec: "does-not-exist-no-really": executable file not found in $PATH`, err.Error())
+
+	// Tests with NTFY_TOPIC set ////
+	require.Nil(t, os.Setenv("NTFY_TOPIC", topic))
+
+	// Test: Successful command with NTFY_TOPIC
+	app, _, stdout, _ = newTestApp()
+	require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--cmd", "echo", "hi there"}))
+	m = toMessage(t, stdout.String())
+	require.Equal(t, "mytopic", m.Topic)
+
+	// Test: Successful --wait-pid with NTFY_TOPIC
+	sleep = exec.Command("sleep", "0.2")
+	require.Nil(t, sleep.Start())
+	go sleep.Wait() // Must be called to release resources
+	app, _, stdout, _ = newTestApp()
+	require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--wait-pid", strconv.Itoa(sleep.Process.Pid)}))
+	m = toMessage(t, stdout.String())
+	require.Regexp(t, `Process with PID \d+ exited after .+ms`, m.Message)
+}

+ 10 - 0
cmd/publish_windows.go

@@ -0,0 +1,10 @@
+package cmd
+
+import (
+	"os"
+)
+
+func processExists(pid int) bool {
+	_, err := os.FindProcess(pid)
+	return err == nil
+}

+ 22 - 8
docs/deprecations.md

@@ -1,21 +1,35 @@
 # Deprecation notices
 This page is used to list deprecation notices for ntfy. Deprecated commands and options will be 
-**removed after ~3 months** from the time they were deprecated.
+**removed after 1-3 months** from the time they were deprecated. How long the feature is deprecated
+before the behavior is changed depends on the severity of the change, and how prominent the feature is.
 
 ## Active deprecations
 
-### Android app: WebSockets will become the default connection protocol  
-> Active since 2022-03-13, behavior will change in **June 2022**
+### ntfy CLI: `ntfy publish --env-topic` will be removed
+> Active since 2022-06-20, behavior will change end of **July 2022**
 
-In future versions of the Android app, instant delivery connections and connections to self-hosted servers will
-be using the WebSockets protocol. This potentially requires [configuration changes in your proxy](https://ntfy.sh/docs/config/#nginxapache2caddy).
+The `ntfy publish --env-topic` option will be removed. It'll still be possible to specify a topic via the 
+`NTFY_TOPIC` environment variable, but it won't be necessary anymore to specify the `--env-topic` flag.
 
-Due to [reports of varying battery consumption](https://github.com/binwiederhier/ntfy/issues/190) (which entirely 
-seems to depend on the phone), JSON HTTP stream support will not be removed. Instead, I'll just flip the default to 
-WebSocket in June.
+=== "Before"
+    ```
+    $ NTFY_TOPIC=mytopic ntfy publish --env-topic "this is the message"
+    ```
+
+=== "After"
+    ```
+    $ NTFY_TOPIC=mytopic ntfy publish "this is the message"
+    ```
 
 ## Previous deprecations
 
+### <del>Android app: WebSockets will become the default connection protocol</del>
+> Active since 2022-03-13, behavior will not change (deprecation removed 2022-06-20)
+
+Instant delivery connections and connections to self-hosted servers in the Android app were going to switch
+to use the WebSockets protocol by default. It was decided to keep JSON stream as the most compatible default
+and add a notice banner in the Android app instead.
+
 ### Android app: Using `since=<timestamp>` instead of `since=<id>`
 > Active since 2022-02-27, behavior changed with v1.14.0
 

+ 1 - 0
docs/releases.md

@@ -8,6 +8,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
 
 **Features:**
 
+* ntfy CLI can now [wait for a command or PID](https://ntfy.sh/docs/subscribe/cli/#wait-for-pidcommand) before publishing ([#263](https://github.com/binwiederhier/ntfy/issues/263), thanks to the [original ntfy](https://github.com/dschep/ntfy) for the idea) 
 * Trace: Log entire HTTP request to simplify debugging (no ticket)
 * Allow setting user password via `NTFY_PASSWORD` env variable ([#327](https://github.com/binwiederhier/ntfy/pull/327), thanks to [@Kenix3](https://github.com/Kenix3))
 

+ 65 - 0
docs/subscribe/cli.md

@@ -56,6 +56,71 @@ quick ones:
     ntfy pub mywebhook
     ```
 
+### Attaching a local file
+You can easily upload and attach a local file to a notification:
+
+```
+$ ntfy pub --file README.md mytopic | jq .
+{
+  "id": "meIlClVLABJQ",
+  "time": 1655825460,
+  "event": "message",
+  "topic": "mytopic",
+  "message": "You received a file: README.md",
+  "attachment": {
+    "name": "README.md",
+    "type": "text/plain; charset=utf-8",
+    "size": 2892,
+    "expires": 1655836260,
+    "url": "https://ntfy.sh/file/meIlClVLABJQ.txt"
+  }
+}
+```
+
+### Wait for PID/command
+If you have a long-running command, you may wrap it directly with `ntfy publish --wait-cmd`,
+or if you forgot to wrap it and it's already running, wait for the process to complete with
+`ntfy publish --wait-pid`.
+
+Run a command and wait for it to complete (here: `rsync ...`):
+
+```
+$ ntfy pub --wait-cmd mytopic rsync -av ./ root@example.com:/backups/ | jq .
+{
+  "id": "Re0rWXZQM8WB",
+  "time": 1655825624,
+  "event": "message",
+  "topic": "mytopic",
+  "message": "Command succeeded after 56.553s: rsync -av ./ root@example.com:/backups/"
+}
+```
+
+Or, if you already started the long-running process and want to wait for it, you can do this:
+
+=== "Using a PID directly"
+    ```
+    $ ntfy pub --wait-pid 8458 mytopic | jq .
+    {
+      "id": "orM6hJKNYkWb",
+      "time": 1655825827,
+      "event": "message",
+      "topic": "mytopic",
+      "message": "Process with PID 8458 exited after 2.003s"
+    }
+    ```
+
+=== "Using a `pidof`"
+    ```
+    $ ntfy pub --wait-pid $(pidof rsync) mytopic | jq .
+    {
+      "id": "orM6hJKNYkWb",
+      "time": 1655825827,
+      "event": "message",
+      "topic": "mytopic",
+      "message": "Process with PID 8458 exited after 2.003s"
+    }
+    ```
+
 ## Subscribe to topics
 You can subscribe to topics using `ntfy subscribe`. Depending on how it is called, this command
 will either print or execute a command for every arriving message. There are a few different ways 

+ 20 - 32
util/util.go

@@ -26,6 +26,7 @@ var (
 	randomMutex        = sync.Mutex{}
 	sizeStrRegex       = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`)
 	errInvalidPriority = errors.New("invalid priority")
+	noQuotesRegex      = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`)
 )
 
 // FileExists checks if a file exists, and returns true if it does
@@ -120,38 +121,6 @@ func ValidRandomString(s string, length int) bool {
 	return true
 }
 
-// DurationToHuman converts a duration to a human-readable format
-func DurationToHuman(d time.Duration) (str string) {
-	if d == 0 {
-		return "0"
-	}
-
-	d = d.Round(time.Second)
-	days := d / time.Hour / 24
-	if days > 0 {
-		str += fmt.Sprintf("%dd", days)
-	}
-	d -= days * time.Hour * 24
-
-	hours := d / time.Hour
-	if hours > 0 {
-		str += fmt.Sprintf("%dh", hours)
-	}
-	d -= hours * time.Hour
-
-	minutes := d / time.Minute
-	if minutes > 0 {
-		str += fmt.Sprintf("%dm", minutes)
-	}
-	d -= minutes * time.Minute
-
-	seconds := d / time.Second
-	if seconds > 0 {
-		str += fmt.Sprintf("%ds", seconds)
-	}
-	return
-}
-
 // ParsePriority parses a priority string into its equivalent integer value
 func ParsePriority(priority string) (int, error) {
 	switch strings.TrimSpace(strings.ToLower(priority)) {
@@ -286,3 +255,22 @@ func MaybeMarshalJSON(v interface{}) string {
 	}
 	return string(jsonBytes)
 }
+
+// QuoteCommand combines a command array to a string, quoting arguments that need quoting.
+// This function is naive, and sometimes wrong. It is only meant for lo pretty-printing a command.
+//
+// Warning: Never use this function with the intent to run the resulting command.
+//
+// Example:
+//    []string{"ls", "-al", "Document Folder"} -> ls -al "Document Folder"
+func QuoteCommand(command []string) string {
+	var quoted []string
+	for _, c := range command {
+		if noQuotesRegex.MatchString(c) {
+			quoted = append(quoted, c)
+		} else {
+			quoted = append(quoted, fmt.Sprintf(`"%s"`, c))
+		}
+	}
+	return strings.Join(quoted, " ")
+}

+ 6 - 25
util/util_test.go

@@ -5,33 +5,8 @@ import (
 	"io/ioutil"
 	"path/filepath"
 	"testing"
-	"time"
 )
 
-func TestDurationToHuman_SevenDays(t *testing.T) {
-	d := 7 * 24 * time.Hour
-	require.Equal(t, "7d", DurationToHuman(d))
-}
-
-func TestDurationToHuman_MoreThanOneDay(t *testing.T) {
-	d := 49 * time.Hour
-	require.Equal(t, "2d1h", DurationToHuman(d))
-}
-
-func TestDurationToHuman_LessThanOneDay(t *testing.T) {
-	d := 17*time.Hour + 15*time.Minute
-	require.Equal(t, "17h15m", DurationToHuman(d))
-}
-
-func TestDurationToHuman_TenOfThings(t *testing.T) {
-	d := 10*time.Hour + 10*time.Minute + 10*time.Second
-	require.Equal(t, "10h10m10s", DurationToHuman(d))
-}
-
-func TestDurationToHuman_Zero(t *testing.T) {
-	require.Equal(t, "0", DurationToHuman(0))
-}
-
 func TestRandomString(t *testing.T) {
 	s1 := RandomString(10)
 	s2 := RandomString(10)
@@ -162,3 +137,9 @@ func TestLastString(t *testing.T) {
 	require.Equal(t, "last", LastString([]string{"first", "second", "last"}, "default"))
 	require.Equal(t, "default", LastString([]string{}, "default"))
 }
+
+func TestQuoteCommand(t *testing.T) {
+	require.Equal(t, `ls -al "Document Folder"`, QuoteCommand([]string{"ls", "-al", "Document Folder"}))
+	require.Equal(t, `rsync -av /home/phil/ root@example.com:/home/phil/`, QuoteCommand([]string{"rsync", "-av", "/home/phil/", "root@example.com:/home/phil/"}))
+	require.Equal(t, `/home/sweet/home "Äöü this is a test" "\a\b"`, QuoteCommand([]string{"/home/sweet/home", "Äöü this is a test", "\\a\\b"}))
+}