From 40a82f26c439048c68838374f9b9511a3779e613 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 13 Jul 2019 20:50:00 -0600 Subject: [PATCH] better arg handling, more descriptive output --- README.md | 185 +++++++++++++----- manager/install.go | 12 +- manager/install_darwin.go | 55 +++--- manager/install_linux.go | 53 +++--- manager/install_other.go | 4 + manager/install_windows.go | 27 +-- manager/start.go | 18 ++ service/service.go | 2 +- serviceman.go | 379 ++++++++++++++++++++++++++++++------- serviceman_darwin.go | 2 +- serviceman_linux.go | 2 +- serviceman_windows.go | 2 +- 12 files changed, 556 insertions(+), 185 deletions(-) diff --git a/README.md b/README.md index 9f222a3..f589378 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ Because debugging launchctl, systemd, etc absolutely sucks! ## Features -- Unprivileged (User Mode) Services +- Unprivileged (User Mode) Services with `--user` (_Default_) - [x] Linux (`sytemctl --user`) - [x] MacOS (`launchctl`) - [x] Windows (`HKEY_CURRENT_USER/.../Run`) -- Privileged (System) Services +- Privileged (System) Services with `--system` (_Default_ for `root`) - [x] Linux (`sudo sytemctl`) - [x] MacOS (`sudo launchctl`) - [ ] Windows (_not yet implemented_) @@ -40,26 +40,17 @@ Because debugging launchctl, systemd, etc absolutely sucks! The basic pattern of usage: -``` -serviceman add [options] [interpreter] -- [service options] -serviceman start -serviceman stop +```bash +sudo serviceman add --name "foobar" [options] [interpreter] [--] [service options] +sudo serviceman start +sudo serviceman stop serviceman version ``` And what that might look like: -``` -# Here the service is named "foo" implicitly -# '--bar /baz' will be used for arguments to foo.exe in the service file -serviceman add foo.exe -- --bar /baz -``` - -``` -# Here the service is named "foo-app" explicitly -# 'node' will be found in the path -# './index.js' will be resolved to a full path -serviceman add --name "foo-app" node ./index.js +```bash +sudo serviceman add --name "foo" foo.exe -c ./config.json ``` You can also view the help: @@ -68,6 +59,14 @@ You can also view the help: serviceman add --help ``` +# System Services VS User Mode Services + +User services start **on login**. + +System services start **on boot**. + +The **default** is to register a _user_ services. To register a _system_ service, use `sudo` or run as `root`. + # Install There are a number of pre-built binaries. @@ -171,8 +170,8 @@ curl https://rootprojects.org/serviceman/dist/linux/armv5/serviceman -o servicem ``` mkdir %userprofile%\bin -reg add HKEY_CURRENT_USER\Environment /v PATH /d "%PATH%;%userprofile%\bin" move serviceman.exe %userprofile%\bin\serviceman.exe +reg add HKEY_CURRENT_USER\Environment /v PATH /d "%PATH%;%userprofile%\bin" ``` **All Others** @@ -184,43 +183,100 @@ sudo mv ./serviceman /usr/local/bin/ # Examples -> **serviceman add** <program> **--** <program options> +```bash +sudo serviceman add --name [options] [--] [raw options] + +# Example +sudo serviceman add --name "gizmo" gizmo --foo bar/baz +``` + +Anything that looks like file or directory will be **resolved to its absolute path**: + +```bash +# Example of path resolution +gizmo --foo /User/me/gizmo/bar/baz +``` + +Use `--` to prevent this behavior: + +```bash +# Complex Example +sudo serviceman add --name "gizmo" gizmo -c ./config.ini -- --separator . +``` + +For native **Windows** programs that use `/` for flags, you'll need to resolve some paths yourself: + +```bash +# Windows Example +serviceman add --name "gizmo" gizmo.exe .\input.txt -- /c \User\me\gizmo\config.ini /q /s . +``` + +In this case `./config.ini` would still be resolved (before `--`), but `.` would not (after `--`)
Compiled Programs Normally you might your program somewhat like this: -``` -dinglehopper --port 8421 +```bash +gizmo run --port 8421 --config envs/prod.ini ``` Adding a service for that program with `serviceman` would look like this: -> **serviceman add** dinglehopper **--** --port 8421 +```bash +sudo serviceman add --name "gizmo" gizmo run --port 8421 --config envs/prod.ini +``` -serviceman will find dinglehopper in your PATH. +serviceman will find `gizmo` in your PATH and resolve `envs/prod.ini` to its absolute path.
Using with scripts +```bash +./snarfblat.sh --port 8421 +``` + Although your text script may be executable, you'll need to specify the interpreter in order for `serviceman` to configure the service correctly. -For example, if you had a bash script that you normally ran like this: +This can be done in two ways: + +1. Put a **hashbang** in your script, such as `#!/bin/bash`. +2. Prepend the **interpreter** explicitly to your command, such as `bash ./dinglehopper.sh`. +For example, suppose you had a script like this: + +`iamok.sh`: + +```bash +while true; do + sleep 1; echo "Still Alive, Still Alive!" +done ``` -./snarfblat.sh --port 8421 + +Normally you would run the script like this: + +```bash +./imok.sh ``` -You'd create a system service for it like this: +So you'd either need to modify the script to include a hashbang: -> serviceman add **bash** ./snarfblat.sh **--** --port 8421 +```bash +#!/usr/bin/env bash +while true; do + sleep 1; echo "I'm Ok!" +done +``` -`serviceman` will resolve `./snarfblat.sh` correctly because it comes -before the **--**. +Or you'd need to prepend it with `bash` when creating a service for it: + +```bash +sudo serviceman add --name "imok" bash ./imok.sh +``` **Background Information** @@ -244,6 +300,8 @@ like this: #!/usr/local/bin/node --harmony --inspect ``` +Serviceman understands all 3 of those approaches. +
@@ -252,14 +310,37 @@ like this: If normally you run your node script something like this: ```bash -node ./demo.js --foo bar --baz +pushd ~/my-node-project/ +npm start +``` + +Then you would add it as a system service like this: + +```bash +sudo serviceman add npm start +``` + +If normally you run your node script something like this: + +```bash +pushd ~/my-node-project/ +node ./serve.js --foo bar --baz ``` Then you would add it as a system service like this: -> **serviceman add** node ./demo.js **--** --foo bar --baz +```bash +sudo serviceman add node ./serve.js --foo bar --baz +``` + +It's important that any paths start with `./` and have the `.js` +so that serviceman knows to resolve the full path. -It is important that you specify `node ./demo.js` and not just `./demo.js` +```bash +# Bad Examples +sudo serviceman add node ./demo # Wouldn't work for 'demo.js' - not a real filename +sudo serviceman add node demo # Wouldn't work for './demo/' - doesn't look like a directory +``` See **Using with scripts** for more detailed information. @@ -271,14 +352,15 @@ See **Using with scripts** for more detailed information. If normally you run your python script something like this: ```bash -python ./demo.py --foo bar --baz +pushd ~/my-python-project/ +python ./serve.py --config ./config.ini ``` Then you would add it as a system service like this: -> **serviceman add** python ./demo.py **--** --foo bar --baz - -It is important that you specify `python ./demo.py` and not just `./demo.py` +```bash +sudo serviceman add python ./serve.py --config ./config.ini +``` See **Using with scripts** for more detailed information. @@ -290,31 +372,32 @@ See **Using with scripts** for more detailed information. If normally you run your ruby script something like this: ```bash -ruby ./demo.rb --foo bar --baz +pushd ~/my-ruby-project/ +ruby ./serve.rb --config ./config.yaml ``` Then you would add it as a system service like this: -> **serviceman add** ruby ./demo.rb **--** --foo bar --baz - -It is important that you specify `ruby ./demo.rb` and not just `./demo.rb` +```bash +sudo serviceman add ruby ./serve.rb --config ./config.yaml +``` See **Using with scripts** for more detailed information.
-## Relative vs Absolute Paths +## Hints -Although serviceman can expand the executable's path, -if you have any arguments with relative paths -you should switch to using absolute paths. +- If something goes wrong, read the output **completely** - it'll probably be helpful +- Run `serviceman` from your **project directory**, just as you would run it normally + - Otherwise specify `--name ` and `--workdir ` +- Use `--` in front of arguments that should not be resolved as paths + - This also holds true if you need `--` as an argument, such as `-- --foo -- --bar` ``` -dinglehopper --config ./conf.json -``` - -``` -serviceman add dinglehopper -- --config /Users/me/dinglehopper/conf.json +# Example of a / that isn't a path +# (it needs to be escaped with --) +sudo serviceman add dinglehopper config/prod -- --category color/blue ``` # Logging @@ -323,6 +406,7 @@ serviceman add dinglehopper -- --config /Users/me/dinglehopper/conf.json ```bash sudo journalctl -xef --unit +sudo journalctl -xef --user-unit ``` ### Mac, Windows @@ -354,6 +438,9 @@ why your app failed to start. # Debugging +- `serviceman add --dryrun ` +- `serviceman run --config ` + One of the most irritating problems with all of these launchers is that they're terrible to debug - it's often difficult to find the logs, and nearly impossible to interpret them, if they exist at all. diff --git a/manager/install.go b/manager/install.go index 1dcd504..e2ad6b2 100644 --- a/manager/install.go +++ b/manager/install.go @@ -14,7 +14,7 @@ import ( // Install will do a best-effort attempt to install a start-on-startup // user or system service via systemd, launchd, or reg.exe -func Install(c *service.Service) error { +func Install(c *service.Service) (string, error) { if "" == c.Exec { c.Exec = c.Name } @@ -24,23 +24,23 @@ func Install(c *service.Service) error { if nil != err { fmt.Fprintf(os.Stderr, "Unrecoverable Error: %s", err) os.Exit(4) - return err + return "", err } else { c.Home = home } } - err := install(c) + name, err := install(c) if nil != err { - return err + return "", err } err = os.MkdirAll(c.Logdir, 0755) if nil != err { - return err + return "", err } - return nil + return name, nil } func Start(conf *service.Service) error { diff --git a/manager/install_darwin.go b/manager/install_darwin.go index efde7be..09d3f6a 100644 --- a/manager/install_darwin.go +++ b/manager/install_darwin.go @@ -50,12 +50,11 @@ func start(conf *service.Service) error { cmds = adjustPrivs(system, cmds) - fmt.Println() typ := "USER" if system { typ = "SYSTEM" } - fmt.Printf("Starting launchd %s service...\n", typ) + fmt.Printf("Starting launchd %s service...\n\n", typ) for i := range cmds { exe := cmds[i] fmt.Println("\t" + exe.String()) @@ -109,11 +108,32 @@ func stop(conf *service.Service) error { return nil } -func install(c *service.Service) error { +func Render(c *service.Service) ([]byte, error) { + // Create service file from template + b, err := static.ReadFile("dist/Library/LaunchDaemons/_rdns_.plist.tmpl") + if err != nil { + return nil, err + } + s := string(b) + rw := &bytes.Buffer{} + // not sure what the template name does, but whatever + tmpl, err := template.New("service").Parse(s) + if err != nil { + return nil, err + } + err = tmpl.Execute(rw, c) + if nil != err { + return nil, err + } + + return rw.Bytes(), nil +} + +func install(c *service.Service) (string, error) { // Darwin-specific config options if c.PrivilegedPorts { if !c.System { - return fmt.Errorf("You must use root-owned LaunchDaemons (not user-owned LaunchAgents) to use priveleged ports on OS X") + return "", fmt.Errorf("You must use root-owned LaunchDaemons (not user-owned LaunchAgents) to use priveleged ports on OS X") } } plistDir := srvSysPath @@ -124,32 +144,20 @@ func install(c *service.Service) error { // Check paths first err := os.MkdirAll(filepath.Dir(plistDir), 0755) if nil != err { - return err + return "", err } - // Create service file from template - b, err := static.ReadFile("dist/Library/LaunchDaemons/_rdns_.plist.tmpl") - if err != nil { - return err - } - s := string(b) - rw := &bytes.Buffer{} - // not sure what the template name does, but whatever - tmpl, err := template.New("service").Parse(s) - if err != nil { - return err - } - err = tmpl.Execute(rw, c) + b, err := Render(c) if nil != err { - return err + return "", err } // Write the file out // TODO rdns plistName := c.ReverseDNS + ".plist" plistPath := filepath.Join(plistDir, plistName) - if err := ioutil.WriteFile(plistPath, rw.Bytes(), 0644); err != nil { - return fmt.Errorf("Error writing %s: %v", plistPath, err) + if err := ioutil.WriteFile(plistPath, b, 0644); err != nil { + return "", fmt.Errorf("Error writing %s: %v", plistPath, err) } // TODO --no-start @@ -158,9 +166,8 @@ func install(c *service.Service) error { fmt.Printf("If things don't go well you should be able to get additional logging from launchctl:\n") fmt.Printf("\tsudo launchctl log level debug\n") fmt.Printf("\ttail -f /var/log/system.log\n") - return err + return "", err } - fmt.Printf("Added and started '%s' as a launchctl service.\n", c.Name) - return nil + return "launchd", nil } diff --git a/manager/install_linux.go b/manager/install_linux.go index 9ea0f71..8b7d570 100644 --- a/manager/install_linux.go +++ b/manager/install_linux.go @@ -88,12 +88,11 @@ func start(conf *service.Service) error { cmds = adjustPrivs(system, cmds) - fmt.Println() typ := "USER MODE" if system { typ = "SYSTEM" } - fmt.Printf("Starting systemd %s service unit...\n", typ) + fmt.Printf("Starting systemd %s service unit...\n\n", typ) for i := range cmds { exe := cmds[i] fmt.Println("\t" + exe.String()) @@ -160,7 +159,28 @@ func stop(conf *service.Service) error { return nil } -func install(c *service.Service) error { +func Render(c *service.Service) ([]byte, error) { + // Create service file from template + b, err := static.ReadFile("dist/etc/systemd/system/_name_.service.tmpl") + if err != nil { + return nil, err + } + s := string(b) + rw := &bytes.Buffer{} + // not sure what the template name does, but whatever + tmpl, err := template.New("service").Parse(s) + if err != nil { + return nil, err + } + err = tmpl.Execute(rw, c) + if nil != err { + return nil, err + } + + return rw.Bytes(), nil +} + +func install(c *service.Service) (string, error) { // Linux-specific config options if c.System { if "" == c.User { @@ -177,32 +197,20 @@ func install(c *service.Service) error { serviceDir = filepath.Join(c.Home, srvUserPath) err := os.MkdirAll(serviceDir, 0755) if nil != err { - return err + return "", err } } - // Create service file from template - b, err := static.ReadFile("dist/etc/systemd/system/_name_.service.tmpl") - if err != nil { - return err - } - s := string(b) - rw := &bytes.Buffer{} - // not sure what the template name does, but whatever - tmpl, err := template.New("service").Parse(s) - if err != nil { - return err - } - err = tmpl.Execute(rw, c) + b, err := Render(c) if nil != err { - return err + return "", err } // Write the file out serviceName := c.Name + ".service" servicePath := filepath.Join(serviceDir, serviceName) - if err := ioutil.WriteFile(servicePath, rw.Bytes(), 0644); err != nil { - return fmt.Errorf("Error writing %s: %v", servicePath, err) + if err := ioutil.WriteFile(servicePath, b, 0644); err != nil { + return "", fmt.Errorf("Error writing %s: %v", servicePath, err) } // TODO --no-start @@ -217,9 +225,8 @@ func install(c *service.Service) error { } fmt.Printf("If things don't go well you should be able to get additional logging from journalctl:\n") fmt.Printf("\t%sjournalctl -xe %s %s.service\n", sudo, unit, c.Name) - return err + return "", err } - fmt.Printf("Added and started '%s' as a systemd service.\n", c.Name) - return nil + return "systemd", nil } diff --git a/manager/install_other.go b/manager/install_other.go index ff4c411..a969adb 100644 --- a/manager/install_other.go +++ b/manager/install_other.go @@ -6,6 +6,10 @@ import ( "git.rootprojects.org/root/go-serviceman/service" ) +func Render(c *service.Service) ([]byte, error) { + return nil, nil +} + func install(c *service.Service) error { return nil, nil } diff --git a/manager/install_windows.go b/manager/install_windows.go index a9f2a72..8e7b1af 100644 --- a/manager/install_windows.go +++ b/manager/install_windows.go @@ -30,7 +30,7 @@ func init() { // TODO system service requires elevated privileges // See https://coolaj86.com/articles/golang-and-windows-and-admins-oh-my/ -func install(c *service.Service) error { +func install(c *service.Service) (string, error) { /* // LEAVE THIS DOCUMENTATION HERE reg.exe @@ -73,7 +73,7 @@ func install(c *service.Service) error { args, err := installServiceman(c) if nil != err { - return err + return "", err } /* @@ -100,7 +100,7 @@ func install(c *service.Service) error { regSZ := fmt.Sprintf(`"%s" %s`, args[0], strings.Join(args[1:], " ")) if len(regSZ) > 260 { - return fmt.Errorf("data value is too long for registry entry") + return "", fmt.Errorf("data value is too long for registry entry") } // In order for a windows gui program to not show a console, // it has to not output any messages? @@ -108,17 +108,22 @@ func install(c *service.Service) error { //fmt.Println(autorunKey, c.Title, regSZ) k.SetStringValue(c.Title, regSZ) - // to return ErrDaemonize - return start(c) + err = start(c) + return "serviceman", err +} + +func Render(c *service.Service) ([]byte, error) { + b, err := json.Marshal(c) + if nil != err { + return nil, err + } + return b, nil } func start(conf *service.Service) error { args := getRunnerArgs(conf) - return &ErrDaemonize{ - DaemonArgs: append(args, "--daemon"), - error: "Not as much an error as a bad value...", - } - //return runner.Start(conf) + args = append(args, "--daemon") + return Run(args[0], args[1:]...) } func stop(conf *service.Service) error { @@ -173,7 +178,7 @@ func installServiceman(c *service.Service) ([]string, error) { } } - b, err := json.Marshal(c) + b, err := Render(c) if nil != err { // this should be impossible, so we'll just panic panic(err) diff --git a/manager/start.go b/manager/start.go index 9c222ba..2a86132 100644 --- a/manager/start.go +++ b/manager/start.go @@ -227,3 +227,21 @@ func adjustPrivs(system bool, cmds []Runnable) []Runnable { return cmds } + +func Run(bin string, args ...string) error { + cmd := exec.Command(bin, args...) + // for debugging + /* + out, err := cmd.CombinedOutput() + if nil != err { + fmt.Println(err) + } + fmt.Println(string(out)) + */ + + err := cmd.Start() + if nil != err { + return err + } + return nil +} diff --git a/service/service.go b/service/service.go index e59e17a..012d4a4 100644 --- a/service/service.go +++ b/service/service.go @@ -116,7 +116,7 @@ func (s *Service) Normalize(force bool) { _, err := os.Stat(optpath) if nil == err { bad = false - fmt.Fprintf(os.Stderr, "Using '%s' for '%s'\n", optpath, s.Exec) + //fmt.Fprintf(os.Stderr, "Using '%s' for '%s'\n", optpath, s.Exec) s.Exec = optpath } } diff --git a/serviceman.go b/serviceman.go index ee3131b..2d1b7e3 100644 --- a/serviceman.go +++ b/serviceman.go @@ -1,5 +1,6 @@ //go:generate go run -mod=vendor git.rootprojects.org/root/go-gitver +// main runs the things and does the stuff package main import ( @@ -9,8 +10,11 @@ import ( "io/ioutil" "os" "os/exec" + "os/user" + "path/filepath" "strings" "time" + "unicode/utf8" "git.rootprojects.org/root/go-serviceman/manager" "git.rootprojects.org/root/go-serviceman/runner" @@ -62,26 +66,15 @@ func add() { Restart: true, } - args := []string{} - for i := range os.Args { - if "--" == os.Args[i] { - if len(os.Args) > i+1 { - args = os.Args[i+1:] - } - os.Args = os.Args[:i] - break - } - } - conf.Argv = args - force := false forUser := false forSystem := false + dryrun := false flag.StringVar(&conf.Title, "title", "", "a human-friendly name for the service") flag.StringVar(&conf.Desc, "desc", "", "a human-friendly description of the service (ex: Foo App)") flag.StringVar(&conf.Name, "name", "", "a computer-friendly name for the service (ex: foo-app)") flag.StringVar(&conf.URL, "url", "", "the documentation on home page of the service") - //flag.StringVar(&conf.Workdir, "workdir", "", "the directory in which the service should be started") + flag.StringVar(&conf.Workdir, "workdir", "", "the directory in which the service should be started (if supported)") flag.StringVar(&conf.ReverseDNS, "rdns", "", "a plist-friendly Reverse DNS name for launchctl (ex: com.example.foo-app)") flag.BoolVar(&forSystem, "system", false, "attempt to add system service as an unprivileged/unelevated user") flag.BoolVar(&forUser, "user", false, "add user space / user mode service even when admin/root/sudo/elevated") @@ -89,67 +82,339 @@ func add() { flag.StringVar(&conf.User, "username", "", "run the service as this user") flag.StringVar(&conf.Group, "groupname", "", "run the service as this group") flag.BoolVar(&conf.PrivilegedPorts, "cap-net-bind", false, "this service should have access to privileged ports") + flag.BoolVar(&dryrun, "dryrun", false, "output the service file without modifying anything on disk") + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage of %s:\n\n", os.Args[0]) + + flag.PrintDefaults() + + fmt.Fprintf(os.Stderr, "Flags and arguments after \"--\" will be completely ignored by serviceman\n", os.Args[0]) + } flag.Parse() - args = flag.Args() + flagargs := flag.Args() + + // You must have something to run, duh + n := len(flagargs) + if 0 == n { + fmt.Println("Usage: serviceman add ./foo-app --foo-arg") + os.Exit(2) + return + } if forUser && forSystem { fmt.Println("Pfff! You can't --user AND --system! What are you trying to pull?") os.Exit(1) return } + + // There are three groups of flags + // serviceman --flag1 arg1 non-flag-arg --child1 -- --raw1 -- --raw2 + // serviceman --flag1 arg1 // these belong to serviceman + // non-flag-arg --child1 // these will be interpretted + // -- // separator + // --raw1 -- --raw2 // after the separater (including additional separators) will be ignored + rawargs := []string{} + for i := range flagargs { + if "--" == flagargs[i] { + if len(flagargs) > i+1 { + rawargs = flagargs[i+1:] + } + flagargs = flagargs[:i] + break + } + } + + // Assumptions + ass := []string{} if forUser { conf.System = false } else if forSystem { conf.System = true } else { conf.System = manager.IsPrivileged() + if conf.System { + ass = append(ass, "# Because you're a privileged user") + ass = append(ass, " --system") + ass = append(ass, "") + } else { + ass = append(ass, "# Because you're a unprivileged user") + ass = append(ass, " --user") + ass = append(ass, "") + } + } + if "" == conf.Workdir { + dir, _ := os.Getwd() + conf.Workdir = dir + ass = append(ass, "# Because this is your current working directory") + ass = append(ass, fmt.Sprintf(" --workdir %s", conf.Workdir)) + ass = append(ass, "") + } + if "" == conf.Name { + name, _ := os.Getwd() + base := filepath.Base(name) + ext := filepath.Ext(base) + n := (len(base) - len(ext)) + name = base[:n] + if "" == name { + name = base + } + conf.Name = name + ass = append(ass, "# Because this is the name of your current working directory") + ass = append(ass, fmt.Sprintf(" --name %s", conf.Name)) + ass = append(ass, "") } - n := len(args) - if 0 == n { - fmt.Println("Usage: serviceman add ./foo-app -- --foo-arg") - os.Exit(2) + exepath, err := findExec(flagargs[0], force) + if nil != err { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(3) return } + flagargs[0] = exepath - execpath, err := manager.WhereIs(args[0]) + exeargs, err := testScript(flagargs[0], force) if nil != err { - fmt.Fprintf(os.Stderr, "Error: '%s' could not be found in PATH or working directory.\n", args[0]) - if !force { - os.Exit(3) - return - } - } else { - args[0] = execpath + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(3) + return } - conf.Exec = args[0] - args = args[1:] - if n >= 2 { - conf.Interpreter = conf.Exec - conf.Exec = args[0] - conf.Argv = append(args[1:], conf.Argv...) + flagargs = append(exeargs, flagargs...) + // TODO + for i := range flagargs { + arg := flagargs[i] + arg = filepath.ToSlash(arg) + // Paths considered to be anything starting with ./, .\, /, \, C: + if "." == arg || strings.Contains(arg, "/") { + //if "." == arg || (len(arg) >= 2 && "./" == arg[:2] || '/' == arg[0] || "C:" == strings.ToUpper(arg[:1])) { + var err error + arg, err = filepath.Abs(arg) + if nil == err { + _, err = os.Stat(arg) + } + if nil != err { + fmt.Printf("%q appears to be a file path, but %q could not be read\n", flagargs[i], arg) + if !force { + os.Exit(7) + return + } + continue + } + + if '\\' != os.PathSeparator { + // Convert paths back to .\ for Windows + arg = filepath.FromSlash(arg) + } + + // Lookin' good + flagargs[i] = arg + } } - conf.Normalize(force) + // We won't bother with Interpreter here + // (it's really just for documentation), + // but we will add any and all unchecked args to the full slice + conf.Exec = flagargs[0] + conf.Argv = append(flagargs[1:], rawargs...) + + // TODO update docs: go to the work directory + // TODO test with "npm start" + + conf.NormalizeWithoutPath() //fmt.Printf("\n%#v\n\n", conf) if conf.System && !manager.IsPrivileged() { fmt.Fprintf(os.Stderr, "Warning: You may need to use 'sudo' to add %q as a privileged system service.\n", conf.Name) } - err = manager.Install(conf) - switch e := err.(type) { - case nil: - // do nothing - case *manager.ErrDaemonize: - runAsDaemon(e.DaemonArgs[0], e.DaemonArgs[1:]...) - default: + if len(ass) > 0 { + fmt.Println("OPTIONS: Making some assumptions...\n") + for i := range ass { + fmt.Println("\t" + ass[i]) + } + } + + // Find who this is running as + // And pretty print the command to run + runAs := conf.User + var wasflag bool + fmt.Printf("COMMAND: Service %q will be run like this (more or less):\n\n", conf.Title) + if conf.System { + if "" == runAs { + runAs = "root" + } + fmt.Printf("\t# Starts on system boot, as %q\n", runAs) + } else { + u, _ := user.Current() + runAs = u.Name + if "" == runAs { + runAs = u.Username + } + fmt.Printf("\t# Starts as %q, when %q logs in\n", runAs, u.Username) + } + //fmt.Printf("\tpushd %s\n", conf.Workdir) + fmt.Printf("\t%s\n", conf.Exec) + for i := range conf.Argv { + arg := conf.Argv[i] + if '-' == arg[0] { + if wasflag { + fmt.Println() + } + wasflag = true + fmt.Printf("\t\t%s", arg) + } else { + if wasflag { + fmt.Printf(" %s\n", arg) + } else { + fmt.Printf("\t\t%s\n", arg) + } + wasflag = false + } + } + if wasflag { + fmt.Println() + } + fmt.Println() + + // TODO output config without installing + if dryrun { + b, err := manager.Render(conf) + if nil != err { + fmt.Fprintf(os.Stderr, "Error rendering: %s\n", err) + os.Exit(10) + } + fmt.Println(string(b)) + return + } + + fmt.Printf("LAUNCHER: ") + servicetype, err := manager.Install(conf) + if nil != err { fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(500) + return } + fmt.Printf("LOGS: ") printLogMessage(conf) fmt.Println() + + servicemode := "USER MODE" + if conf.System { + servicemode = "SYSTEM" + } + fmt.Printf( + "SUCCESS:\n\n\t%q started as a %q %s service, running as %q\n", + conf.Name, + servicetype, + servicemode, + runAs, + ) + fmt.Println() +} + +func findExec(exe string, force bool) (string, error) { + // ex: node => /usr/local/bin/node + // ex: ./demo.js => /Users/aj/project/demo.js + exepath, err := exec.LookPath(exe) + if nil != err { + var msg string + if strings.Contains(filepath.ToSlash(exe), "/") { + if _, err := os.Stat(exe); err != nil { + msg = fmt.Sprintf("Error: '%s' could not be found in PATH or working directory.\n", exe) + } else { + msg = fmt.Sprintf("Error: '%s' is not an executable.\nYou may be able to fix that. Try running this:\n\tchmod a+x %s\n", exe, exe) + } + } else { + if _, err := os.Stat(exe); err != nil { + msg = fmt.Sprintf("Error: '%s' could not be found in PATH", exe) + } else { + msg = fmt.Sprintf("Error: '%s' could not be found in PATH, did you mean './%s'?\n", exe, exe) + } + } + if !force { + return "", fmt.Errorf(msg) + } + fmt.Fprintf(os.Stderr, "%s\n", msg) + return exe, nil + } + + // ex: \Users\aj\project\demo.js => /Users/aj/project/demo.js + // Can't have an error here when lookpath succeeded + exepath, _ = filepath.Abs(filepath.ToSlash(exepath)) + return exepath, nil +} + +func testScript(exepath string, force bool) ([]string, error) { + f, err := os.Open(exepath) + b := make([]byte, 256) + if nil == err { + _, err = f.Read(b) + } + if nil != err || len(b) < len("#!/x") { + msg := fmt.Sprintf("Error when testing if '%s' is a binary or script: could not read file: %s\n", exepath, err) + if !force { + return nil, fmt.Errorf(msg) + } + fmt.Fprintf(os.Stderr, "%s\n", msg) + return nil, nil + } + + // Nott sure if this is more readable and idiomatic as if else or switch + // However, the order matters + switch { + case utf8.Valid(b): + // Looks like an executable script + if "#!/" == string(b[:3]) { + break + } + + msg := fmt.Sprintf("Error: %q looks like a script, but we don't know the interpreter.\nYou can probably fix this by...\n"+ + "\tExplicitly naming the interpreter (ex: 'python my-script.py' instead of just 'my-script.py')\n"+ + "\tPlacing a hashbang at the top of the script (ex: '#!/usr/bin/env python')", exepath) + + if !force { + return nil, fmt.Errorf(msg) + } + return nil, nil + case "#!/" != string(b[:3]): + // Looks like a normal binary + return nil, nil + default: + // Looks like a corrupt script file + msg := "Error: It looks like you've specified a corrupt script file." + if !force { + return nil, fmt.Errorf(msg) + } + return nil, nil + } + + // Deal with #!/whatever + + // Get that first line + // "#!/usr/bin/env node" => ["/usr/bin/env", "node"] + // "#!/usr/bin/node --harmony => ["/usr/bin/node", "--harmony"] + s := string(b[2:]) // strip leading #! + s = strings.Split(strings.Replace(s, "\r\n", "\n", -1), "\n")[0] + allargs := strings.Split(strings.TrimSpace(s), " ") + args := []string{} + for i := range allargs { + arg := strings.TrimSpace(allargs[i]) + if "" != arg { + args = append(args, arg) + } + } + if strings.HasSuffix(args[0], "/env") && len(args) > 1 { + // TODO warn that "env" is probably not an executable if 1 = len(args)? + args = args[1:] + } + exepath, err = findExec(args[0], force) + if nil != err { + return nil, err + } + args[0] = exepath + + return args, nil } func start() { @@ -185,14 +450,10 @@ func start() { conf.NormalizeWithoutPath() err := manager.Start(conf) - switch e := err.(type) { - case nil: - // do nothing - case *manager.ErrDaemonize: - runAsDaemon(e.DaemonArgs[0], e.DaemonArgs[1:]...) - default: - fmt.Println(err) - os.Exit(127) + if nil != err { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(500) + return } } @@ -242,7 +503,7 @@ func run() { flag.Parse() if "" == confpath { - fmt.Fprintf(os.Stderr, "%s", strings.Join(flag.Args(), " ")) + fmt.Fprintf(os.Stderr, "%s\n", strings.Join(flag.Args(), " ")) fmt.Fprintf(os.Stderr, "--config /path/to/config.json is required\n") usage() os.Exit(1) @@ -295,23 +556,5 @@ func run() { return } - runAsDaemon(os.Args[0], "run", "--config", confpath) -} - -func runAsDaemon(bin string, args ...string) { - cmd := exec.Command(bin, args...) - // for debugging - /* - out, err := cmd.CombinedOutput() - if nil != err { - fmt.Println(err) - } - fmt.Println(string(out)) - */ - - err := cmd.Start() - if nil != err { - fmt.Fprintf(os.Stderr, "%s\n", err) - os.Exit(500) - } + manager.Run(os.Args[0], "run", "--config", confpath) } diff --git a/serviceman_darwin.go b/serviceman_darwin.go index 7d5c388..ef291fc 100644 --- a/serviceman_darwin.go +++ b/serviceman_darwin.go @@ -7,5 +7,5 @@ import ( ) func printLogMessage(conf *service.Service) { - fmt.Printf("If all went well the logs should have been created at:\n\t%s\n", conf.Logdir) + fmt.Printf("If all went well the logs should have been created at:\n\n\t%s\n", conf.Logdir) } diff --git a/serviceman_linux.go b/serviceman_linux.go index b71cea0..2ec2b0a 100644 --- a/serviceman_linux.go +++ b/serviceman_linux.go @@ -17,7 +17,7 @@ func printLogMessage(conf *service.Service) { } else { unit = "--user-unit" } - fmt.Println("If all went well you should be able to see some goodies in the logs:") + fmt.Println("If all went well you should be able to see some goodies in the logs:\n") fmt.Printf("\t%sjournalctl -xe %s %s.service\n", sudo, unit, conf.Name) if !conf.System { fmt.Println("\nIf that's not the case, see https://unix.stackexchange.com/a/486566/45554.") diff --git a/serviceman_windows.go b/serviceman_windows.go index 7d5c388..ef291fc 100644 --- a/serviceman_windows.go +++ b/serviceman_windows.go @@ -7,5 +7,5 @@ import ( ) func printLogMessage(conf *service.Service) { - fmt.Printf("If all went well the logs should have been created at:\n\t%s\n", conf.Logdir) + fmt.Printf("If all went well the logs should have been created at:\n\n\t%s\n", conf.Logdir) }