Kirsle.net logo Kirsle.net

script.go: Run Go Programs as Shell Scripts

January 7, 2018 by Noah

Have you ever wanted to use Go to write shell scripts?

Where you could put a "shebang" line like #!/usr/bin/env go, mark your source file executable, and run it?

I've found a pretty elegant way of making this possible, all in pure Go!

tl;dr.

To write your own Go scripts, just include this shebang header (vim modeline optional). Your shell script does not need a *.go extension. In fact you're better off not having a *.go extension!

///bin/true && exec /usr/bin/env script.go "$0" "$@"
// vim:set ft=go:

And then put this file, script.go, in your $PATH and make it executable:

///bin/true && exec /usr/bin/env go run "$0" "$@"
//
// script.go is a Go program that can run itself as a shell script, as well as
// help other Go scripts to run themselves.
//
// To make your Go scripts work with this, use the following "shebang" header
// at the top of your script:
//
//	///bin/true && exec /usr/bin/env script.go "$0" "$@"
//	// vim:set ft=go:
//	package main
//
// The first line will cause your shell to run `script.go` passing the current
// filename and the rest of the command line arguments. The vim modeline comment
// may help your code editor to highlight the file as Go syntax.
package main

import (
	"crypto/rand"
	"encoding/hex"
	"flag"
	"fmt"
	"io/ioutil"
	"os"
	"os/exec"
	"os/signal"
	"path/filepath"
	"syscall"
)

// Version of this script.
const Version = "1.0.0"

// Command line arguments
var (
	debug   bool
	version bool
)

func init() {
	flag.BoolVar(&debug, "debug", false, "Verbose debug logging")
	flag.BoolVar(&version, "version", false, "Show version number and exit")
}

func main() {
	flag.Parse()
	if version {
		fmt.Printf("This is script.go v%s\n", Version)
		os.Exit(0)
	}

	// Parse the script name and remaining arguments.
	args := flag.Args()
	if len(args) == 0 {
		usage()
	}
	scriptName := args[0]
	argv := args[1:]

	// Verify it's a file.
	if _, err := os.Stat(scriptName); os.IsNotExist(err) {
		die("%s: not a file", scriptName)
	}

	// Make a temp file with a *.go extension
	tmpfile, err := NamedTempFile("", "script", ".go")
	if err != nil {
		die("tempfile error: %s", err)
	}
	log("scriptName: %s; tmpFile: %s", scriptName, tmpfile.Name())

	// Read the source and write it to the new file.
	src, err := ioutil.ReadFile(scriptName)
	dieIfError(err)
	_, err = tmpfile.Write(src)
	dieIfError(err)
	err = tmpfile.Close()
	dieIfError(err)

	// Catch interrupt signals to clean up the tempfile.
	interrupt := make(chan os.Signal, 2)
	signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
	go func() {
		<-interrupt
		log("interrupt detected; cleaning up tempfile")
		err = os.Remove(tmpfile.Name())
		if err != nil {
			die("remove tmpfile error: %s", err)
		}
	}()

	// Finally, `go run` the script from $TMPDIR.
	goArgs := append([]string{"run", tmpfile.Name()}, argv...)
	c := exec.Command(
		"go",
		goArgs...,
	)
	c.Stdin = os.Stdin
	c.Stdout = os.Stdout
	c.Stderr = os.Stderr
	err = c.Run()
	if err != nil {
		fmt.Printf("[script.go] script error: %s\n", err)
	}

	log("cleaning up tempfile")
	os.Remove(tmpfile.Name())
}

// handler for Ctrl-C cleaning up the temp file.
func cleanup() {
	fmt.Println("cleanup")
}

// NamedTempFile is like ioutil.TempFile but accepts a suffix too.
func NamedTempFile(dir, prefix, suffix string) (f *os.File, err error) {
	if dir == "" {
		dir = os.TempDir()
	}

	// Random string generator.
	randomString := func() string {
		randBytes := make([]byte, 16)
		rand.Read(randBytes)
		return hex.EncodeToString(randBytes)
	}

	for i := 0; i < 10000; i++ {
		name := filepath.Join(dir, prefix+randomString()+suffix)
		f, err = os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
		if os.IsExist(err) {
			continue
		}
		break
	}

	return
}

