mirror of
https://github.com/sorenisanerd/gotty.git
synced 2024-11-22 12:24:25 +00:00
refactor: decouple gotty app with terminal backends
This commit is contained in:
parent
d71e2fcfa8
commit
496ef86339
97
app/app.go
97
app/app.go
@ -13,20 +13,18 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/yudai/gotty/backends"
|
||||
"github.com/yudai/gotty/utils"
|
||||
|
||||
"github.com/braintree/manners"
|
||||
"github.com/elazarl/go-bindata-assetfs"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/kr/pty"
|
||||
"github.com/yudai/umutex"
|
||||
)
|
||||
|
||||
@ -36,14 +34,12 @@ type InitMessage struct {
|
||||
}
|
||||
|
||||
type App struct {
|
||||
command []string
|
||||
manager backends.ClientContextManager
|
||||
options *Options
|
||||
|
||||
upgrader *websocket.Upgrader
|
||||
server *manners.GracefulServer
|
||||
|
||||
titleTemplate *template.Template
|
||||
|
||||
onceMutex *umutex.UnblockingMutex
|
||||
timer *time.Timer
|
||||
|
||||
@ -66,14 +62,12 @@ type Options struct {
|
||||
TLSKeyFile string `hcl:"tls_key_file" flagName:"tls-key" flagDescribe:"TLS/SSL key file path" default:"~/.gotty.key"`
|
||||
EnableTLSClientAuth bool `hcl:"enable_tls_client_auth" default:"false"`
|
||||
TLSCACrtFile string `hcl:"tls_ca_crt_file" flagName:"tls-ca-crt" flagDescribe:"TLS/SSL CA certificate file for client certifications" default:"~/.gotty.ca.crt"`
|
||||
TitleFormat string `hcl:"title_format" flagName:"title-format" flagDescribe:"Title format of browser window" default:"GoTTY - {{ .Command }} ({{ .Hostname }})"`
|
||||
EnableReconnect bool `hcl:"enable_reconnect" flagName:"reconnect" flagDescribe:"Enable reconnection" default:"false"`
|
||||
ReconnectTime int `hcl:"reconnect_time" flagName:"reconnect-time" flagDescribe:"Time to reconnect" default:"10"`
|
||||
MaxConnection int `hcl:"max_connection" flagName:"max-connection" flagDescribe:"Maximum connection to gotty" default:"0"`
|
||||
Once bool `hcl:"once" flagName:"once" flagDescribe:"Accept only one client and exit on disconnection" default:"false"`
|
||||
Timeout int `hcl:"timeout" flagName:"timeout" flagDescribe:"Timeout seconds for waiting a client(0 to disable)" default:"0"`
|
||||
PermitArguments bool `hcl:"permit_arguments" flagName:"permit-arguments" flagDescribe:"Permit clients to send command line arguments in URL (e.g. http://example.com:8080/?arg=AAA&arg=BBB)" default:"true"`
|
||||
CloseSignal int `hcl:"close_signal" flagName:"close-signal" flagDescribe:"Signal sent to the command process when gotty close it (default: SIGHUP)" default:"1"`
|
||||
Preferences HtermPrefernces `hcl:"preferences"`
|
||||
RawPreferences map[string]interface{} `hcl:"preferences"`
|
||||
Width int `hcl:"width" flagName:"width" flagDescribe:"Static width of the screen, 0(default) means dynamically resize" default:"0"`
|
||||
@ -82,16 +76,10 @@ type Options struct {
|
||||
|
||||
var Version = "1.0.0"
|
||||
|
||||
func New(command []string, options *Options) (*App, error) {
|
||||
titleTemplate, err := template.New("title").Parse(options.TitleFormat)
|
||||
if err != nil {
|
||||
return nil, errors.New("Title format string syntax error")
|
||||
}
|
||||
|
||||
func New(manager backends.ClientContextManager, options *Options) (*App, error) {
|
||||
connections := int64(0)
|
||||
|
||||
return &App{
|
||||
command: command,
|
||||
manager: manager,
|
||||
options: options,
|
||||
|
||||
upgrader: &websocket.Upgrader{
|
||||
@ -99,9 +87,6 @@ func New(command []string, options *Options) (*App, error) {
|
||||
WriteBufferSize: 1024,
|
||||
Subprotocols: []string{"gotty"},
|
||||
},
|
||||
|
||||
titleTemplate: titleTemplate,
|
||||
|
||||
onceMutex: umutex.New(),
|
||||
connections: &connections,
|
||||
}, nil
|
||||
@ -169,10 +154,6 @@ func (app *App) Run() error {
|
||||
if app.options.EnableTLS {
|
||||
scheme = "https"
|
||||
}
|
||||
log.Printf(
|
||||
"Server is starting with command: %s",
|
||||
strings.Join(app.command, " "),
|
||||
)
|
||||
if app.options.Address != "" {
|
||||
log.Printf(
|
||||
"URL: %s",
|
||||
@ -267,8 +248,23 @@ func (app *App) restartTimer() {
|
||||
|
||||
func (app *App) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
app.stopTimer()
|
||||
|
||||
connections := atomic.AddInt64(app.connections, 1)
|
||||
defer func() {
|
||||
connections := atomic.AddInt64(app.connections, -1)
|
||||
|
||||
if app.options.MaxConnection != 0 {
|
||||
log.Printf("Connection closed: %s, connections: %d/%d",
|
||||
r.RemoteAddr, connections, app.options.MaxConnection)
|
||||
} else {
|
||||
log.Printf("Connection closed: %s, connections: %d",
|
||||
r.RemoteAddr, connections)
|
||||
}
|
||||
|
||||
if connections == 0 {
|
||||
app.restartTimer()
|
||||
}
|
||||
}()
|
||||
|
||||
if int64(app.options.MaxConnection) != 0 {
|
||||
if connections > int64(app.options.MaxConnection) {
|
||||
log.Printf("Reached max connection: %d", app.options.MaxConnection)
|
||||
@ -287,11 +283,11 @@ func (app *App) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
log.Print("Failed to upgrade connection: " + err.Error())
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_, stream, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Print("Failed to authenticate websocket connection")
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
var init InitMessage
|
||||
@ -299,32 +295,34 @@ func (app *App) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
err = json.Unmarshal(stream, &init)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse init message %v", err)
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
if init.AuthToken != app.options.Credential {
|
||||
log.Print("Failed to authenticate websocket connection")
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
argv := app.command[1:]
|
||||
if app.options.PermitArguments {
|
||||
if init.Arguments == "" {
|
||||
init.Arguments = "?"
|
||||
|
||||
var queryPath string
|
||||
if app.options.PermitArguments && init.Arguments != "" {
|
||||
queryPath = init.Arguments
|
||||
} else {
|
||||
queryPath = "?"
|
||||
}
|
||||
query, err := url.Parse(init.Arguments)
|
||||
|
||||
query, err := url.Parse(queryPath)
|
||||
if err != nil {
|
||||
log.Print("Failed to parse arguments")
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
params := query.Query()["arg"]
|
||||
if len(params) != 0 {
|
||||
argv = append(argv, params...)
|
||||
}
|
||||
params := query.Query()
|
||||
ctx, err := app.manager.New(params)
|
||||
if err != nil {
|
||||
log.Printf("Failed to new client context %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
app.server.StartRoutine()
|
||||
defer app.server.FinishRoutine()
|
||||
|
||||
if app.options.Once {
|
||||
if app.onceMutex.TryLock() { // no unlock required, it will die soon
|
||||
@ -337,30 +335,7 @@ func (app *App) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(app.command[0], argv...)
|
||||
ptyIo, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
log.Print("Failed to execute command")
|
||||
return
|
||||
}
|
||||
|
||||
if app.options.MaxConnection != 0 {
|
||||
log.Printf("Command is running for client %s with PID %d (args=%q), connections: %d/%d",
|
||||
r.RemoteAddr, cmd.Process.Pid, strings.Join(argv, " "), connections, app.options.MaxConnection)
|
||||
} else {
|
||||
log.Printf("Command is running for client %s with PID %d (args=%q), connections: %d",
|
||||
r.RemoteAddr, cmd.Process.Pid, strings.Join(argv, " "), connections)
|
||||
}
|
||||
|
||||
context := &clientContext{
|
||||
app: app,
|
||||
request: r,
|
||||
connection: conn,
|
||||
command: cmd,
|
||||
pty: ptyIo,
|
||||
writeMutex: &sync.Mutex{},
|
||||
}
|
||||
|
||||
context := &clientContext{app: app, connection: conn, writeMutex: &sync.Mutex{}, ClientContext: ctx}
|
||||
context.goHandleClient()
|
||||
}
|
||||
|
||||
|
@ -1,29 +1,21 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/fatih/structs"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/yudai/gotty/backends"
|
||||
)
|
||||
|
||||
type clientContext struct {
|
||||
backends.ClientContext
|
||||
app *App
|
||||
request *http.Request
|
||||
connection *websocket.Conn
|
||||
command *exec.Cmd
|
||||
pty *os.File
|
||||
writeMutex *sync.Mutex
|
||||
}
|
||||
|
||||
@ -46,56 +38,23 @@ type argResizeTerminal struct {
|
||||
Rows float64
|
||||
}
|
||||
|
||||
type ContextVars struct {
|
||||
Command string
|
||||
Pid int
|
||||
Hostname string
|
||||
RemoteAddr string
|
||||
}
|
||||
|
||||
func (context *clientContext) goHandleClient() {
|
||||
exit := make(chan bool, 2)
|
||||
exit := make(chan bool, 3)
|
||||
|
||||
context.Start(exit)
|
||||
|
||||
go func() {
|
||||
defer func() { exit <- true }()
|
||||
|
||||
context.processSend()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer func() { exit <- true }()
|
||||
|
||||
context.processReceive()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer context.app.server.FinishRoutine()
|
||||
defer func() {
|
||||
connections := atomic.AddInt64(context.app.connections, -1)
|
||||
|
||||
if context.app.options.MaxConnection != 0 {
|
||||
log.Printf("Connection closed: %s, connections: %d/%d",
|
||||
context.request.RemoteAddr, connections, context.app.options.MaxConnection)
|
||||
} else {
|
||||
log.Printf("Connection closed: %s, connections: %d",
|
||||
context.request.RemoteAddr, connections)
|
||||
}
|
||||
|
||||
if connections == 0 {
|
||||
context.app.restartTimer()
|
||||
}
|
||||
}()
|
||||
|
||||
<-exit
|
||||
context.pty.Close()
|
||||
|
||||
// Even if the PTY has been closed,
|
||||
// Read(0 in processSend() keeps blocking and the process doen't exit
|
||||
context.command.Process.Signal(syscall.Signal(context.app.options.CloseSignal))
|
||||
|
||||
context.command.Wait()
|
||||
context.connection.Close()
|
||||
}()
|
||||
context.TearDown()
|
||||
}
|
||||
|
||||
func (context *clientContext) processSend() {
|
||||
@ -107,9 +66,9 @@ func (context *clientContext) processSend() {
|
||||
buf := make([]byte, 1024)
|
||||
|
||||
for {
|
||||
size, err := context.pty.Read(buf)
|
||||
size, err := context.OutputReader().Read(buf)
|
||||
if err != nil {
|
||||
log.Printf("Command exited for: %s", context.request.RemoteAddr)
|
||||
log.Printf("failed to read output from terminal backend: %v", err)
|
||||
return
|
||||
}
|
||||
safeMessage := base64.StdEncoding.EncodeToString([]byte(buf[:size]))
|
||||
@ -127,19 +86,11 @@ func (context *clientContext) write(data []byte) error {
|
||||
}
|
||||
|
||||
func (context *clientContext) sendInitialize() error {
|
||||
hostname, _ := os.Hostname()
|
||||
titleVars := ContextVars{
|
||||
Command: strings.Join(context.app.command, " "),
|
||||
Pid: context.command.Process.Pid,
|
||||
Hostname: hostname,
|
||||
RemoteAddr: context.request.RemoteAddr,
|
||||
}
|
||||
|
||||
titleBuffer := new(bytes.Buffer)
|
||||
if err := context.app.titleTemplate.Execute(titleBuffer, titleVars); err != nil {
|
||||
windowTitle, err := context.WindowTitle()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := context.write(append([]byte{SetWindowTitle}, titleBuffer.Bytes()...)); err != nil {
|
||||
if err := context.write(append([]byte{SetWindowTitle}, []byte(windowTitle)...)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -187,7 +138,7 @@ func (context *clientContext) processReceive() {
|
||||
break
|
||||
}
|
||||
|
||||
_, err := context.pty.Write(data[1:])
|
||||
_, err := context.InputWriter().Write(data[1:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@ -204,7 +155,6 @@ func (context *clientContext) processReceive() {
|
||||
log.Print("Malformed remote command")
|
||||
return
|
||||
}
|
||||
|
||||
rows := uint16(context.app.options.Height)
|
||||
if rows == 0 {
|
||||
rows = uint16(args.Rows)
|
||||
@ -215,24 +165,7 @@ func (context *clientContext) processReceive() {
|
||||
columns = uint16(args.Columns)
|
||||
}
|
||||
|
||||
window := struct {
|
||||
row uint16
|
||||
col uint16
|
||||
x uint16
|
||||
y uint16
|
||||
}{
|
||||
rows,
|
||||
columns,
|
||||
0,
|
||||
0,
|
||||
}
|
||||
syscall.Syscall(
|
||||
syscall.SYS_IOCTL,
|
||||
context.pty.Fd(),
|
||||
syscall.TIOCSWINSZ,
|
||||
uintptr(unsafe.Pointer(&window)),
|
||||
)
|
||||
|
||||
context.ResizeTerminal(columns, rows)
|
||||
default:
|
||||
log.Print("Unknown message type")
|
||||
return
|
||||
|
19
backends/interface.go
Normal file
19
backends/interface.go
Normal file
@ -0,0 +1,19 @@
|
||||
package backends
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type ClientContextManager interface {
|
||||
New(params url.Values) (ClientContext, error)
|
||||
}
|
||||
|
||||
type ClientContext interface {
|
||||
WindowTitle() (string, error)
|
||||
Start(exitCh chan bool)
|
||||
InputWriter() io.Writer
|
||||
OutputReader() io.Reader
|
||||
ResizeTerminal(width, height uint16) error
|
||||
TearDown() error
|
||||
}
|
BIN
backends/ptycommand/.command.go.swp
Normal file
BIN
backends/ptycommand/.command.go.swp
Normal file
Binary file not shown.
130
backends/ptycommand/command.go
Normal file
130
backends/ptycommand/command.go
Normal file
@ -0,0 +1,130 @@
|
||||
package ptycommand
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"text/template"
|
||||
"unsafe"
|
||||
|
||||
"github.com/yudai/gotty/backends"
|
||||
|
||||
"github.com/kr/pty"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
CloseSignal int `hcl:"close_signal" flagName:"close-signal" flagSName:"" flagDescribe:"Signal sent to the command process when gotty close it (default: SIGHUP)" default:"1"`
|
||||
TitleFormat string `hcl:"title_format" flagName:"title-format" flagSName:"" flagDescribe:"Title format of browser window" default:"GoTTY - {{ .Command }} ({{ .Hostname }})"`
|
||||
}
|
||||
|
||||
type CommandClientContextManager struct {
|
||||
command []string
|
||||
|
||||
options *Options
|
||||
titleTemplate *template.Template
|
||||
}
|
||||
|
||||
func NewCommandClientContextManager(command []string, options *Options) (*CommandClientContextManager, error) {
|
||||
titleTemplate, err := template.New("title").Parse(options.TitleFormat)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Title format string syntax error: %v", options.TitleFormat)
|
||||
}
|
||||
return &CommandClientContextManager{command: command, options: options, titleTemplate: titleTemplate}, nil
|
||||
}
|
||||
|
||||
type CommandClientContext struct {
|
||||
cmd *exec.Cmd
|
||||
pty *os.File
|
||||
mgr *CommandClientContextManager
|
||||
}
|
||||
|
||||
func (mgr *CommandClientContextManager) New(params url.Values) (backends.ClientContext, error) {
|
||||
argv := mgr.command[1:]
|
||||
args := params["arg"]
|
||||
if len(args) != 0 {
|
||||
argv = append(argv, args...)
|
||||
}
|
||||
|
||||
cmd := exec.Command(mgr.command[0], argv...)
|
||||
return &CommandClientContext{cmd: cmd, mgr: mgr}, nil
|
||||
}
|
||||
|
||||
func (context *CommandClientContext) WindowTitle() (title string, err error) {
|
||||
hostname, _ := os.Hostname()
|
||||
|
||||
titleVars := struct {
|
||||
Command string
|
||||
Pid int
|
||||
Hostname string
|
||||
}{
|
||||
Command: context.cmd.Path,
|
||||
Pid: context.cmd.Process.Pid,
|
||||
Hostname: hostname,
|
||||
}
|
||||
|
||||
titleBuffer := new(bytes.Buffer)
|
||||
if err := context.mgr.titleTemplate.Execute(titleBuffer, titleVars); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return titleBuffer.String(), nil
|
||||
}
|
||||
|
||||
func (context *CommandClientContext) Start(exitCh chan bool) {
|
||||
ptyIo, err := pty.Start(context.cmd)
|
||||
if err != nil {
|
||||
log.Printf("failed to start command %v", err)
|
||||
exitCh <- true
|
||||
} else {
|
||||
context.pty = ptyIo
|
||||
}
|
||||
}
|
||||
|
||||
func (context *CommandClientContext) InputWriter() io.Writer {
|
||||
return context.pty
|
||||
}
|
||||
|
||||
func (context *CommandClientContext) OutputReader() io.Reader {
|
||||
return context.pty
|
||||
}
|
||||
|
||||
func (context *CommandClientContext) ResizeTerminal(width, height uint16) error {
|
||||
window := struct {
|
||||
row uint16
|
||||
col uint16
|
||||
x uint16
|
||||
y uint16
|
||||
}{
|
||||
height,
|
||||
width,
|
||||
0,
|
||||
0,
|
||||
}
|
||||
_, _, errno := syscall.Syscall(
|
||||
syscall.SYS_IOCTL,
|
||||
context.pty.Fd(),
|
||||
syscall.TIOCSWINSZ,
|
||||
uintptr(unsafe.Pointer(&window)),
|
||||
)
|
||||
if errno != 0 {
|
||||
return errno
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (context *CommandClientContext) TearDown() error {
|
||||
context.pty.Close()
|
||||
|
||||
// Even if the PTY has been closed,
|
||||
// Read(0 in processSend() keeps blocking and the process doen't exit
|
||||
if context.cmd != nil && context.cmd.Process != nil {
|
||||
context.cmd.Process.Signal(syscall.Signal(context.mgr.options.CloseSignal))
|
||||
context.cmd.Wait()
|
||||
}
|
||||
return nil
|
||||
}
|
36
main.go
36
main.go
@ -9,22 +9,28 @@ import (
|
||||
"github.com/codegangsta/cli"
|
||||
|
||||
"github.com/yudai/gotty/app"
|
||||
"github.com/yudai/gotty/backends/ptycommand"
|
||||
"github.com/yudai/gotty/utils"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cmd := cli.NewApp()
|
||||
cmd.Version = app.Version
|
||||
cmd.Name = "gotty"
|
||||
cmd.Version = app.Version
|
||||
cmd.Usage = "Share your terminal as a web application"
|
||||
cmd.HideHelp = true
|
||||
cli.AppHelpTemplate = helpTemplate
|
||||
|
||||
options := &app.Options{}
|
||||
if err := utils.ApplyDefaultValues(options); err != nil {
|
||||
appOptions := &app.Options{}
|
||||
if err := utils.ApplyDefaultValues(appOptions); err != nil {
|
||||
exit(err, 1)
|
||||
}
|
||||
backendOptions := &ptycommand.Options{}
|
||||
if err := utils.ApplyDefaultValues(backendOptions); err != nil {
|
||||
exit(err, 1)
|
||||
}
|
||||
|
||||
cliFlags, flagMappings, err := utils.GenerateFlags(options)
|
||||
cliFlags, flagMappings, err := utils.GenerateFlags(appOptions, backendOptions)
|
||||
if err != nil {
|
||||
exit(err, 3)
|
||||
}
|
||||
@ -41,28 +47,33 @@ func main() {
|
||||
|
||||
cmd.Action = func(c *cli.Context) {
|
||||
if len(c.Args()) == 0 {
|
||||
msg := "Error: No command given."
|
||||
cli.ShowAppHelp(c)
|
||||
exit(fmt.Errorf("Error: No command given."), 1)
|
||||
exit(fmt.Errorf(msg), 1)
|
||||
}
|
||||
|
||||
configFile := c.String("config")
|
||||
_, err := os.Stat(utils.ExpandHomeDir(configFile))
|
||||
if configFile != "~/.gotty" || !os.IsNotExist(err) {
|
||||
if err := utils.ApplyConfigFile(configFile, options); err != nil {
|
||||
if err := utils.ApplyConfigFile(configFile, appOptions, backendOptions); err != nil {
|
||||
exit(err, 2)
|
||||
}
|
||||
}
|
||||
|
||||
utils.ApplyFlags(cliFlags, flagMappings, c, options)
|
||||
utils.ApplyFlags(cliFlags, flagMappings, c, appOptions, backendOptions)
|
||||
|
||||
options.EnableBasicAuth = c.IsSet("credential")
|
||||
options.EnableTLSClientAuth = c.IsSet("tls-ca-crt")
|
||||
appOptions.EnableBasicAuth = c.IsSet("credential")
|
||||
appOptions.EnableTLSClientAuth = c.IsSet("tls-ca-crt")
|
||||
|
||||
if err := app.CheckConfig(options); err != nil {
|
||||
if err := app.CheckConfig(appOptions); err != nil {
|
||||
exit(err, 6)
|
||||
}
|
||||
|
||||
app, err := app.New(c.Args(), options)
|
||||
manager, err := ptycommand.NewCommandClientContextManager(c.Args(), backendOptions)
|
||||
if err != nil {
|
||||
exit(err, 3)
|
||||
}
|
||||
app, err := app.New(manager, appOptions)
|
||||
if err != nil {
|
||||
exit(err, 3)
|
||||
}
|
||||
@ -74,9 +85,6 @@ func main() {
|
||||
exit(err, 4)
|
||||
}
|
||||
}
|
||||
|
||||
cli.AppHelpTemplate = helpTemplate
|
||||
|
||||
cmd.Run(os.Args)
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user