Initial commit
diff --git a/dockerimg/dockerimg.go b/dockerimg/dockerimg.go
new file mode 100644
index 0000000..72110da
--- /dev/null
+++ b/dockerimg/dockerimg.go
@@ -0,0 +1,626 @@
+// Package dockerimg
+package dockerimg
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+
+ "sketch.dev/skribe"
+)
+
+// ContainerConfig holds all configuration for launching a container
+type ContainerConfig struct {
+ // SessionID is the unique identifier for this session
+ SessionID string
+
+ // LocalAddr is the initial address to use (though it may be overwritten later)
+ LocalAddr string
+
+ // SkabandAddr is the address of the skaband service if available
+ SkabandAddr string
+
+ // AntURL is the URL of the LLM service.
+ AntURL string
+
+ // AntAPIKey is the API key for LLM service.
+ AntAPIKey string
+
+ // Path is the local filesystem path to use
+ Path string
+
+ // GitUsername is the username to use for git operations
+ GitUsername string
+
+ // GitEmail is the email to use for git operations
+ GitEmail string
+
+ // OpenBrowser determines whether to open a browser automatically
+ OpenBrowser bool
+
+ // NoCleanup prevents container cleanup when set to true
+ NoCleanup bool
+
+ // ForceRebuild forces rebuilding of the Docker image even if it exists
+ ForceRebuild bool
+
+ // Host directory to copy container logs into, if not set to ""
+ ContainerLogDest string
+
+ // Path to pre-built linux sketch binary, or build a new one if set to ""
+ SketchBinaryLinux string
+
+ // Sketch client public key.
+ SketchPubKey string
+}
+
+// LaunchContainer creates a docker container for a project, installs sketch and opens a connection to it.
+// It writes status to stdout.
+func LaunchContainer(ctx context.Context, stdout, stderr io.Writer, config ContainerConfig) error {
+ if _, err := exec.LookPath("docker"); err != nil {
+ return fmt.Errorf("cannot find `docker` binary; run: brew install docker colima && colima start")
+ }
+
+ if out, err := combinedOutput(ctx, "docker", "ps"); err != nil {
+ // `docker ps` provides a good error message here that can be
+ // easily chatgpt'ed by users, so send it to the user as-is:
+ // Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
+ return fmt.Errorf("docker ps: %s (%w)", out, err)
+ }
+
+ _, hostPort, err := net.SplitHostPort(config.LocalAddr)
+ if err != nil {
+ return err
+ }
+
+ gitRoot, err := findGitRoot(ctx, config.Path)
+ if err != nil {
+ return err
+ }
+
+ imgName, err := findOrBuildDockerImage(ctx, stdout, stderr, config.Path, gitRoot, config.AntURL, config.AntAPIKey, config.ForceRebuild)
+ if err != nil {
+ return err
+ }
+
+ linuxSketchBin := config.SketchBinaryLinux
+ if linuxSketchBin == "" {
+ linuxSketchBin, err = buildLinuxSketchBin(ctx, config.Path)
+ if err != nil {
+ return err
+ }
+ defer os.Remove(linuxSketchBin)
+ }
+
+ cntrName := imgName + "-" + config.SessionID
+ defer func() {
+ if config.NoCleanup {
+ return
+ }
+ if out, err := combinedOutput(ctx, "docker", "kill", cntrName); err != nil {
+ // TODO: print in verbose mode? fmt.Fprintf(os.Stderr, "docker kill: %s: %v\n", out, err)
+ _ = out
+ }
+ if out, err := combinedOutput(ctx, "docker", "rm", cntrName); err != nil {
+ // TODO: print in verbose mode? fmt.Fprintf(os.Stderr, "docker kill: %s: %v\n", out, err)
+ _ = out
+ }
+ }()
+
+ // errCh receives errors from operations that this function calls in separate goroutines.
+ errCh := make(chan error)
+
+ // Start the git server
+ gitSrv, err := newGitServer(gitRoot)
+ if err != nil {
+ return fmt.Errorf("failed to start git server: %w", err)
+ }
+ defer gitSrv.shutdown(ctx)
+
+ go func() {
+ errCh <- gitSrv.serve(ctx)
+ }()
+
+ // Get the current host git commit
+ var commit string
+ if out, err := combinedOutput(ctx, "git", "rev-parse", "HEAD"); err != nil {
+ return fmt.Errorf("git rev-parse HEAD: %w", err)
+ } else {
+ commit = strings.TrimSpace(string(out))
+ }
+ if out, err := combinedOutput(ctx, "git", "config", "http.receivepack", "true"); err != nil {
+ return fmt.Errorf("git config http.receivepack true: %s: %w", out, err)
+ }
+
+ relPath, err := filepath.Rel(gitRoot, config.Path)
+ if err != nil {
+ return err
+ }
+
+ // Create the sketch container
+ if err := createDockerContainer(ctx, cntrName, hostPort, relPath, imgName, config); err != nil {
+ return err
+ }
+
+ // Copy the sketch linux binary into the container
+ if out, err := combinedOutput(ctx, "docker", "cp", linuxSketchBin, cntrName+":/bin/sketch"); err != nil {
+ return fmt.Errorf("docker cp: %s, %w", out, err)
+ }
+
+ fmt.Printf("starting container %s\ncommits made by the agent will be pushed to \033[1msketch/*\033[0m\n", cntrName)
+
+ // Start the sketch container
+ if out, err := combinedOutput(ctx, "docker", "start", cntrName); err != nil {
+ return fmt.Errorf("docker start: %s, %w", out, err)
+ }
+
+ // Copies structured logs from the container to the host.
+ copyLogs := func() {
+ if config.ContainerLogDest == "" {
+ return
+ }
+ out, err := combinedOutput(ctx, "docker", "logs", cntrName)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "docker logs failed: %v\n", err)
+ return
+ }
+ logLines := strings.Split(string(out), "\n")
+ for _, logLine := range logLines {
+ if !strings.HasPrefix(logLine, "structured logs:") {
+ continue
+ }
+ logFile := strings.TrimSpace(strings.TrimPrefix(logLine, "structured logs:"))
+ srcPath := fmt.Sprintf("%s:%s", cntrName, logFile)
+ logFileName := filepath.Base(logFile)
+ dstPath := filepath.Join(config.ContainerLogDest, logFileName)
+ _, err := combinedOutput(ctx, "docker", "cp", srcPath, dstPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "docker cp %s %s failed: %v\n", srcPath, dstPath, err)
+ }
+ fmt.Fprintf(os.Stderr, "\ncopied container log %s to %s\n", srcPath, dstPath)
+ }
+ }
+
+ // NOTE: we want to see what the internal sketch binary prints
+ // regardless of the setting of the verbosity flag on the external
+ // binary, so reading "docker logs", which is the stdout/stderr of
+ // the internal binary is not conditional on the verbose flag.
+ appendInternalErr := func(err error) error {
+ if err == nil {
+ return nil
+ }
+ out, logsErr := combinedOutput(ctx, "docker", "logs", cntrName)
+ if err != nil {
+ return fmt.Errorf("%w; and docker logs failed: %s, %v", err, out, logsErr)
+ }
+ out = bytes.TrimSpace(out)
+ if len(out) > 0 {
+ return fmt.Errorf("docker logs: %s;\n%w", out, err)
+ }
+ return err
+ }
+
+ // Get the sketch server port from the container
+ localAddr, err := getContainerPort(ctx, cntrName)
+ if err != nil {
+ return appendInternalErr(err)
+ }
+
+ // Tell the sketch container which git server port and commit to initialize with.
+ go func() {
+ // TODO: Why is this called in a goroutine? I have found that when I pull this out
+ // of the goroutine and call it inline, then the terminal UI clears itself and all
+ // the scrollback (which is not good, but also not fatal). I can't see why it does this
+ // though, since none of the calls in postContainerInitConfig obviously write to stdout
+ // or stderr.
+ if err := postContainerInitConfig(ctx, localAddr, commit, gitSrv.gitPort); err != nil {
+ slog.ErrorContext(ctx, "LaunchContainer.postContainerInitConfig", slog.String("err", err.Error()))
+ errCh <- appendInternalErr(err)
+ }
+ }()
+
+ if config.OpenBrowser {
+ OpenBrowser(ctx, "http://"+localAddr)
+ }
+
+ go func() {
+ cmd := exec.CommandContext(ctx, "docker", "attach", cntrName)
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ errCh <- run(ctx, "docker attach", cmd)
+ }()
+
+ defer copyLogs()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case err := <-errCh:
+ if err != nil {
+ return appendInternalErr(fmt.Errorf("container process: %w", err))
+ }
+ return nil
+ }
+ }
+}
+
+func combinedOutput(ctx context.Context, cmdName string, args ...string) ([]byte, error) {
+ cmd := exec.CommandContext(ctx, cmdName, args...)
+ // Really only needed for the "go build" command for the linux sketch binary
+ cmd.Env = append(os.Environ(), "GOOS=linux", "CGO_ENABLED=0")
+ start := time.Now()
+
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ slog.ErrorContext(ctx, cmdName, slog.Duration("elapsed", time.Now().Sub(start)), slog.String("err", err.Error()), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
+ } else {
+ slog.DebugContext(ctx, cmdName, slog.Duration("elapsed", time.Now().Sub(start)), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
+ }
+ return out, err
+}
+
+func run(ctx context.Context, cmdName string, cmd *exec.Cmd) error {
+ start := time.Now()
+ err := cmd.Run()
+ if err != nil {
+ slog.ErrorContext(ctx, cmdName, slog.Duration("elapsed", time.Now().Sub(start)), slog.String("err", err.Error()), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
+ } else {
+ slog.DebugContext(ctx, cmdName, slog.Duration("elapsed", time.Now().Sub(start)), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
+ }
+ return err
+}
+
+type gitServer struct {
+ gitLn net.Listener
+ gitPort string
+ srv *http.Server
+}
+
+func (gs *gitServer) shutdown(ctx context.Context) {
+ gs.srv.Shutdown(ctx)
+ gs.gitLn.Close()
+}
+
+// Serve a git remote from the host for the container to fetch from and push to.
+func (gs *gitServer) serve(ctx context.Context) error {
+ slog.DebugContext(ctx, "starting git server", slog.String("git_remote_addr", "http://host.docker.internal:"+gs.gitPort+"/.git"))
+ return gs.srv.Serve(gs.gitLn)
+}
+
+func newGitServer(gitRoot string) (*gitServer, error) {
+ ret := &gitServer{}
+ gitLn, err := net.Listen("tcp4", ":0")
+ if err != nil {
+ return nil, fmt.Errorf("git listen: %w", err)
+ }
+ ret.gitLn = gitLn
+
+ srv := http.Server{
+ Handler: &gitHTTP{gitRepoRoot: gitRoot},
+ }
+ ret.srv = &srv
+
+ _, gitPort, err := net.SplitHostPort(gitLn.Addr().String())
+ if err != nil {
+ return nil, fmt.Errorf("git port: %w", err)
+ }
+ ret.gitPort = gitPort
+ return ret, nil
+}
+
+func createDockerContainer(ctx context.Context, cntrName, hostPort, relPath, imgName string, config ContainerConfig) error {
+ //, config.SessionID, config.GitUsername, config.GitEmail, config.SkabandAddr
+ // sessionID, gitUsername, gitEmail, skabandAddr string
+ cmdArgs := []string{"create",
+ "-it",
+ "--name", cntrName,
+ "-p", hostPort + ":80", // forward container port 80 to a host port
+ "-e", "ANTHROPIC_API_KEY=" + config.AntAPIKey,
+ }
+ if config.AntURL != "" {
+ cmdArgs = append(cmdArgs, "-e", "ANT_URL="+config.AntURL)
+ }
+ if config.SketchPubKey != "" {
+ cmdArgs = append(cmdArgs, "-e", "SKETCH_PUB_KEY="+config.SketchPubKey)
+ }
+ if relPath != "." {
+ cmdArgs = append(cmdArgs, "-w", "/app/"+relPath)
+ }
+ cmdArgs = append(
+ cmdArgs,
+ imgName,
+ "/bin/sketch",
+ "-unsafe",
+ "-addr=:80",
+ "-session-id="+config.SessionID,
+ "-git-username="+config.GitUsername, "-git-email="+config.GitEmail,
+ )
+ if config.SkabandAddr != "" {
+ cmdArgs = append(cmdArgs, "-skaband-addr="+config.SkabandAddr)
+ }
+ if out, err := combinedOutput(ctx, "docker", cmdArgs...); err != nil {
+ return fmt.Errorf("docker create: %s, %w", out, err)
+ }
+ return nil
+}
+
+func buildLinuxSketchBin(ctx context.Context, path string) (string, error) {
+ start := time.Now()
+ linuxSketchBin := filepath.Join(path, "tmp-sketch-binary-linux")
+ cmd := exec.CommandContext(ctx, "go", "build", "-o", linuxSketchBin, "sketch.dev/cmd/sketch")
+ cmd.Env = append(os.Environ(), "GOOS=linux", "CGO_ENABLED=0")
+
+ fmt.Printf("building linux agent binary...\n")
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ slog.ErrorContext(ctx, "go", slog.Duration("elapsed", time.Now().Sub(start)), slog.String("err", err.Error()), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
+ return "", fmt.Errorf("failed to build linux sketch binary: %s: %w", out, err)
+ } else {
+ slog.DebugContext(ctx, "go", slog.Duration("elapsed", time.Now().Sub(start)), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
+ }
+
+ fmt.Printf("built linux agent binary in %s\n", time.Since(start).Round(100*time.Millisecond))
+
+ return linuxSketchBin, nil
+}
+
+func getContainerPort(ctx context.Context, cntrName string) (string, error) {
+ localAddr := ""
+ if out, err := combinedOutput(ctx, "docker", "port", cntrName, "80"); err != nil {
+ return "", fmt.Errorf("failed to find container port: %s: %v", out, err)
+ } else {
+ v4, _, found := strings.Cut(string(out), "\n")
+ if !found {
+ return "", fmt.Errorf("failed to find container port: %s: %v", out, err)
+ }
+ localAddr = v4
+ if strings.HasPrefix(localAddr, "0.0.0.0") {
+ localAddr = "127.0.0.1" + strings.TrimPrefix(localAddr, "0.0.0.0")
+ }
+ }
+ return localAddr, nil
+}
+
+// Contact the container and configure it.
+func postContainerInitConfig(ctx context.Context, localAddr, commit, gitPort string) error {
+ localURL := "http://" + localAddr
+ initMsg, err := json.Marshal(map[string]string{
+ "commit": commit,
+ "git_remote_addr": "http://host.docker.internal:" + gitPort + "/.git",
+ "host_addr": localAddr,
+ })
+ if err != nil {
+ return fmt.Errorf("init msg: %w", err)
+ }
+
+ slog.DebugContext(ctx, "/init POST", slog.String("initMsg", string(initMsg)))
+
+ // Note: this /init POST is handled in loop/server/loophttp.go:
+ initMsgByteReader := bytes.NewReader(initMsg)
+ req, err := http.NewRequest("POST", localURL+"/init", initMsgByteReader)
+ if err != nil {
+ return err
+ }
+
+ var res *http.Response
+ for i := 0; ; i++ {
+ time.Sleep(100 * time.Millisecond)
+ // If you DON'T reset this byteReader, then subsequent retries may end up sending 0 bytes.
+ initMsgByteReader.Reset(initMsg)
+ res, err = http.DefaultClient.Do(req)
+ if err != nil {
+ // In addition to "connection refused", we also occasionally see "EOF" errors that can succeed on retries.
+ if i < 100 && (strings.Contains(err.Error(), "connection refused") || strings.Contains(err.Error(), "EOF")) {
+ slog.DebugContext(ctx, "postContainerInitConfig retrying", slog.Int("retry", i), slog.String("err", err.Error()))
+ continue
+ }
+ return fmt.Errorf("failed to %s/init sketch in container, NOT retrying: err: %v", localURL, err)
+ }
+ break
+ }
+ resBytes, _ := io.ReadAll(res.Body)
+ if res.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to initialize sketch in container, response status code %d: %s", res.StatusCode, resBytes)
+ }
+ return nil
+}
+
+func findOrBuildDockerImage(ctx context.Context, stdout, stderr io.Writer, cwd, gitRoot, antURL, antAPIKey string, forceRebuild bool) (imgName string, err error) {
+ h := sha256.Sum256([]byte(gitRoot))
+ imgName = "sketch-" + hex.EncodeToString(h[:6])
+
+ var curImgInitFilesHash string
+ if out, err := combinedOutput(ctx, "docker", "inspect", "--format", "{{json .Config.Labels}}", imgName); err != nil {
+ if strings.Contains(string(out), "No such object") {
+ // Image does not exist, continue and build it.
+ curImgInitFilesHash = ""
+ } else {
+ return "", fmt.Errorf("docker inspect failed: %s, %v", out, err)
+ }
+ } else {
+ m := map[string]string{}
+ if err := json.Unmarshal(bytes.TrimSpace(out), &m); err != nil {
+ return "", fmt.Errorf("docker inspect output unparsable: %s, %v", out, err)
+ }
+ curImgInitFilesHash = m["sketch_context"]
+ }
+
+ candidates, err := findRepoDockerfiles(cwd, gitRoot)
+ if err != nil {
+ return "", fmt.Errorf("find dockerfile: %w", err)
+ }
+
+ var initFiles map[string]string
+ var dockerfilePath string
+
+ // TODO: prefer a "Dockerfile.sketch" so users can tailor any env to this tool.
+ if len(candidates) == 1 && strings.ToLower(filepath.Base(candidates[0])) == "dockerfile" {
+ dockerfilePath = candidates[0]
+ contents, err := os.ReadFile(dockerfilePath)
+ if err != nil {
+ return "", err
+ }
+ fmt.Printf("using %s as dev env\n", candidates[0])
+ if hashInitFiles(map[string]string{dockerfilePath: string(contents)}) == curImgInitFilesHash && !forceRebuild {
+ fmt.Printf("using existing docker image %s\n", imgName)
+ return imgName, nil
+ }
+ } else {
+ initFiles, err = readInitFiles(os.DirFS(gitRoot))
+ if err != nil {
+ return "", err
+ }
+ subPathWorkingDir, err := filepath.Rel(gitRoot, cwd)
+ if err != nil {
+ return "", err
+ }
+ initFileHash := hashInitFiles(initFiles)
+ if curImgInitFilesHash == initFileHash && !forceRebuild {
+ fmt.Printf("using existing docker image %s\n", imgName)
+ return imgName, nil
+ }
+
+ start := time.Now()
+ dockerfile, err := createDockerfile(ctx, http.DefaultClient, antURL, antAPIKey, initFiles, subPathWorkingDir)
+ if err != nil {
+ return "", fmt.Errorf("create dockerfile: %w", err)
+ }
+ dockerfilePath = filepath.Join(cwd, "tmp-sketch-dockerfile")
+ if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0o666); err != nil {
+ return "", err
+ }
+ defer os.Remove(dockerfilePath)
+
+ fmt.Fprintf(stderr, "generated Dockerfile in %s:\n\t%s\n\n", time.Since(start).Round(time.Millisecond), strings.Replace(dockerfile, "\n", "\n\t", -1))
+ }
+
+ var gitUserEmail, gitUserName string
+ if out, err := combinedOutput(ctx, "git", "config", "--get", "user.email"); err != nil {
+ return "", fmt.Errorf("git config: %s: %v", out, err)
+ } else {
+ gitUserEmail = strings.TrimSpace(string(out))
+ }
+ if out, err := combinedOutput(ctx, "git", "config", "--get", "user.name"); err != nil {
+ return "", fmt.Errorf("git config: %s: %v", out, err)
+ } else {
+ gitUserName = strings.TrimSpace(string(out))
+ }
+
+ start := time.Now()
+ cmd := exec.CommandContext(ctx,
+ "docker", "build",
+ "-t", imgName,
+ "-f", dockerfilePath,
+ "--build-arg", "GIT_USER_EMAIL="+gitUserEmail,
+ "--build-arg", "GIT_USER_NAME="+gitUserName,
+ ".",
+ )
+ cmd.Dir = gitRoot
+ cmd.Stdout = stdout
+ cmd.Stderr = stderr
+ fmt.Printf("building docker image %s...\n", imgName)
+
+ err = run(ctx, "docker build", cmd)
+ if err != nil {
+ return "", fmt.Errorf("docker build failed: %v", err)
+ }
+ fmt.Printf("built docker image %s in %s\n", imgName, time.Since(start).Round(time.Millisecond))
+ return imgName, nil
+}
+
+func findRepoDockerfiles(cwd, gitRoot string) ([]string, error) {
+ files, err := findDirDockerfiles(cwd)
+ if err != nil {
+ return nil, err
+ }
+ if len(files) > 0 {
+ return files, nil
+ }
+
+ path := cwd
+ for path != gitRoot {
+ path = filepath.Dir(path)
+ files, err := findDirDockerfiles(path)
+ if err != nil {
+ return nil, err
+ }
+ if len(files) > 0 {
+ return files, nil
+ }
+ }
+ return files, nil
+}
+
+// findDirDockerfiles finds all "Dockerfile*" files in a directory.
+func findDirDockerfiles(root string) (res []string, err error) {
+ err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() && root != path {
+ return filepath.SkipDir
+ }
+ name := strings.ToLower(info.Name())
+ if name == "dockerfile" || strings.HasPrefix(name, "dockerfile.") {
+ res = append(res, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return res, nil
+}
+
+func findGitRoot(ctx context.Context, path string) (string, error) {
+ cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-common-dir")
+ cmd.Dir = path
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ if strings.Contains(string(out), "not a git repository") {
+ return "", fmt.Errorf(`sketch needs to run from within a git repo, but %s is not part of a git repo.
+Consider one of the following options:
+ - cd to a different dir that is already part of a git repo first, or
+ - to create a new git repo from this directory (%s), run this command:
+
+ git init . && git commit --allow-empty -m "initial commit"
+
+and try running sketch again.
+`, path, path)
+ }
+ return "", fmt.Errorf("git rev-parse --git-common-dir: %s: %w", out, err)
+ }
+ gitDir := strings.TrimSpace(string(out)) // location of .git dir, often as a relative path
+ absGitDir := filepath.Join(path, gitDir)
+ return filepath.Dir(absGitDir), err
+}
+
+func OpenBrowser(ctx context.Context, url string) {
+ var cmd *exec.Cmd
+ switch runtime.GOOS {
+ case "darwin":
+ cmd = exec.CommandContext(ctx, "open", url)
+ case "windows":
+ cmd = exec.CommandContext(ctx, "cmd", "/c", "start", url)
+ default: // Linux and other Unix-like systems
+ cmd = exec.CommandContext(ctx, "xdg-open", url)
+ }
+ if b, err := cmd.CombinedOutput(); err != nil {
+ fmt.Fprintf(os.Stderr, "failed to open browser: %v: %s\n", err, b)
+ }
+}