func usage() {
	fmt.Print(
		"Usage: script.go [options] <go script path>\n",
		"See script.go -h for command line options.\n",
	)
	os.Exit(0)
}

func log(message string, v ...interface{}) {
	if debug {
		fmt.Printf("[script.go] "+message+"\n", v...)
	}
}

func die(message string, v ...interface{}) {
	fmt.Printf(message+"\n", v...)
	os.Exit(1)
}

func dieIfError(err error) {
	if err != nil {
		die(err.Error())
	}
}

Links to an example on my .dotfiles repo:

  • SimpleHTTPServer is a Go shell script that makes your current directory available on a web server.
  • script.go is the script runner. It is the only *.go file in ~/bin.

The Journey

Let me tell you about the adventure getting here.

Python "Go Script" Runner

My first pass at this was to use a Python script to run my Go scripts. I wrote a Python script named gosh (for "Go shell") and put it in my $PATH (at $HOME/bin).

Now I could start a Go script with #!/usr/bin/env gosh and running the Go script would end up passing it into gosh, where I could then find a way to make the script go runnable.

The Python script would read in the source file, chop off the shebang line (because #! would be invalid syntax in Go), write it into a new empty directory in /tmp with a *.go extension (because go run won't run it otherwise), and finally go run /tmp/whatever/script.go while passing the remaining command line arguments to it.

It worked well enough, but having the #! shebang line in my Go script broke my text editor. As it wasn't valid Go code, a lot of my Go plugins weren't able to work with it, so I'd have to temporarily remove the shebang line while working on it, and probably add a *.go extension temporarily for easy and quick write-run cycles.

These weren't ideal, and having a dependency on Python to run a Go script wasn't ideal either.

Native Go "Shebang Line"

Recently, I Googled to see what other people are doing to solve this problem, and found the closest thing to a shebang line that Go will natively accept.

For very simple use cases, name your script with a *.go suffix and begin it like this:

///bin/true; exec /usr/bin/env go run "$0" "$@"
package main

As it turns out, when you ./main.go your script, your shell will initially guess it's a Bash script and try running it as such. So that first line tells Bash to run /bin/true (a no-op that returns a success code) and then use exec to replace its process with go run and passes its file name to it ($0) and the remaining command line arguments ($@).

Go then runs the script, ignoring the "shebang line" because it's a valid comment in Go, and happily builds and runs the program.

There are a couple of problems with this:

  • Your scripts need to have a *.go suffix; otherwise go run ignores them.
  • If you have multiple Go scripts in the same folder, your text editor will get confused. It will see many *.go with package main and func main() and start bothering you with complaints about duplicate names and all sorts of problems.

But this basic shebang line to run *.go scripts with go run gave me a better idea.

Hybrid Approach: script.go

I could combine the best of the above solutions. Replace the Python gosh script with a Go script that self-runs itself with go run, and then have my Go shell scripts run that Go script to run themselves.

I called the Go shell script runner script.go and its source is at the top of this post.

This solves the problems of the above go run method:

  • Your scripts do not need a *.go extension, and to make it easier on you, they should not have one.
  • The only *.go script in my bin folder is script.go, so my text editor doesn't freak out. It only sees one func main() and such because there is only one *.go file.

The last problem may be the syntax highlighting; without a *.go extension your editor might not detect it as Go code.

For this, I put a Vim modeline in, so opening it in Vim will automatically set the Go syntax highlighting. For Atom, I installed vim-modeline.

Atom still works fine with my Go scripts, running go fmt and goimports and criticizing my syntax, but it doesn't confuse the sources of all of my Go scripts and getting in my way.

You can check out the sources here:

  • SimpleHTTPServer is a Go shell script that makes your current directory available on a web server.
  • script.go is the script runner.
Tags:

Comments

There are 0 comments on this page. Add yours.

Add a Comment

Used for your Gravatar and optional thread subscription. Privacy policy.
You may format your message using GitHub Flavored Markdown syntax.