diff --git a/CHANGELOG.md b/CHANGELOG.md index a81210e2..346d429f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,11 +22,17 @@ The format is based on [Keep a Changelog][], and this project adheres to error if the targets have a dependency cycle. - yb attempts to detect some common Docker configuration issues and inform the user about them. +- yb now obeys the [`NO_COLOR` environment variable][] and propagates it to the + build environment. + +[`NO_COLOR` environment variable]: https://no-color.org/ ### Changed - Commands run as part of `build`, `exec`, or `run` now run without Docker by default. You can get the old behavior by running with `--mode=container`. +- Tool output has been changed to be more compact, to be easier to trace + command output, and to include more timing information. - `yb platform` is now an alias for `yb version`. ### Fixed diff --git a/cmd/yb/build.go b/cmd/yb/build.go index 732d91db..e92ced30 100644 --- a/cmd/yb/build.go +++ b/cmd/yb/build.go @@ -3,7 +3,7 @@ package main import ( "context" "fmt" - "io/ioutil" + "io" "os" "strings" "time" @@ -98,6 +98,7 @@ func (b *buildCmd) run(ctx context.Context) error { startTime := time.Now() ctx, span := ybtrace.Start(ctx, "Build", trace.WithNewRoot()) defer span.End() + ctx = withStdoutLogs(ctx) log.Infof(ctx, "Build started at %s", startTime.Format(longTimeFormat)) @@ -117,10 +118,10 @@ func (b *buildCmd) run(ctx context.Context) error { showDockerWarningsIfNeeded(ctx, b.mode, buildTargets) // Do the build! - startSection("BUILD") log.Debugf(ctx, "Building package %s in %s...", targetPackage.Name, targetPackage.Path) buildError := doTargetList(ctx, targetPackage, buildTargets, &doOptions{ + output: os.Stdout, executionMode: b.mode, dockerClient: dockerClient, dataDirs: dataDirs, @@ -132,27 +133,27 @@ func (b *buildCmd) run(ctx context.Context) error { }) if buildError != nil { span.SetStatus(codes.Unknown, buildError.Error()) + log.Errorf(ctx, "%v", buildError) } span.End() endTime := time.Now() buildTime := endTime.Sub(startTime) - log.Infof(ctx, "") - log.Infof(ctx, "Build finished at %s, taking %s", endTime.Format(longTimeFormat), buildTime) - log.Infof(ctx, "") - - log.Infof(ctx, "%s", buildTraces.dump()) + fmt.Printf("\nBuild finished at %s, taking %v\n\n", endTime.Format(longTimeFormat), buildTime.Truncate(time.Millisecond)) + fmt.Println(buildTraces.dump()) + style := termStylesFromEnv() if buildError != nil { - subSection("BUILD FAILED") - return buildError + fmt.Printf("%sBUILD FAILED%s ❌\n", style.buildResult(false), style.reset()) + return alreadyLoggedError{buildError} } - subSection("BUILD SUCCEEDED") + fmt.Printf("%sBUILD PASSED%s ️✔️\n", style.buildResult(true), style.reset()) return nil } type doOptions struct { + output io.Writer dataDirs *ybdata.Dirs downloader *ybdata.Downloader executionMode executionMode @@ -198,6 +199,11 @@ func doTargetList(ctx context.Context, pkg *yb.Package, targets []*yb.Target, op } func doTarget(ctx context.Context, pkg *yb.Package, target *yb.Target, opts *doOptions) error { + style := termStylesFromEnv() + fmt.Printf("\n🎯 %sTarget: %s%s\n", style.target(), target.Name, style.reset()) + + ctx = withLogPrefix(ctx, target.Name) + bio, err := newBiome(ctx, target, newBiomeOptions{ packageDir: pkg.Path, dataDirs: opts.dataDirs, @@ -216,15 +222,17 @@ func doTarget(ctx context.Context, pkg *yb.Package, target *yb.Target, opts *doO log.Warnf(ctx, "Clean up environment: %v", err) } }() + output := newLinePrefixWriter(opts.output, target.Name) sys := build.Sys{ Biome: bio, Downloader: opts.downloader, DockerClient: opts.dockerClient, DockerNetworkID: opts.dockerNetworkID, - Stdout: os.Stdout, - Stderr: os.Stderr, + + Stdout: output, + Stderr: output, } - execBiome, err := build.Setup(ctx, sys, target) + execBiome, err := build.Setup(withLogPrefix(ctx, setupLogPrefix), sys, target) if err != nil { return err } @@ -241,34 +249,15 @@ func doTarget(ctx context.Context, pkg *yb.Package, target *yb.Target, opts *doO return nil } - subSection(fmt.Sprintf("Build target: %s", target.Name)) - log.Infof(ctx, "Executing build steps...") - return build.Execute(ctx, sys, target) + return build.Execute(withStdoutLogs(ctx), sys, announceCommand, target) } -// Because, why not? -// Based on https://github.com/sindresorhus/is-docker/blob/master/index.js and https://github.com/moby/moby/issues/18355 -// Discussion is not settled yet: https://stackoverflow.com/questions/23513045/how-to-check-if-a-process-is-running-inside-docker-container#25518538 -func insideTheMatrix() bool { - hasDockerEnv := pathExists("/.dockerenv") - hasDockerCGroup := false - dockerCGroupPath := "/proc/self/cgroup" - if pathExists(dockerCGroupPath) { - contents, _ := ioutil.ReadFile(dockerCGroupPath) - hasDockerCGroup = strings.Count(string(contents), "docker") > 0 - } - return hasDockerEnv || hasDockerCGroup +func announceCommand(cmdString string) { + style := termStylesFromEnv() + fmt.Printf("%s> %s%s\n", style.command(), cmdString, style.reset()) } func pathExists(path string) bool { _, err := os.Lstat(path) return !os.IsNotExist(err) } - -func startSection(name string) { - fmt.Printf(" === %s ===\n", name) -} - -func subSection(name string) { - fmt.Printf(" -- %s -- \n", name) -} diff --git a/cmd/yb/exec.go b/cmd/yb/exec.go index c9bd6a7a..ee43cffb 100644 --- a/cmd/yb/exec.go +++ b/cmd/yb/exec.go @@ -99,7 +99,7 @@ func (b *execCmd) run(ctx context.Context) error { Stdout: os.Stdout, Stderr: os.Stderr, } - execBiome, err := build.Setup(ctx, sys, execTarget) + execBiome, err := build.Setup(withLogPrefix(ctx, execTarget.Name+setupLogPrefix), sys, execTarget) if err != nil { return err } @@ -109,5 +109,5 @@ func (b *execCmd) run(ctx context.Context) error { log.Errorf(ctx, "Clean up environment %s: %v", b.execEnvName, err) } }() - return build.Execute(ctx, sys, execTarget) + return build.Execute(ctx, sys, announceCommand, execTarget) } diff --git a/cmd/yb/main.go b/cmd/yb/main.go index a3e8538c..fa9e68fc 100644 --- a/cmd/yb/main.go +++ b/cmd/yb/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -87,12 +88,28 @@ func main() { err = rootCmd.ExecuteContext(ctx) cancel() if err != nil { - initLog(cfg, false) - log.Errorf(ctx, "%v", err) + if !errors.As(err, new(alreadyLoggedError)) { + initLog(cfg, false) + log.Errorf(ctx, "%v", err) + } os.Exit(1) } } +// alreadyLoggedError wraps another error to signal that it has already been +// shown to the user. If returned from a subcommand, then main() will not log it. +type alreadyLoggedError struct { + err error +} + +func (e alreadyLoggedError) Error() string { + return e.err.Error() +} + +func (e alreadyLoggedError) Unwrap() error { + return e.err +} + func displayOldDirectoryWarning(ctx context.Context) { home := os.Getenv("HOME") if home == "" { diff --git a/cmd/yb/output.go b/cmd/yb/output.go index 5c00e35c..07e270b2 100644 --- a/cmd/yb/output.go +++ b/cmd/yb/output.go @@ -17,26 +17,32 @@ package main import ( + "bytes" "context" "fmt" "io" "os" - "strconv" "strings" "sync" + "time" - "github.com/yourbase/commons/envvar" "github.com/yourbase/yb/internal/config" "go.opentelemetry.io/otel/api/trace" exporttrace "go.opentelemetry.io/otel/sdk/export/trace" "zombiezen.com/go/log" ) -const longTimeFormat = "15:04:05 MST" +const ( + shortTimeFormat = "15:04:05" + longTimeFormat = "15:04:05 MST" +) + +// setupLogPrefix is the log prefix used with withLogPrefix when running a +// target's setup. +const setupLogPrefix = ".deps" type logger struct { - color bool - showLevels bool + color termStyles mu sync.Mutex buf []byte @@ -49,50 +55,49 @@ func initLog(cfg config.Getter, showDebug bool) { log.SetDefault(&log.LevelFilter{ Min: configuredLogLevel(cfg, showDebug), Output: &logger{ - color: colorLogs(), - showLevels: showLogLevels(cfg), + color: termStylesFromEnv(), }, }) }) } func (l *logger) Log(ctx context.Context, entry log.Entry) { + logToStdout, _ := ctx.Value(logToStdoutKey{}).(bool) + logToStdout = logToStdout && entry.Level < log.Warn + var colorSequence string + switch { + case entry.Level < log.Info: + colorSequence = l.color.debug() + case entry.Level >= log.Error: + colorSequence = l.color.failure() + } + prefix, _ := ctx.Value(logPrefixKey{}).(string) + l.mu.Lock() defer l.mu.Unlock() l.buf = l.buf[:0] - if l.showLevels { - if l.color { - switch { - case entry.Level >= log.Error: - // Red text - l.buf = append(l.buf, "\x1b[31m"...) - case entry.Level >= log.Warn: - // Yellow text - l.buf = append(l.buf, "\x1b[33m"...) - default: - // Cyan text - l.buf = append(l.buf, "\x1b[36m"...) - } - } + l.buf = append(l.buf, colorSequence...) + for _, line := range strings.Split(strings.TrimSuffix(entry.Msg, "\n"), "\n") { + l.buf = appendLogPrefix(l.buf, entry.Time, prefix) switch { case entry.Level >= log.Error: - l.buf = append(l.buf, "ERROR"...) + l.buf = append(l.buf, "❌ "...) case entry.Level >= log.Warn: - l.buf = append(l.buf, "WARN"...) - case entry.Level >= log.Info: - l.buf = append(l.buf, "INFO"...) - default: - l.buf = append(l.buf, "DEBUG"...) + l.buf = append(l.buf, "⚠️ "...) } - if l.color { - l.buf = append(l.buf, "\x1b[0m"...) - } - l.buf = append(l.buf, ' ') + l.buf = append(l.buf, line...) + l.buf = append(l.buf, '\n') + } + if colorSequence != "" { + l.buf = append(l.buf, l.color.reset()...) + } + + out := os.Stderr + if logToStdout { + out = os.Stdout } - l.buf = append(l.buf, entry.Msg...) - l.buf = append(l.buf, '\n') - os.Stderr.Write(l.buf) + out.Write(l.buf) } func (l *logger) LogEnabled(entry log.Entry) bool { @@ -115,18 +120,83 @@ func configuredLogLevel(cfg config.Getter, showDebug bool) log.Level { return log.Info } -func colorLogs() bool { - b, _ := strconv.ParseBool(envvar.Get("CLICOLOR", "1")) - return b +type logToStdoutKey struct{} + +func withStdoutLogs(parent context.Context) context.Context { + if isPresent, _ := parent.Value(logToStdoutKey{}).(bool); isPresent { + return parent + } + return context.WithValue(parent, logToStdoutKey{}, true) +} + +type logPrefixKey struct{} + +func withLogPrefix(parent context.Context, prefix string) context.Context { + parentPrefix, _ := parent.Value(logPrefixKey{}).(string) + return context.WithValue(parent, logPrefixKey{}, parentPrefix+prefix) +} + +// linePrefixWriter prepends a timestamp and a prefix string to every line +// written to it and writes to an underlying writer. +type linePrefixWriter struct { + dst io.Writer + prefix string + buf []byte + wrote bool + now func() time.Time +} + +func newLinePrefixWriter(w io.Writer, prefix string) *linePrefixWriter { + return &linePrefixWriter{ + dst: w, + prefix: prefix, + now: time.Now, + } +} + +// Write writes p to the underlying writer, inserting line prefixes as required. +// Write will issue at most one Write call per line on the underlying writer. +func (lp *linePrefixWriter) Write(p []byte) (int, error) { + now := lp.now() + origLen := len(p) + for next := []byte(nil); len(p) > 0; p = next { + line := p + next = nil + lineEnd := bytes.IndexByte(p, '\n') + if lineEnd != -1 { + line, next = p[:lineEnd+1], p[lineEnd+1:] + } + if lp.wrote { + // Pass through rest of line if we already wrote the prefix. + n, err := lp.dst.Write(line) + lp.wrote = lineEnd == -1 + if err != nil { + return origLen - (len(p) + n), err + } + continue + } + lp.buf = appendLogPrefix(lp.buf[:0], now, lp.prefix) + lp.buf = append(lp.buf, line...) + n, err := lp.dst.Write(lp.buf) + lp.wrote = n > 0 && (n < len(lp.buf) || lineEnd == -1) + if err != nil { + n -= len(lp.buf) - len(line) // exclude prefix length + return origLen - (len(p) + n), err + } + } + return origLen, nil } -func showLogLevels(cfg config.Getter) bool { - out := config.Get(cfg, "defaults", "no-pretty-output") - if out != "" { - b, _ := strconv.ParseBool(out) - return !b +// appendLogPrefix formats the given timestamp and label and appends the result +// to dst. +func appendLogPrefix(dst []byte, t time.Time, prefix string) []byte { + if prefix == "" { + return dst } - return !envvar.Bool("YB_NO_PRETTY_OUTPUT") + dst = t.AppendFormat(dst, shortTimeFormat) + buf := bytes.NewBuffer(dst) + fmt.Fprintf(buf, " %-18s| ", prefix) + return buf.Bytes() } // A traceSink records spans in memory. The zero value is an empty sink. @@ -160,7 +230,7 @@ const ( // received. It is safe to call concurrently, including with ExportSpan. func (sink *traceSink) dump() string { sb := new(strings.Builder) - fmt.Fprintf(sb, "%-*s %-*s %-*s\n", + fmt.Fprintf(sb, "%-*s %-*s %*s\n", traceDumpStartWidth, "Start", traceDumpEndWidth, "End", traceDumpElapsedWidth, "Elapsed", diff --git a/cmd/yb/output_test.go b/cmd/yb/output_test.go new file mode 100644 index 00000000..ea86e8a3 --- /dev/null +++ b/cmd/yb/output_test.go @@ -0,0 +1,110 @@ +// Copyright 2021 YourBase Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "io" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +func TestLinePrefixWriter(t *testing.T) { + start := time.Date(2021, time.April, 21, 14, 10, 23, 0, time.FixedZone("America/Los_Angeles", -7*60*60)) + const defaultPrefix = "default" + const defaultPaddedPrefix = "default |" + tests := []struct { + name string + prefix string + writes []string + want string + }{ + { + name: "Empty", + prefix: defaultPrefix, + writes: []string{""}, + want: "", + }, + { + name: "PartialLine", + prefix: defaultPrefix, + writes: []string{"foo"}, + want: "14:10:23 " + defaultPaddedPrefix + " foo", + }, + { + name: "FullLine", + prefix: defaultPrefix, + writes: []string{"foo\n"}, + want: "14:10:23 " + defaultPaddedPrefix + " foo\n", + }, + { + name: "MultipleLines/OneWrite", + prefix: defaultPrefix, + writes: []string{"foo\nbar\n"}, + want: "14:10:23 " + defaultPaddedPrefix + " foo\n" + + "14:10:23 " + defaultPaddedPrefix + " bar\n", + }, + { + name: "MultipleLines/MultipleWrites", + prefix: defaultPrefix, + writes: []string{"foo\n", "bar\n"}, + want: "14:10:23 " + defaultPaddedPrefix + " foo\n" + + "14:10:24 " + defaultPaddedPrefix + " bar\n", + }, + { + name: "MultipleLines/Split", + prefix: defaultPrefix, + writes: []string{"foo\nb", "ar\n"}, + want: "14:10:23 " + defaultPaddedPrefix + " foo\n" + + "14:10:23 " + defaultPaddedPrefix + " bar\n", + }, + { + name: "RSpecDots", + prefix: defaultPrefix, + writes: []string{".", ".", ".", ".", "\n"}, + want: "14:10:23 " + defaultPaddedPrefix + " ....\n", + }, + { + name: "LongPrefix", + prefix: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + writes: []string{"foo\n"}, + want: "14:10:23 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx| foo\n", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + currTime := start + out := new(strings.Builder) + w := newLinePrefixWriter(out, test.prefix) + w.now = func() time.Time { + result := currTime + currTime = currTime.Add(1 * time.Second) + return result + } + for i, data := range test.writes { + if n, err := io.WriteString(w, data); n != len(data) || err != nil { + t.Errorf("Write[%d](%q) = %d, %v; want %d, ", i, data, n, err, len(data)) + } + } + if diff := cmp.Diff(test.want, out.String()); diff != "" { + t.Errorf("Output (-want +got):\n%s", diff) + } + }) + } +} diff --git a/cmd/yb/run.go b/cmd/yb/run.go index 5dac0c58..7639421e 100644 --- a/cmd/yb/run.go +++ b/cmd/yb/run.go @@ -82,6 +82,7 @@ func (b *runCmd) run(ctx context.Context, args []string) error { // Build dependencies before running command. err = doTargetList(ctx, pkg, targets[:len(targets)-1], &doOptions{ + output: os.Stderr, executionMode: b.mode, dockerClient: dockerClient, dockerNetworkID: dockerNetworkID, @@ -121,7 +122,7 @@ func (b *runCmd) run(ctx context.Context, args []string) error { Stdout: os.Stdout, Stderr: os.Stderr, } - execBiome, err := build.Setup(ctx, sys, execTarget) + execBiome, err := build.Setup(withLogPrefix(ctx, execTarget.Name+setupLogPrefix), sys, execTarget) if err != nil { return err } diff --git a/cmd/yb/styles.go b/cmd/yb/styles.go new file mode 100644 index 00000000..5e114e1c --- /dev/null +++ b/cmd/yb/styles.go @@ -0,0 +1,91 @@ +// Copyright 2021 YourBase Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "strconv" + + "github.com/yourbase/commons/envvar" +) + +// termStyles generates ANSI escape codes for specific styles. +// The zero value will not return any special escape codes. +// https://en.wikipedia.org/wiki/ANSI_escape_code +type termStyles bool + +func termStylesFromEnv() termStyles { + if os.Getenv("NO_COLOR") != "" { + // https://no-color.org/ + return false + } + b, _ := strconv.ParseBool(envvar.Get("CLICOLOR", "1")) + return termStyles(b) +} + +// reset returns the escape code to change the output to default settings. +func (style termStyles) reset() string { + if !style { + return "" + } + return "\x1b[0m" +} + +// target returns the escape code for formatting a target section heading. +func (style termStyles) target() string { + if !style { + return "" + } + // Bold + return "\x1b[1m" +} + +// command returns the escape code for formatting a command section heading. +func (style termStyles) command() string { + return style.target() +} + +// buildResult returns the escape code for formatting the final result message. +func (style termStyles) buildResult(success bool) string { + switch { + case !bool(style): + return "" + case !success: + return style.failure() + default: + // Bold + return "\x1b[1m" + } +} + +// failure returns the escape code for formatting error messages. +func (style termStyles) failure() string { + if !style { + return "" + } + // Red, but not bold to avoid using "bright red". + return "\x1b[31m" +} + +// debug returns the escape code for formatting debugging information. +func (style termStyles) debug() string { + if !style { + return "" + } + // Gray + return "\x1b[90m" +} diff --git a/internal/biome/biome.go b/internal/biome/biome.go index e74585dd..2326006d 100644 --- a/internal/biome/biome.go +++ b/internal/biome/biome.go @@ -183,7 +183,7 @@ func (l Local) Run(ctx context.Context, invoke *Invocation) error { if len(invoke.Argv) == 0 { return fmt.Errorf("local run: argv empty") } - log.Infof(ctx, "Run: %s", strings.Join(invoke.Argv, " ")) + log.Debugf(ctx, "Run: %s", strings.Join(invoke.Argv, " ")) log.Debugf(ctx, "Environment:\n%v", invoke.Env) dir := invoke.Dir if !filepath.IsAbs(invoke.Dir) { @@ -200,6 +200,9 @@ func (l Local) Run(ctx context.Context, invoke *Invocation) error { "LOGNAME=" + os.Getenv("LOGNAME"), "USER=" + os.Getenv("USER"), } + if v, ok := os.LookupEnv("NO_COLOR"); ok { + c.Env = append(c.Env, "NO_COLOR="+v) + } c.Env = appendStandardEnv(c.Env, runtime.GOOS) c.Env = invoke.Env.appendTo(c.Env, os.Getenv("PATH"), filepath.ListSeparator) c.Dir = dir diff --git a/internal/biome/docker.go b/internal/biome/docker.go index 9b432ff3..22b1245f 100644 --- a/internal/biome/docker.go +++ b/internal/biome/docker.go @@ -255,7 +255,7 @@ func (c *Container) Run(ctx context.Context, invoke *Invocation) error { return fmt.Errorf("run in container %s: argv empty", c.id) } - log.Infof(ctx, "Run (Docker): %s", strings.Join(invoke.Argv, " ")) + log.Debugf(ctx, "Run (Docker): %s", strings.Join(invoke.Argv, " ")) log.Debugf(ctx, "Running in container %s", c.id) log.Debugf(ctx, "Environment:\n%v", invoke.Env) @@ -272,6 +272,9 @@ func (c *Container) Run(ctx context.Context, invoke *Invocation) error { // TODO(light): Set LOGNAME and USER. "HOME=" + c.dirs.Home, } + if v, ok := os.LookupEnv("NO_COLOR"); ok { + opts.Env = append(opts.Env, "NO_COLOR="+v) + } opts.Env = appendStandardEnv(opts.Env, c.Describe().OS) opts.Env = invoke.Env.appendTo(opts.Env, c.path, ':') if slashpath.IsAbs(invoke.Dir) { diff --git a/internal/build/build.go b/internal/build/build.go index 312f5251..7faea5f2 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -38,7 +38,9 @@ type Sys = buildpack.Sys // Execute runs the given phase. It assumes that the phase's dependencies are // already available in the biome. -func Execute(ctx context.Context, sys Sys, target *yb.Target) (err error) { +// +// announce is called before every command run if not nil. +func Execute(ctx context.Context, sys Sys, announce func(string), target *yb.Target) (err error) { ctx, span := ybtrace.Start(ctx, "Build "+target.Name, trace.WithAttributes( label.String("target", target.Name), )) @@ -63,6 +65,9 @@ func Execute(ctx context.Context, sys Sys, target *yb.Target) (err error) { } } for _, cmdString := range target.Commands { + if announce != nil { + announce(cmdString) + } newWorkDir, err := runCommand(ctx, sys, workDir, cmdString) if err != nil { return fmt.Errorf("build %s: %w", target.Name, err) diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 12e033c5..d033dca2 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -164,7 +164,7 @@ func TestExecute(t *testing.T) { return nil }, } - err := Execute(ctx, Sys{Biome: bio}, test.target) + err := Execute(ctx, Sys{Biome: bio}, nil, test.target) if err != nil { if test.wantError { t.Logf("Build: %v (expected)", err)