~ach/hermes

cbf4e63903ea730e208c6e82c6454492b5f44bb5 — Andrew Chambers 3 years ago 6157219 env_cmd
Add an 'env' command.
M src/cmd/hermes/build.go => src/cmd/hermes/build.go +2 -7
@@ 14,7 14,6 @@ import (
	"path"
	"path/filepath"
	"strings"
	"time"

	"github.com/andrewchambers/hermes/extrasqlite"
	"github.com/andrewchambers/hermes/hscript/hscript"


@@ 410,12 409,8 @@ func buildMain() {
		<-c
		_, _ = fmt.Fprintf(os.Stderr, "Got interrupt, cancelling build.\n")
		cancelBuild()
		select {
		case <-time.After(10 * time.Second):
			_, _ = fmt.Fprintf(os.Stderr, "Cancel timer expired.\n")
		case <-c:
		}
		die("Aborting.\n")
		<-c
		die("Second interrupt, hard aborting build.\n")
	}()

	modUrl := ""

A src/cmd/hermes/env.go => src/cmd/hermes/env.go +152 -0
@@ 0,0 1,152 @@
package main

import (
	"bytes"
	"context"
	"fmt"
	"io/ioutil"
	"os"
	"os/exec"
	"os/signal"
	"path/filepath"

	"github.com/andrewchambers/hermes/envfile"
	"github.com/andrewchambers/hermes/proctools"
	"github.com/pkg/errors"
	flag "github.com/spf13/pflag"
	"golang.org/x/sys/unix"
)

type doShellOptions struct {
	Quiet   bool
	Expr    string
	ModUrl  string
	KeepEnv []string
	ToRun   []string
}

func doShell(ctx context.Context, opts doShellOptions) error {
	if len(opts.ToRun) < 1 {
		return errors.New("no command specified")
	}

	selfBin, err := proctools.SelfExe()
	if err != nil {
		return err
	}

	tmpDir, err := ioutil.TempDir("", "")
	if err != nil {
		return err
	}
	defer os.RemoveAll(tmpDir)

	buildArgs := []string{"build", "-o", filepath.Join(tmpDir, "hermes-shell")}
	if opts.Expr != "" {
		buildArgs = append(buildArgs, "-e", opts.Expr)
	}
	buildArgs = append(buildArgs, opts.ModUrl)

	var outBuf bytes.Buffer
	cmd := exec.Command(selfBin, buildArgs...)
	cmd.Stdout = &outBuf
	if !opts.Quiet {
		cmd.Stderr = os.Stderr
	}
	err = proctools.RunCmd(ctx, cmd, func() {
		_ = cmd.Process.Signal(unix.SIGTERM)
	})
	if err != nil {
		return err
	}

	envPath := filepath.Join(outBuf.String(), "hermes_env")
	buf, err := ioutil.ReadFile(envPath)
	if err != nil {
		return err
	}

	env, err := envfile.Parse(envPath, bytes.NewReader(buf))
	if err != nil {
		return err
	}

	cmd = exec.Command(opts.ToRun[0], opts.ToRun[1:]...)

	cmd.Env = []string{}
	for k, v := range env {
		cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
	}
	for _, e := range opts.KeepEnv {
		cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", os.Getenv(e)))
	}
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	// If we press Ctrl+C in a terminal,
	// simple commands that don't install signal handlers
	// will get a SIGTERM, commands like bash and ssh
	// seem to know to shift the terminal away so we don't
	// get terminal signals and they can operate sanely.
	// Simpler commands will just get terminated if we are
	// cancelled.

	err = proctools.RunCmd(ctx, cmd, func() {
		_ = cmd.Process.Signal(unix.SIGTERM)
	})

	if err != nil {
		return err
	}

	return nil
}

func envMain() {
	var err error

	quiet := flag.BoolP("quiet", "q", false, "Don't print shell preparation output to stderr.")
	expr := flag.StringP("expr", "e", "default", "The expression for selecting the shell package to run.")
	keep := flag.StringSliceP("keep", "k", nil, "Env vars to inherit from the host.")

	flag.Parse()

	modUrl := "./env.hpkg"
	toRun := []string{}

	if len(flag.Args()) >= 1 {
		modUrl = flag.Args()[0]
	}

	if len(flag.Args()) >= 2 {
		toRun = flag.Args()[1:]
	}

	if len(toRun) == 0 {
		die("Please specify a command to run.\n")
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	go func() {
		c := make(chan os.Signal, 1)
		signal.Notify(c, os.Interrupt, unix.SIGTERM)
		<-c
		_, _ = fmt.Fprintf(os.Stderr, "Got interrupt.\n")
		cancel()
		die("Aborting.\n")
	}()

	err = doShell(ctx, doShellOptions{
		Quiet:   *quiet,
		ModUrl:  modUrl,
		Expr:    *expr,
		KeepEnv: *keep,
		ToRun:   toRun,
	})
	if err != nil {
		die("Error running shell: %s\n", err)
	}
}

M src/cmd/hermes/main.go => src/cmd/hermes/main.go +2 -0
@@ 169,6 169,8 @@ func main() {
		mainFunc = hashMain
	case "export":
		mainFunc = exportMain
	case "env":
		mainFunc = envMain
	case "--help", "-h":
		subCommand = "help"
		fallthrough

A src/envfile/envfile_test.go => src/envfile/envfile_test.go +100 -0
@@ 0,0 1,100 @@
package envfile

import (
	"strings"
	"testing"
)

func TestParse(t *testing.T) {

	testInput := `
FOO1=bar
FOO2=bar 	
FOO3=

  # a comment
QUOTE1="abc" 	
QUOTE2="abc"
QUOTE3=""
QUOTE4="he\l\""
MULTI1="a 
b' c"
MULTI2='a "
b c'
`

	result, err := Parse("<test>", strings.NewReader(testInput))
	if err != nil {
		t.Fatal(err)
	}

	expected := map[string]string{
		"FOO1":   "bar",
		"FOO2":   "bar \t",
		"FOO3":   "",
		"QUOTE1": "abc",
		"QUOTE2": "abc",
		"QUOTE3": "",
		"QUOTE4": "he\\l\"",
		"MULTI1": "a \nb' c",
		"MULTI2": "a \"\nb c",
	}

	for k, v := range expected {
		if result[k] != v {
			t.Fatalf("result[%q] != %q (got %q)", k, v, result[k])
		}
	}
}

func TestParseEOF(t *testing.T) {
	inputs := []string{
		"foo=",
		"foo=a",
		"foo=\"\"",
	}

	outputs := []string{
		"",
		"a",
		"",
	}

	for idx, input := range inputs {
		result, err := Parse("<test>", strings.NewReader(input))
		if err != nil {
			t.Fatal(err)
		}
		if result["foo"] != outputs[idx] {
			t.Fatalf("test %d: %q != %q", idx, result["foo"], outputs[idx])
		}
	}
}

func TestParseErrors(t *testing.T) {
	inputs := []string{
		`foo="

		`,
		"bar",
		"bar ",
		"bar='t'x",
	}

	outputs := []string{
		"syntax error at <test>:3: unclosed quotation",
		"syntax error at <test>:1: unexpected end of file",
		"syntax error at <test>:1: unexpected space or tab",
		"syntax error at <test>:1: expected newline after quoted value",
	}

	for idx, input := range inputs {
		_, err := Parse("<test>", strings.NewReader(input))
		if err == nil {
			t.Fatal(err)
		}
		if err.Error() != outputs[idx] {
			t.Fatalf("test %d: %q != %q", idx, err.Error(), outputs[idx])
		}
	}
}

A src/envfile/parse.go => src/envfile/parse.go +142 -0
@@ 0,0 1,142 @@
package envfile

import (
	"bufio"
	"bytes"
	"fmt"
	"io"
)

func Parse(name string, input io.Reader) (map[string]string, error) {
	r := bufio.NewReader(input)
	env := make(map[string]string)
	ln := 1

	syntaxError := func(msg string, args ...interface{}) error {
		return fmt.Errorf("syntax error at %s:%d: %s", name, ln, fmt.Sprintf(msg, args...))
	}

	const (
		st_line_start = iota
		st_comment
		st_name
		st_val_start
		st_val
		st_quote
		st_quote_esc
		st_quote_end
	)

	state := st_line_start
	var quoteRune rune
	var envName bytes.Buffer
	var envVal bytes.Buffer

	for {

		c, _, err := r.ReadRune()
		if err == io.EOF {
			break
		}
		if err != nil {
			return nil, err
		}
		if c == '\n' {
			ln += 1
		}

		switch state {
		case st_line_start:
			envName.Reset()
			envVal.Reset()
			switch c {
			case '\n', ' ', '\t':
				/* nothing */
			case '#':
				state = st_comment
			default:
				envName.WriteRune(c)
				state = st_name
			}
		case st_comment:
			switch c {
			case '\n':
				state = st_line_start
			default:
			}
		case st_name:
			switch c {
			case ' ', '\t':
				return nil, syntaxError("unexpected space or tab")
			case '=':
				state = st_val_start
			default:
				envName.WriteRune(c)
			}
		case st_val_start:
			switch c {
			case '\n':
				env[envName.String()] = envVal.String()
				state = st_line_start
			case '\'', '"':
				quoteRune = c
				state = st_quote
			default:
				state = st_val
				envVal.WriteRune(c)
			}
		case st_val:
			switch c {
			case '\n':
				env[envName.String()] = envVal.String()
				state = st_line_start
			default:
				envVal.WriteRune(c)
			}
		case st_quote:
			switch c {
			case '\\':
				state = st_quote_esc
			case '\'', '"':
				if c == quoteRune {
					env[envName.String()] = envVal.String()
					state = st_quote_end
				} else {
					envVal.WriteRune(c)
				}
			default:
				envVal.WriteRune(c)
			}
		case st_quote_esc:
			if c == quoteRune {
				envVal.WriteRune(c)
			} else {
				envVal.WriteRune('\\')
				envVal.WriteRune(c)
			}
			state = st_quote
		case st_quote_end:
			switch c {
			case '\n':
				state = st_line_start
			case ' ', '\t':
				// Just skip
			default:
				return nil, syntaxError("expected newline after quoted value")
			}
		}
	}

	switch state {
	case st_quote, st_quote_esc:
		return nil, syntaxError("unclosed quotation")
	case st_val_start, st_val, st_quote_end:
		env[envName.String()] = envVal.String()
	case st_line_start:
		/* nothings */
	default:
		return nil, syntaxError("unexpected end of file")
	}

	return env, nil
}