Repository: openllb/hlb
Pull request #137 (2020-05-02 06:59:26)

add localRun command with options

coryb: > I think this PR looks good, but I've been thinking instead of the magic shell-style quoting deciding whether it runs with shell or not, we should probably always run with shell, and add a `option::run exec` or `option::run noShell` option to deliberately choose this behaviour. WDYT?

Hmm, so the rules would be:
1) if multiple variadic arguments then we directly execute
ie: `run "echo" "$HOME"` would run without a /bin/sh wrapper because we explicitly separate the arguments and run `["/bin/echo", "$HOME"]`

2) if single argument always run with shell (barring noShell option)
ie `run "echo $HOME"` and `run "true"` would always run as `["/bin/sh",  "-c", "echo $HOME"]` and `["/bin/sh", "-c", "true"]`

Maybe we can have `shlex` option to force lexing like we do now? So:
`run "sleep 10" with shlex` would run `["/bin/sleep", "10"]` rather than `["/bin/sh", "-c", "sleep 10"]` and `run "true" with shlex` would run `["/bin/true"]`
hinshun: Yeah that works for me.

Patch:
diff --git a/LANGSERVER.md b/LANGSERVER.md
deleted file mode 100644
index 5d6d4a6..0000000
--- a/LANGSERVER.md
+++ /dev/null
@@ -1,54 +0,0 @@
-hlb langserver
-==============
-
-Language server for [hlb](https://github.com/openllb/hlb) speaking [LSP](https://github.com/Microsoft/language-server-protocol).
-
-Capabilities
-------------
-
-| Capability            | Support |
-|-----------------------|---------|
-| Hover                 |    ✔    |
-| Jump to definition    |    ✔    |
-| Find references       |         |
-| Completion            |         |
-| Workspace symbols     |         |
-| Semantic highlighting |    ✔    |
-
-Installation
-------------
-
-To build and install the `hlb langserver` run:
-
-```sh
-go get -u github.com/openllb/hlb/cmd/hlb
-```
-
-Usage
------
-
-Kakoune ([kak-lsp](https://github.com/ul/kak-lsp/))
-```toml
-[language.hlb]
-filetypes = ["hlb"]
-roots = [".git", ".hg"]
-command = "hlb-langserver"
-offset_encoding = "utf-8"
-
-[semantic_scopes]
-# Map textmate scopes to kakoune faces for semantic highlighting
-# the underscores are translated to dots, and indicate nesting.
-# That is, if variable_other_field is omitted, it will try the face for
-# variable_other and then variable
-#
-# To see a list of available scopes in the debug buffer, run lsp-semantic-available-scopes
-string="string"
-constant="value"
-variable="variable"
-keyword="keyword"
-storage_modifier="type"
-storage_type="type"
-entity_name_function="function"
-entity_name_namespace="module"
-comment="comment"
-```
diff --git a/builtin/builtin.go b/builtin/builtin.go
index 734aacd..fd60852 100644
--- a/builtin/builtin.go
+++ b/builtin/builtin.go
@@ -248,6 +248,22 @@ var (
 					},
 				},
 			},
+			"option::localRun": LookupByType{
+				Func: map[string]FuncLookup{
+					"ignoreError": FuncLookup{
+						Params: []*parser.Field{},
+					},
+					"includeStderr": FuncLookup{
+						Params: []*parser.Field{},
+					},
+					"onlyStderr": FuncLookup{
+						Params: []*parser.Field{},
+					},
+					"shlex": FuncLookup{
+						Params: []*parser.Field{},
+					},
+				},
+			},
 			"option::mkdir": LookupByType{
 				Func: map[string]FuncLookup{
 					"createParents": FuncLookup{
@@ -344,6 +360,9 @@ var (
 							parser.NewField(parser.Str, "securitymode", false),
 						},
 					},
+					"shlex": FuncLookup{
+						Params: []*parser.Field{},
+					},
 					"host": FuncLookup{
 						Params: []*parser.Field{
 							parser.NewField(parser.Str, "hostname", false),
@@ -463,6 +482,12 @@ var (
 					"localOs": FuncLookup{
 						Params: []*parser.Field{},
 					},
+					"localRun": FuncLookup{
+						Params: []*parser.Field{
+							parser.NewField(parser.Str, "command", false),
+							parser.NewField(parser.Str, "args", true),
+						},
+					},
 					"template": FuncLookup{
 						Params: []*parser.Field{
 							parser.NewField(parser.Str, "text", false),
diff --git a/codegen/chain.go b/codegen/chain.go
index 6c0b360..c8302c1 100644
--- a/codegen/chain.go
+++ b/codegen/chain.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"fmt"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"strings"
 	"text/template"
@@ -125,6 +126,39 @@ func (cg *CodeGen) EmitFilesystemChainStmt(ctx context.Context, scope *parser.Sc
 
 	return fc, nil
 }
+
+func (cg *CodeGen) EmitShellCommand(ctx context.Context, scope *parser.Scope, args []*parser.Expr, wantShlex bool) ([]string, error) {
+	if len(args) == 0 {
+		return nil, nil
+	}
+
+	if len(args) == 1 {
+		commandStr, err := cg.EmitStringExpr(ctx, scope, args[0])
+		if err != nil {
+			return nil, err
+		}
+
+		if wantShlex {
+			parts, err := shellquote.Split(commandStr)
+			if err != nil {
+				return nil, err
+			}
+			return parts, nil
+		}
+
+		return []string{"/bin/sh", "-c", commandStr}, nil
+	}
+	var runArgs []string
+	for _, arg := range args {
+		runArg, err := cg.EmitStringExpr(ctx, scope, arg)
+		if err != nil {
+			return nil, err
+		}
+		runArgs = append(runArgs, runArg)
+	}
+	return runArgs, nil
+}
+
 func (cg *CodeGen) EmitFilesystemBuiltinChainStmt(ctx context.Context, scope *parser.Scope, expr *parser.Expr, args []*parser.Expr, with *parser.WithOpt, ac aliasCallback, chainStart interface{}) (fc FilesystemChain, err error) {
 	var iopts []interface{}
 	if with != nil {
@@ -287,39 +321,20 @@ func (cg *CodeGen) EmitFilesystemBuiltinChainStmt(ctx context.Context, scope *pa
 			}), nil
 		}
 	case "run":
-		var shlex string
-		if len(args) == 1 {
-			commandStr, err := cg.EmitStringExpr(ctx, scope, args[0])
-			if err != nil {
-				return fc, err
-			}
-
-			parts, err := shellquote.Split(commandStr)
-			if err != nil {
-				return fc, err
-			}
-
-			if len(parts) == 1 {
-				shlex = commandStr
-			} else {
-				shlex = shellquote.Join("/bin/sh", "-c", commandStr)
-			}
-		} else {
-			var runArgs []string
-			for _, arg := range args {
-				runArg, err := cg.EmitStringExpr(ctx, scope, arg)
-				if err != nil {
-					return fc, err
-				}
-				runArgs = append(runArgs, runArg)
+		wantShlex := false
+		var opts []llb.RunOption
+		for _, iopt := range iopts {
+			switch opt := iopt.(type) {
+			case llb.RunOption:
+				opts = append(opts, opt)
+			case *shlexOption:
+				wantShlex = true
 			}
-			shlex = shellquote.Join(runArgs...)
 		}
 
-		var opts []llb.RunOption
-		for _, iopt := range iopts {
-			opt := iopt.(llb.RunOption)
-			opts = append(opts, opt)
+		cmd, err := cg.EmitShellCommand(ctx, scope, args, wantShlex)
+		if err != nil {
+			return fc, err
 		}
 
 		var targets []string
@@ -351,8 +366,8 @@ func (cg *CodeGen) EmitFilesystemBuiltinChainStmt(ctx context.Context, scope *pa
 			}
 		}
 
-		customName := strings.ReplaceAll(shlex, "\n", "")
-		opts = append(opts, llb.Shlex(shlex), llb.WithCustomName(customName))
+		customName := strings.ReplaceAll(shellquote.Join(cmd...), "\n", "")
+		opts = append(opts, llb.Args(cmd), llb.WithCustomName(customName))
 
 		err = fixReadonlyMounts(opts)
 		if err != nil {
@@ -829,6 +844,46 @@ func (cg *CodeGen) EmitStringChainStmt(ctx context.Context, scope *parser.Scope,
 		return func(_ string) (string, error) {
 			return local.Os(ctx), nil
 		}, nil
+	case "localRun":
+		wantShlex := false
+		execOpts := &LocalRunOptions{}
+		for _, iopt := range iopts {
+			switch opt := iopt.(type) {
+			case func(*LocalRunOptions):
+				opt(execOpts)
+			case *shlexOption:
+				wantShlex = true
+			}
+		}
+
+		cmd, err := cg.EmitShellCommand(ctx, scope, args, wantShlex)
+		if err != nil {
+			return nil, err
+		}
+
+		c := exec.CommandContext(ctx, cmd[0], cmd[1:]...)
+		c.Env = local.Environ(ctx)
+		c.Dir, err = local.Cwd(ctx)
+		if err != nil {
+			return nil, err
+		}
+
+		var buf strings.Builder
+		if execOpts.OnlyStderr {
+			c.Stderr = &buf
+		} else {
+			c.Stdout = &buf
+		}
+		if execOpts.IncludeStderr {
+			c.Stderr = &buf
+		}
+		err = c.Run()
+		if err != nil && !execOpts.IgnoreError {
+			return nil, err
+		}
+		return func(_ string) (string, error) {
+			return buf.String(), nil
+		}, nil
 	case "template":
 		text, err := cg.EmitStringExpr(ctx, scope, args[0])
 		if err != nil {
diff --git a/codegen/codegen.go b/codegen/codegen.go
index f31e9ce..eec6ca9 100644
--- a/codegen/codegen.go
+++ b/codegen/codegen.go
@@ -451,6 +451,8 @@ func (cg *CodeGen) EmitOptionBlock(ctx context.Context, scope *parser.Scope, op
 		return cg.EmitCopyOptions(ctx, scope, op, stmts)
 	case "template":
 		return cg.EmitTemplateOptions(ctx, scope, op, stmts)
+	case "localRun":
+		return cg.EmitLocalExecOptions(ctx, scope, op, stmts)
 	default:
 		return opts, errors.Errorf("call stmt does not support options: %s", op)
 	}
@@ -941,6 +943,45 @@ func (cg *CodeGen) EmitTemplateOptions(ctx context.Context, scope *parser.Scope,
 	return opts, nil
 }
 
+type LocalRunOptions struct {
+	IgnoreError   bool
+	OnlyStderr    bool
+	IncludeStderr bool
+}
+
+func (cg *CodeGen) EmitLocalExecOptions(ctx context.Context, scope *parser.Scope, op string, stmts []*parser.Stmt) (opts []interface{}, err error) {
+	for _, stmt := range stmts {
+		if stmt.Call != nil {
+			args := stmt.Call.Args
+			switch stmt.Call.Func.Name() {
+			case "ignoreError":
+				opts = append(opts, func(o *LocalRunOptions) {
+					o.IgnoreError = true
+				})
+			case "onlyStderr":
+				opts = append(opts, func(o *LocalRunOptions) {
+					o.OnlyStderr = true
+				})
+			case "includeStderr":
+				opts = append(opts, func(o *LocalRunOptions) {
+					o.IncludeStderr = true
+				})
+			case "shlex":
+				opts = append(opts, &shlexOption{})
+			default:
+				iopts, err := cg.EmitOptionLookup(ctx, scope, stmt.Call.Func, args, op)
+				if err != nil {
+					return opts, err
+				}
+				opts = append(opts, iopts...)
+			}
+		}
+	}
+	return opts, nil
+}
+
+type shlexOption struct{}
+
 func (cg *CodeGen) EmitExecOptions(ctx context.Context, scope *parser.Scope, op string, stmts []*parser.Stmt, ac aliasCallback) (opts []interface{}, err error) {
 	for _, stmt := range stmts {
 		if stmt.Call != nil {
@@ -1032,6 +1073,8 @@ func (cg *CodeGen) EmitExecOptions(ctx context.Context, scope *parser.Scope, op
 				}
 
 				opts = append(opts, llb.Security(securityMode))
+			case "shlex":
+				opts = append(opts, &shlexOption{})
 			case "host":
 				host, err := cg.EmitStringExpr(ctx, scope, args[0])
 				if err != nil {
diff --git a/docs/reference.md b/docs/reference.md
index f6ba1af..2ffdb2b 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -469,14 +469,12 @@ Allows wildcards in the path to remove.
 ### <span class='hlb-type'>fs</span> <span class='hlb-name'>run</span>(<span class='hlb-type'>string</span> <span class='hlb-variable'>arg</span>)
 
 !!! info "<span class='hlb-type'>string</span> <span class='hlb-variable'>arg</span>"
-	a command to execute.
+	are optional arguments to execute.
 
 Executes an command in the current filesystem.
 If no arguments are given, it will execute the current args set on the
 filesystem.
-If exactly one arg is given, it will attempt to parse as a command with
-shell-style quoting. If it remains a single element, it is executed directly,
-otherwise it is run with the current shell.
+If exactly one arg is given it will be wrapped with /bin/sh -c &#x27;arg&#x27;.
 If more than one arg is given, it will be executed directly, without a shell.
 
 	#!hlb
@@ -492,6 +490,7 @@ If more than one arg is given, it will be executed directly, without a shell.
 			readonlyRootfs
 			secret "localPath" "mountPoint"
 			security "securitymode"
+			shlex
 			ssh
 			user "name"
 		}
@@ -581,6 +580,12 @@ attached via a tmpfs mount, so all the data stays in volatile memory.
 Sets the security mode for the duration of the run command. By default, the
 value is &#x60;sandbox&#x60;.
 
+#### <span class='hlb-type'>option::run</span> <span class='hlb-name'>shlex</span>()
+
+
+Attempt to lex the single-argument shell command provided to &#x60;run&#x60;
+to determine if a &#x60;/bin/sh -c &#x27;...&#x27;&#x60; wrapper needs to be added.
+
 #### <span class='hlb-type'>option::run</span> <span class='hlb-name'>ssh</span>()
 
 
@@ -705,6 +710,51 @@ The OS from the clients local environment.
 
 
 
+### <span class='hlb-type'>string</span> <span class='hlb-name'>localRun</span>(<span class='hlb-type'>string</span> <span class='hlb-variable'>command</span>, <span class='hlb-type'>string</span> <span class='hlb-variable'>args</span>)
+
+!!! info "<span class='hlb-type'>string</span> <span class='hlb-variable'>command</span>"
+	a command to execute.
+!!! info "<span class='hlb-type'>string</span> <span class='hlb-variable'>args</span>"
+	optional arguments to the command.
+
+Executes an command in the local environment.
+If exactly one arg is given it will be wrapped with /bin/sh -c &#x27;arg&#x27;.
+If more than one arg is given, it will be executed directly, without a shell.
+
+	#!hlb
+	string myString() {
+		localRun "command" "args" with option {
+			ignoreError
+			includeStderr
+			onlyStderr
+			shlex
+		}
+	}
+
+
+#### <span class='hlb-type'>option::localRun</span> <span class='hlb-name'>ignoreError</span>()
+
+
+If the command returns a non-zero status code ignore
+the failure and continue processing the hlb file.
+
+#### <span class='hlb-type'>option::localRun</span> <span class='hlb-name'>includeStderr</span>()
+
+
+Capture stderr intermixed with stdout on the command.
+
+#### <span class='hlb-type'>option::localRun</span> <span class='hlb-name'>onlyStderr</span>()
+
+
+Only capture the stderr from the command, ignore stdout.
+
+#### <span class='hlb-type'>option::localRun</span> <span class='hlb-name'>shlex</span>()
+
+
+Attempt to lex the single-argument shell command provided to &#x60;localRun&#x60;
+to determine if a &#x60;/bin/sh -c &#x27;...&#x27;&#x60; wrapper needs to be added.
+
+
 ### <span class='hlb-type'>string</span> <span class='hlb-name'>template</span>(<span class='hlb-type'>string</span> <span class='hlb-variable'>text</span>)
 
 !!! info "<span class='hlb-type'>string</span> <span class='hlb-variable'>text</span>"
diff --git a/language/builtin.hlb b/language/builtin.hlb
index 580b001..e05b66a 100644
--- a/language/builtin.hlb
+++ b/language/builtin.hlb
@@ -113,12 +113,10 @@ fs shell(variadic string arg)
 #
 # If no arguments are given, it will execute the current args set on the
 # filesystem.
-# If exactly one arg is given, it will attempt to parse as a command with
-# shell-style quoting. If it remains a single element, it is executed directly,
-# otherwise it is run with the current shell.
+# If exactly one arg is given it will be wrapped with /bin/sh -c 'arg'.
 # If more than one arg is given, it will be executed directly, without a shell.
 #
-# @param arg a command to execute.
+# @param arg are optional arguments to execute.
 # @return the filesystem after the command has executed.
 fs run(variadic string arg)
 
@@ -171,6 +169,12 @@ option::run network(string networkmode)
 # - insecure: enables all capabilities.
 option::run security(string securitymode)
 
+# Attempt to lex the single-argument shell command provided to `run`
+# to determine if a `/bin/sh -c '...'` wrapper needs to be added.
+#
+# @return an option to attempt to optimize the command execution remoiving the /bin/sh -c "..." wrapper when possible.
+option::run shlex()
+
 # Adds a host entry to /etc/hosts for the duration of the run command.
 #
 # @param hostname the host name of the entry, may include spaces to delimit
@@ -533,6 +537,38 @@ string localEnv(string key)
 # @return the OS
 string localOs()
 
+# Executes an command in the local environment.
+#
+# If exactly one arg is given it will be wrapped with /bin/sh -c 'arg'.
+# If more than one arg is given, it will be executed directly, without a shell.
+#
+# @param command a command to execute.
+# @param args optional arguments to the command.
+# @return the string output from the command
+string localRun(string command, variadic string args)
+
+# If the command returns a non-zero status code ignore
+# the failure and continue processing the hlb file.
+#
+# @return an option to ignore errors on the command
+option::localRun ignoreError()
+
+# Capture stderr intermixed with stdout on the command.
+#
+# @return an option to capture stderr along with stdout on the command.
+option::localRun includeStderr()
+
+# Only capture the stderr from the command, ignore stdout.
+#
+# @return an option to ignore stdout on the command
+option::localRun onlyStderr()
+
+# Attempt to lex the single-argument shell command provided to `localRun`
+# to determine if a `/bin/sh -c '...'` wrapper needs to be added.
+#
+# @return an option to attempt to optimize the command execution remoiving the /bin/sh -c "..." wrapper when possible.
+option::localRun shlex()
+
 # Process text as a Go text template.
 # For template syntax documentation see:
 #   https://golang.org/pkg/text/template/
diff --git a/local/environment.go b/local/environment.go
index 76cccac..148cf5a 100644
--- a/local/environment.go
+++ b/local/environment.go
@@ -65,6 +65,13 @@ func Env(ctx context.Context, key string) string {
 	return os.Getenv(key)
 }
 
+func Environ(ctx context.Context) []string {
+	if environ, ok := ctx.Value(environContextKey).([]string); ok {
+		return environ
+	}
+	return os.Environ()
+}
+
 func Cwd(ctx context.Context) (string, error) {
 	if workdir, ok := ctx.Value(cwdContextKey).(string); ok {
 		return workdir, nil
diff --git a/parser/cst.go b/parser/cst.go
index 357bc31..2cd90ca 100644
--- a/parser/cst.go
+++ b/parser/cst.go
@@ -20,7 +20,7 @@ var (
 		HereDoc = <<[-~]?
 		Keyword  = \b(with|as|import|export|from)\b
 		Modifier = \b(variadic)\b
-		Type     = \b(string|int|bool|fs|option|group)(::[a-z][a-z]*)?\b
+		Type     = \b(string|int|bool|fs|option|group)(::[a-z][a-zA-Z]*)?\b
 		Numeric  = \b(0(b|B|o|O|x|X)[a-fA-F0-9]+)\b
 		Decimal  = \b(0|[1-9][0-9]*)\b
 		String   = "(\\.|[^"])*"|'[^']*'
diff --git a/solver/request.go b/solver/request.go
index 5dabed6..2176d14 100644
--- a/solver/request.go
+++ b/solver/request.go
@@ -121,12 +121,7 @@ func (o op) Tree(tree treeprint.Tree) error {
 		branch = tree.AddMetaBranch("source", v.Source)
 	case *pb.Op_Exec:
 		meta := v.Exec.Meta
-		cmd := ""
-		if len(meta.Args) == 3 && meta.Args[0] == "/bin/sh" && meta.Args[1] == "-c" {
-			cmd = meta.Args[2]
-		} else {
-			cmd = shellquote.Join(meta.Args...)
-		}
+		cmd := shellquote.Join(meta.Args...)
 		if o.meta[o.dgst].IgnoreCache {
 			cmd += " [ignoreCache]"
 		}


Test patch:
diff --git a/codegen/codegen_test.go b/codegen/codegen_test.go
index d71e228..06e8286 100644
--- a/codegen/codegen_test.go
+++ b/codegen/codegen_test.go
@@ -2,11 +2,13 @@ package codegen
 
 import (
 	"context"
+	"fmt"
 	"os"
 	"strings"
 	"testing"
 	"time"
 
+	"github.com/lithammer/dedent"
 	"github.com/moby/buildkit/client/llb"
 	"github.com/moby/buildkit/solver/pb"
 	"github.com/moby/buildkit/util/entitlements"
@@ -35,10 +37,7 @@ type testCase struct {
 }
 
 func cleanup(value string) string {
-	result := strings.TrimSpace(value)
-	result = strings.ReplaceAll(result, strings.Repeat("\t", 3), "")
-	result = strings.ReplaceAll(result, "|\n", "| \n")
-	return result
+	return dedent.Dedent(value)
 }
 
 func TestCodeGen(t *testing.T) {
@@ -166,7 +165,7 @@ func TestCodeGen(t *testing.T) {
 		fs default() {
 			scratch
 			env "TEST_VAR" "test value"
-			run "echo Hello"
+			run "echo Hello" with shlex
 		}
 		`,
 		func(t *testing.T, cg *CodeGen) solver.Request {
@@ -179,7 +178,7 @@ func TestCodeGen(t *testing.T) {
 		fs default() {
 			scratch
 			dir "testDir"
-			run "echo Hello"
+			run "echo Hello" with shlex
 		}
 		`,
 		func(t *testing.T, cg *CodeGen) solver.Request {
@@ -192,7 +191,7 @@ func TestCodeGen(t *testing.T) {
 		fs default() {
 			scratch
 			user "testUser"
-			run "echo Hello"
+			run "echo Hello" with shlex
 		}
 		`,
 		func(t *testing.T, cg *CodeGen) solver.Request {
@@ -373,6 +372,7 @@ func TestCodeGen(t *testing.T) {
 			image "alpine"
 			run "touch /out/foo" with option {
 				mount scratch "/out" as default
+				shlex
 			}
 		}
 		`,
@@ -531,11 +531,11 @@ func TestCodeGen(t *testing.T) {
 		`,
 		func(t *testing.T, cg *CodeGen) solver.Request {
 			return Expect(t, llb.Image("busybox").Run(
-				llb.Shlex("echo hi"),
+				llb.Args([]string{"/bin/sh", "-c", "echo hi"}),
 			).Run(
-				llb.Shlex("echo hi"),
+				llb.Args([]string{"/bin/sh", "-c", "echo hi"}),
 			).Run(
-				llb.Shlex("\techo hi"),
+				llb.Args([]string{"/bin/sh", "-c", "\techo hi"}),
 			).Root())
 		},
 	}, {
@@ -554,7 +554,7 @@ func TestCodeGen(t *testing.T) {
 
 		fs default() {
 			image "busybox"
-			run cmd 
+			run cmd with shlex
 		}
 		`,
 		func(t *testing.T, cg *CodeGen) solver.Request {
@@ -577,7 +577,7 @@ func TestCodeGen(t *testing.T) {
 		func(t *testing.T, cg *CodeGen) solver.Request {
 			return Expect(t,
 				llb.Image("busybox").Run(
-					llb.Shlex("entitlements"),
+					llb.Args([]string{"/bin/sh", "-c", "entitlements"}),
 					llb.Network(pb.NetMode_HOST),
 					llb.Security(pb.SecurityMode_INSECURE),
 				).Root(),
@@ -592,6 +592,7 @@ func TestCodeGen(t *testing.T) {
 		fs default() {
 			image "busybox"
 			run "find ." with option {
+				shlex
 				dir "/foo"
 				mount fs {
 					local "."
@@ -661,6 +662,48 @@ func TestCodeGen(t *testing.T) {
 				}),
 			))
 		},
+	}, {
+		"localRun",
+		[]string{"default"},
+		`
+		fs default() {
+			mkfile "./just-stdout" 0o644 string {
+				localRun "echo stdout; echo stderr >&2"
+			}
+			mkfile "./just-stderr" 0o644 string {
+				localRun "echo stdout; echo stderr >&2" with onlyStderr
+			}
+			mkfile "./stdio" 0o644 string {
+				localRun "echo stdout; echo stderr >&2" with includeStderr
+			}
+			mkfile "./goterror" 0o644 string {
+				localRun "echo stdout; exit 1" with ignoreError
+			}
+			# this will write evaluate the $HOME env var because run as ["/bin/sh", "-c", "echo $HOME"]
+			mkfile "./noshlex" 0o644 string {
+				localRun "echo $HOME"
+			}
+			# this will write the literal string "$HOME" because run as ["/bin/echo", "$HOME"]
+			mkfile "./shlex" 0o644 string {
+				localRun "echo $HOME" with shlex
+			}
+		}
+		`,
+		func(t *testing.T, cg *CodeGen) solver.Request {
+			return Expect(t, llb.Scratch().File(
+				llb.Mkfile("just-stdout", os.FileMode(0644), []byte("stdout\n")),
+			).File(
+				llb.Mkfile("just-stderr", os.FileMode(0644), []byte("stderr\n")),
+			).File(
+				llb.Mkfile("stdio", os.FileMode(0644), []byte("stdout\nstderr\n")),
+			).File(
+				llb.Mkfile("goterror", os.FileMode(0644), []byte("stdout\n")),
+			).File(
+				llb.Mkfile("noshlex", os.FileMode(0644), []byte(fmt.Sprintf("%s\n", os.Getenv("HOME")))),
+			).File(
+				llb.Mkfile("shlex", os.FileMode(0644), []byte("$HOME\n")),
+			))
+		},
 	}} {
 		tc := tc
 		t.Run(tc.name, func(t *testing.T) {