Re-work Sketch's Docker setup.
We were being fancy and creating Dockerfiles for folks. This sometimes
worked, but quite often didn't.
Instead, we you have -base-image and -force-rebuild-container, and the
"cache key" for images is just the base image and the working dir.
The layer cake is
(base image)
(customization) [optional]
(repo) [/app]
diff --git a/cmd/sketch/main.go b/cmd/sketch/main.go
index e6e2d1e..7775607 100644
--- a/cmd/sketch/main.go
+++ b/cmd/sketch/main.go
@@ -223,6 +223,7 @@
dumpDist string
sshPort int
forceRebuild bool
+ baseImage string
linkToGitHub bool
ignoreSig bool
@@ -275,6 +276,10 @@
userFlags.BoolVar(&flags.version, "version", false, "print the version and exit")
userFlags.IntVar(&flags.sshPort, "ssh-port", 0, "the host port number that the container's ssh server will listen on, or a randomly chosen port if this value is 0")
userFlags.BoolVar(&flags.forceRebuild, "force-rebuild-container", false, "rebuild Docker container")
+ // Get the default image info for help text
+ defaultImageName, _, defaultTag := dockerimg.DefaultImage()
+ defaultHelpText := fmt.Sprintf("base Docker image to use (defaults to %s:%s); see https://sketch.dev/docs/docker for instructions", defaultImageName, defaultTag)
+ userFlags.StringVar(&flags.baseImage, "base-image", "", defaultHelpText)
userFlags.StringVar(&flags.dockerArgs, "docker-args", "", "additional arguments to pass to the docker create command (e.g., --memory=2g --cpus=2)")
userFlags.Var(&flags.mounts, "mount", "volume to mount in the container in format /path/on/host:/path/in/container (can be repeated)")
@@ -437,6 +442,7 @@
SketchPubKey: pubKey,
SSHPort: flags.sshPort,
ForceRebuild: flags.forceRebuild,
+ BaseImage: flags.baseImage,
OutsideHostname: getHostname(),
OutsideOS: runtime.GOOS,
OutsideWorkingDir: cwd,
diff --git a/dockerimg/README.md b/dockerimg/README.md
new file mode 100644
index 0000000..38572f9
--- /dev/null
+++ b/dockerimg/README.md
@@ -0,0 +1,53 @@
+# Docker Containers
+
+## Why!?!
+
+To support multiple Sketch sessions in parallel, and to give the sessions
+isolation, each Sketch session runs in its own container. (At the end of the
+day, the output of a Sketch session is new git commit(s).)
+
+## Customization
+
+*Customizing Sketch's containers*
+
+By default, Sketch uses a Docker container generated by
+[https://github.com/boldsoftware/sketch/blob/main/dockerimg/Dockerfile.base](https://github.com/boldsoftware/sketch/blob/main/dockerimg/Dockerfile.base)
+and published to
+[https://github.com/boldsoftware/sketch/pkgs/container/sketch](https://github.com/boldsoftware/sketch/pkgs/container/sketch).
+This container is based on Ubuntu 24.04 and contains many popular tools. Sketch
+will install additional tools as it needs.
+
+Locally, Sketch creates a container image based on the default that includes
+your working tree. This image is cached (identified by a container label that
+is a hash of your working tree directory and the base image id) to speed up
+starting up Sketch. (Future invocations do a "git reset" inside the image, but
+don't need to copy over the whole git repo.) You can force re-creation with the
+`-force-rebuild-container` flag.
+
+If you'd like to customize the container, specify `-base-image` and
+point Sketch to an image you've built. We recommend layering your customizations
+on top of our base image, but this is not strictly necessary. Sketch will then
+add your repo on top of it, at runtime.
+
+
+
+For example, if you want to add Node 22, you might create a Dockerfile
+like below, and build it with `docker build -t sketch-with-node-22 - < Dockerfile.sketch`,
+and pass it to sketch with `-base-image sketch-with-node-22`.
+
+```dockerfile
+FROM ghcr.io/boldsoftware/sketch:latest
+
+RUN apt-get update && \
+ apt-get install -y curl && \
+ curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
+ apt-get install -y nodejs && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/*
+```
+
+## Troubleshooting
+
+"no space left on device"
+
+`docker system prune -a` removes stopped containers and unused images, which usually frees up significant disk space.
diff --git a/dockerimg/createdockerfile.go b/dockerimg/createdockerfile.go
index 8343005..d20da12 100644
--- a/dockerimg/createdockerfile.go
+++ b/dockerimg/createdockerfile.go
@@ -1,34 +1,15 @@
package dockerimg
import (
- "bytes"
- "context"
"crypto/sha256"
_ "embed" // Using underscore import to keep embed package for go:embed directive
"encoding/hex"
"encoding/json"
"fmt"
"io"
- "io/fs"
- "maps"
"net/http"
- "slices"
- "strings"
- "text/template"
-
- "sketch.dev/llm"
- "sketch.dev/llm/conversation"
)
-func hashInitFiles(initFiles map[string]string) string {
- h := sha256.New()
- for _, path := range slices.Sorted(maps.Keys(initFiles)) {
- fmt.Fprintf(h, "%s\n%s\n\n", path, initFiles[path])
- }
- fmt.Fprintf(h, "docker template\n%s\n", dockerfileDefaultTmpl)
- return hex.EncodeToString(h.Sum(nil))
-}
-
// DefaultImage is intended to ONLY be used by the pushdockerimg.go script.
func DefaultImage() (name, dockerfile, tag string) {
return dockerImgName, dockerfileBase, dockerfileBaseHash()
@@ -45,42 +26,12 @@
return hex.EncodeToString(h.Sum(nil))[:32]
}
-const tmpSketchDockerfile = "tmp-sketch-dockerfile"
-
//go:embed Dockerfile.base
var dockerfileBaseData []byte
// dockerfileBase is the content of the base Dockerfile
var dockerfileBase = string(dockerfileBaseData)
-const dockerfileFragment = `
-ARG GIT_USER_EMAIL
-ARG GIT_USER_NAME
-
-RUN git config --global user.email "$GIT_USER_EMAIL" && \
- git config --global user.name "$GIT_USER_NAME" && \
- git config --global http.postBuffer 524288000
-
-LABEL sketch_context="{{.InitFilesHash}}"
-COPY . /app
-RUN rm -f /app/` + tmpSketchDockerfile + `
-
-WORKDIR /app{{.SubDir}}
-RUN if [ -f go.mod ]; then go mod download; fi
-
-# Switch to lenient shell so we are more likely to get past failing extra_cmds.
-SHELL ["/bin/bash", "-uo", "pipefail", "-c"]
-
-{{.ExtraCmds}}
-
-# Switch back to strict shell after extra_cmds.
-SHELL ["/bin/bash", "-euxo", "pipefail", "-c"]
-
-CMD ["/bin/sketch"]
-`
-
-var dockerfileDefaultTmpl = "FROM " + dockerImgName + ":" + dockerfileBaseHash() + "\n" + dockerfileFragment
-
func readPublishedTags() ([]string, error) {
req, err := http.NewRequest("GET", "https://ghcr.io/token?service=ghcr.io&scope=repository:"+dockerImgRepo+":pull", nil)
if err != nil {
@@ -137,179 +88,3 @@
}
return fmt.Errorf("check tag exists: %q not found in %v", tag, tags)
}
-
-// createDockerfile creates a Dockerfile for a git repo.
-// It expects the relevant initFiles to have been provided.
-// If the sketch binary is being executed in a sub-directory of the repository,
-// the relative path is provided on subPathWorkingDir.
-func createDockerfile(ctx context.Context, srv llm.Service, initFiles map[string]string, subPathWorkingDir string, verbose bool) (string, error) {
- if subPathWorkingDir == "." {
- subPathWorkingDir = ""
- } else if subPathWorkingDir != "" && subPathWorkingDir[0] != '/' {
- subPathWorkingDir = "/" + subPathWorkingDir
- }
- toolCalled := false
- var dockerfileExtraCmds string
- runDockerfile := func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
- // TODO: unmarshal straight into a struct
- var m map[string]any
- if err := json.Unmarshal(input, &m); err != nil {
- return nil, fmt.Errorf(`input=%[1]v (%[1]T), wanted a map[string]any, got: %w`, input, err)
- }
- var ok bool
- dockerfileExtraCmds, ok = m["extra_cmds"].(string)
- if !ok {
- return nil, fmt.Errorf(`input["extra_cmds"]=%[1]v (%[1]T), wanted a string`, m["path"])
- }
- toolCalled = true
- return llm.TextContent("OK"), nil
- }
-
- convo := conversation.New(ctx, srv, nil)
-
- convo.Tools = []*llm.Tool{{
- Name: "dockerfile",
- Description: "Helps define a Dockerfile that sets up a dev environment for this project.",
- Run: runDockerfile,
- InputSchema: llm.MustSchema(`{
- "type": "object",
- "required": ["extra_cmds"],
- "properties": {
- "extra_cmds": {
- "type": "string",
- "description": "Extra dockerfile commands to add to the dockerfile. Each command should start with RUN."
- }
- }
-}`),
- }}
-
- // TODO: it's basically impossible to one-shot a python env. We need an agent loop for that.
- // Right now the prompt contains a set of half-baked workarounds.
-
- // If you want to edit the model prompt, run:
- //
- // go test ./dockerimg -httprecord ".*" -rewritewant
- //
- // Then look at the changes with:
- //
- // git diff dockerimg/testdata/*.dockerfile
- //
- // If the dockerfile changes are a strict improvement, commit all the changes.
- msg := llm.Message{
- Role: llm.MessageRoleUser,
- Content: []llm.Content{{
- Type: llm.ContentTypeText,
- Text: `
-Call the dockerfile tool to create a Dockerfile.
-The parameters to dockerfile fill out the From and ExtraCmds
-template variables in the following Go template:
-
-` + "```\n" + dockerfileBase + dockerfileFragment + "\n```" + `
-
-In particular:
-- Assume it is primarily a Go project.
-- Python env setup is challenging and often no required, so any RUN commands involving python tooling should be written to let docker build continue if there is a failure.
-- Include any tools particular to this repository that can be inferred from the given context.
-- Append || true to any apt-get install commands in case the package does not exist.
-- MINIMIZE the number of extra_cmds generated. Straightforward environments do not need any.
-- Do NOT expose any ports.
-- Do NOT generate any CMD or ENTRYPOINT extra commands.
-`,
- }},
- }
- if len(initFiles) > 0 {
- msg.Content[0].Text += "Here is the content of several files from the repository that may be relevant:\n\n"
- }
-
- for _, name := range slices.Sorted(maps.Keys(initFiles)) {
- msg.Content = append(msg.Content, llm.StringContent(fmt.Sprintf("Here is the contents %s:\n<file>\n%s\n</file>\n\n", name, initFiles[name])))
- }
- msg.Content = append(msg.Content, llm.StringContent("Now call the dockerfile tool."))
- res, err := convo.SendMessage(msg)
- if err != nil {
- return "", err
- }
- if res.StopReason != llm.StopReasonToolUse {
- return "", fmt.Errorf("expected stop reason %q, got %q", llm.StopReasonToolUse, res.StopReason)
- }
- _, _, err = convo.ToolResultContents(context.TODO(), res)
- if err != nil {
- return "", err
- }
-
- // Print the LLM response when verbose is enabled
- if verbose && len(res.Content) > 0 && res.Content[0].Type == llm.ContentTypeText && res.Content[0].Text != "" {
- fmt.Printf("\n<llm_response>\n%s\n</llm_response>\n\n", res.Content[0].Text)
- }
-
- if !toolCalled {
- return "", fmt.Errorf("no dockerfile returned")
- }
-
- tmpl := dockerfileDefaultTmpl
- if tag := dockerfileBaseHash(); checkTagExists(tag) != nil {
- // In development, if you edit dockerfileBase but don't release
- // (as is reasonable for testing things!) the hash won't exist
- // yet. In that case, we skip the sketch image and build it ourselves.
- fmt.Printf("published container tag %s:%s missing; building locally\n", dockerImgName, tag)
- tmpl = dockerfileBase + dockerfileFragment
- }
- buf := new(bytes.Buffer)
- err = template.Must(template.New("dockerfile").Parse(tmpl)).Execute(buf, map[string]string{
- "ExtraCmds": dockerfileExtraCmds,
- "SubDir": subPathWorkingDir,
- "InitFilesHash": hashInitFiles(initFiles),
- })
- if err != nil {
- return "", fmt.Errorf("dockerfile template failed: %w", err)
- }
-
- return buf.String(), nil
-}
-
-// For future reference: we can find the current git branch/checkout with: git symbolic-ref -q --short HEAD || git describe --tags --exact-match 2>/dev/null || git rev-parse HEAD
-
-func readInitFiles(fsys fs.FS) (map[string]string, error) {
- result := make(map[string]string)
-
- err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
- if err != nil {
- return err
- }
- if d.IsDir() && (d.Name() == ".git" || d.Name() == "node_modules") {
- return fs.SkipDir
- }
- if !d.Type().IsRegular() {
- return nil
- }
-
- // Case 1: Check for README files
- // TODO: find README files between the .git root (where we start)
- // and the dir that sketch was initialized. This needs more info
- // plumbed to this function.
- if strings.HasPrefix(strings.ToLower(path), "readme") {
- content, err := fs.ReadFile(fsys, path)
- if err != nil {
- return err
- }
- result[path] = string(content)
- return nil
- }
-
- // Case 2: Check for GitHub workflow files
- if strings.HasPrefix(path, ".github/workflows/") {
- content, err := fs.ReadFile(fsys, path)
- if err != nil {
- return err
- }
- result[path] = string(content)
- return nil
- }
-
- return nil
- })
- if err != nil {
- return nil, err
- }
- return result, nil
-}
diff --git a/dockerimg/docker.svg b/dockerimg/docker.svg
new file mode 100644
index 0000000..8af0a8e
--- /dev/null
+++ b/dockerimg/docker.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1581.7100067138663 396.2087402343751" width="3163.4200134277326" height="792.4174804687502"><!-- svg-source:excalidraw --><metadata><!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXNlS20pcdTAwMWG+z1NoPJdzrPS+pOZcdTAwMDZIXGImYTNcdTAwMDRcdTAwMDLDVEqWZFtYlowks53KXHUwMDAznLt5g3nFeYT5ZYNl2TLI4IBTZVNQuK2lu9Xf9/1b+893hlFJbntu5YNRcW9sy/ecyLqu/JG2X7lR7IVcdTAwMDF8RFx1MDAwNu/jsFx1MDAxZtmDI9tJ0os/vH+fnWHaYXd4luu7XTdIYjjuX/DeMP5cdTAwMWP8hU88Jz23dnXQbVx1MDAwNd2dXHUwMDFiXHUwMDFj8sbpmVx1MDAxZvQ2TzuDU1x1MDAwN1x1MDAwNz10JnLtxFxuWr6bfXRcdTAwMDPtiuLR+1t4j0X2/tpzkja0MalHbW3Xa7WT9MSsbXjZXHUwMDBmXHUwMDA2XHUwMDFhtcRJXHUwMDE0dtyN0Fx1MDAwZqP03n/HbvqT3blh2Z1WXHUwMDE09lx1MDAwMyc7pkGapNHIjml6vn+Y3Fx1MDAwZa5cZlNcdTAwMDXTUpm4/sl9/8hE+6yz4IatduDG6VRmo1xme5btJYOxo2xcdTAwMDRp73o1ZzDr/876XHUwMDE0WV23lk570Pf9UbNcdTAwMTc4bjqZlVx1MDAwNtrJ3S5w7m/38MyyXHUwMDA3Qu9bfmadd930ykxQSqSUWVx1MDAwZrN1Q+Vk425cdTAwMThcZpZcdTAwMTCWiiiGsaBZr+KPsHaSwUWbllx1MDAxZrvZ9Kc9+zS5rsbXVm7pJO5NMlx1MDAxYdbYyvP4RS063GrXLy26cX3CuL2z1quMjvt5/182ff2eY1xy+4Mlx1x1MDAxY3GEXHUwMDA0XHUwMDFi67HvXHUwMDA1ncm59UO7k1xy4d3YlE3goLg3UzjIXHJmXGJcdTAwMDGtTa1cdTAwMTBcIoIwjrEkeUAoZPIpSFCGTUS1ZpIjLVx1MDAwNVx1MDAxN9NcdTAwMDDhaLFcdTAwMDBJXCIriHtWXHUwMDA0z+x3XHUwMDA3yW4xSHKH36NcdTAwMDFcdTAwMTOMXGJVjNNcIjigmXAgQiChXHTBz4HDXCJXbLZcdTAwMDDThVx1MDAwN8Nft2LXqHWtlntcdTAwMWW02nZkeuH7Rug7cdhMruHxvo87bmK3P/hw43j8UYdBcujdpaMjKNe6aXU9P302PHezNd9rpbNUsWFcXG5UXHUwMDE5n6vEXHUwMDAzoVx1MDAxOVx1MDAxZND1XHUwMDFjZ1xcXHUwMDE4bLio5Vx1MDAwNW5UK6MwYeS1vMDyj144QKufhHU3XHUwMDFlXHUwMDBlMYn67vgsu1tcdTAwMGa4wibhj9DA5enh56OvNlK4b9m7pHNmXahwXHUwMDBlOVx1MDAxY0d7OqeE01x1MDAwMvxcdTAwMTdKXCIm1CSKS8zu/2brZDFcdTAwMTJpcUc1m787+vdeLpGcXGKEMdLjS/qeXHUwMDEzMJtqXHUwMDFkkVx1MDAwMqUgq0AomYr+co28/FTf2e5cbtupbYS4fbW5tvfj1n0zjSzuTVx0jdRcdTAwMWOZimitXHUwMDA0l4SM691cdTAwMTAjqlx1MDAwMCNA2yZlqa4qzplQkk9DZiWSM2GyP4dIYlxyYFx1MDAwMDtmyjxcdTAwMWM8XHUwMDFjMlx1MDAxM1x1MDAxMJxcIlx1MDAwZVxctcRcdTAwMWFp/MM4XHUwMDA1N8mou73wPFx1MDAxOCpHXHUwMDE1iJZxq2E77qvIY1x1MDAxMvZmauNcdTAwMTNyM1tcdTAwMWJLXHUwMDBmbDGymFxcrVx1MDAxZK0529RcdTAwMGabzfWTm9uuxPbXUtYxYJuNqVr2XFzTKaUgeWjsNe08UizzLDBNXHUwMDAybEVcdTAwMDKzSOCgPFx0XHUwMDEwWINKKcaKLGWi1CxcdTAwMTJcdTAwMTBcdTAwMTJRScmz/MZfSlx1MDAwMlx1MDAxYpGbXtJo3Fx1MDAxYYdcdTAwMDN4nFx1MDAwN3W30fd8x7j2krZRbYaR7VajYVt1XHUwMDA0zEJOwFwi1/pcdTAwMTQn+G4zeVx1MDAxZSPkRjhcdP+XXHUwMDBmKcdcdTAwMDb5eZuHXHUwMDBlOlx1MDAwN+vEZ4Hs3t2ITd9cdTAwMGa612FVl6FcdTAwMDOMKDEzXHUwMDBiV01YXHUwMDAyXHUwMDE4s/ynXHUwMDE53Fx1MDAxZlxiQTKTYlxyL6GZXCKMZHBcdTAwMWJcdTAwMTFcdTAwMDLJnsqKXHUwMDEw8oRQL09cYlx1MDAxYUxdgrUs4lx1MDAwMzk7kKTSp8JcdTAwMDRbPqvgo9u0+v5CXFziX4TvqVx1MDAxZS5GvG9+bIaH3z+Ss021/aXFkXurr45K+7SYSmRKzVxiWO5gv3M8XHUwMDEx4OJcdTAwMWFM+1xms2PrZVx1MDAxNVx1MDAwMJ5cdTAwMWaghy/2bjlGXGZJKfj4+nzQcTrVmlx1MDAxOfNcXHAliHrFXGLw+iZcdTAwMTfup++a1nePd3e2qknrS12/mXdb3JsyosaoNqVkRCtOsVx1MDAxMIrlXHUwMDExXCK1SVx1MDAxZkfIKlx1MDAxZWy8XHUwMDAwM0flRVxyU6JcdTAwMDWTqij0XHUwMDAz9DZb1bDiXHUwMDFhMaGfXHUwMDAzjlU8OOfzPiFHy1x1MDAxNVx1MDAwZlx1MDAwZbbjvXaYXHUwMDA0P/atrbZcdTAwMWKpdbaZtObRTmymusngXHUwMDFmUE6S2U1cdTAwMDP3N1xyi+nMOy4pnZjmrWSZrdtVoDijhW8vllJMhFaKXG5RRFx1MDAxN1SyydZcdTAwMDe6oCBcYkKAor6elDaJXrvdJ9Gp+sZcdTAwMGXv9quMfmHtN5PS4t6UklKiXHUwMDAxkOmilpxcdTAwMTKkJlx1MDAwMcOfXHUwMDAwXGZcdTAwMTXU1KCkXHUwMDEya0JcdTAwMTVReFx1MDAxYT4rJZ1cdJnjOZRcdTAwMTRcdTAwMWM9plx1MDAxMUZFhVx1MDAwNkzPllKqXHUwMDE5Tu2kZ2VRXi9svNGPk7Dr3VlcdHQ+hoZlXHIhP6FQj4aQ51x1MDAxZuRiVPVU0eqPhufZ1TO7xdjd+pZ/U6CqXHUwMDA1/MCFeMxcdTAwMTnF1OQ0S6Gy6bRcdTAwMTJG4K9KXHJcZkE4mN1SXHUwMDE3RJRXXHUwMDAxpJlcZnEyXHUwMDA3Q1xigDpDWFx1MDAxNTFcdTAwMDSZbn1gXGIl02Sfep4j+mtDyvdwcZ1FoP5XXHUwMDA1iVx1MDAwYjq5XHUwMDE41H537k42jnu1XU7C/W/Mx0GNJnPYwlx1MDAwMpA3Vt6Q95JcdEi7pmlcdJpGaVxmccxcdTAwMTl73ThSs2lrW//uOP3+YuNXXHUwMDExjCXStDhcdTAwMWY0MyksJVx1MDAwNT+Z4FdcZiNd2aed5PjyuFx1MDAxYlx1MDAwN2s38uJyd5tdXHUwMDA0b2b7XHUwMDE296aUtiFpXG5wXHUwMDE0XHUwMDE5MKdcdTAwMDBcdTAwMTdcIp8rJVx1MDAwMuVcdTAwMDAybftcdTAwMTJETSBcXIRcYmOYIUFcbiomVtI2XHUwMDEzMqflpS21YcGpL/RcdTAwMGLJdFx1MDAxOcXI9kVUKoWQXFy+5MigmCBvXHUwMDBl/u3NTdsnXHUwMDA0Z1L5XHUwMDFlXHUwMDFmw2I00N79hDr7+uTGp/o4XHSbx5KeXHUwMDFllEI3OKVwacU5LFx1MDAwMKRo3rElXHUwMDFhm0pcdTAwMTBcdTAwMDFWXHUwMDBmlYjTXHUwMDAy8SOgj1x1MDAxODPOXHUwMDE5XHUwMDFjpoukcFVcYjFcdTAwMTPbZ/NUQ2GiXHUwMDA1x7gogVx1MDAwMlx1MDAwZm+m2Sooklx1MDAwMtPlM1tcdTAwMDFcdTAwMTeGPShcdTAwMWRcdTAwMTiWXHScV5z0hMhcdTAwMTiWXHUwMDBlVFx1MDAxM+O7UTX+aXxcdTAwMWO0wlx1MDAwM3bPK4ZcdTAwMTU450HPimPDio1qXHUwMDAzvMaqN/BcdTAwMWG/XHUwMDE30sJyXHUwMDE0SDx3pMZcdTAwMTNcdTAwMDNdUNnEXdxoI/t0WyR8vVvbqe/R3Vx1MDAxZuXCYlTLR1xuKFx1MDAxOcmzR0HZxKqOynhcdTAwMDF9WHOYXHUwMDA2XHUwMDAy6dTVKVxmi+GZxcVUcLDaXHUwMDE0Wz72WNVR/bo6Ku7ozb2P/uf1rbOdQG05/XZ7rfzmO6yRNqlcdTAwMDBXXHUwMDFhg0fNXHUwMDA1zm89oEyZWGVcdTAwMGVcdTAwMDOeZoVVeun5nNAo5oQ5PGxccv5cdTAwMDBHwO1FVEHFZOtcdTAwMDNVXHUwMDEwxFx1MDAxMFx1MDAwN2A/b3PSqHtzudhy70i0m1x1MDAwN4dcdTAwMTdJ1WpGUn1Re9d3b+ZiXHUwMDE396aEjoKHLIe1XHUwMDE4SMMveNN5xHD6XHUwMDE0YkhasZxa8FhRjDgr0NFVfmkmZuw5ylx1MDAwZqlcdTAwMDZnXHUwMDA3y1wicHAxM3gsgVxiXHUwMDE1UkvoYf82XHSlJ0RpVrz5tXNIbf8g3lx1MDAwZfr407V15P44q9n1yzopRVx1MDAwMlx1MDAxODGTUVA2XHUwMDA08qY1mqhBTkVVK5Zu6CVSSD2dQ9LIZGlcblx0bHHJ6fhcdTAwMDVWcbanScApT1x1MDAwMoxQmtZrXHUwMDE1ueJgaM9cIlx1MDAwMeBmprjSavm27645V1x1MDAxNnRymVx1MDAxM0jTXVxcXGZgt74hcfg1+sZ24i6uXHUwMDFmXFyg/VqHz2HsYmpcdTAwMDJ04Vx1MDAxMUhMlVx1MDAxMFx1MDAxM/tswb/l6Z5OpLlO65HL2bqr7FEhRN1cdTAwMTfbtlx1MDAxNGGgT1SUPGL4kd1cdTAwMDNISympesUttntHrY1cdTAwMTZZXHUwMDE3x/XjvSBR3y4swp03M22Le1PKtOXIXHUwMDA0MUNglGo6/kVcdTAwMWVcdTAwMDN4XHUwMDEw8lx1MDAwNDxWyaNB63NcdTAwMTHTLC9qJN1rKzXhxX7f7O22RDMmXHRdvsKppUxcdTAwMWU9ITdvkTw66TZRf8Pe/bxcdTAwMTN4UejXTj99rd6Uiv/qdIdcdTAwMDFnRKZcdTAwMDBnWE3WTkgzLWeSQkiG0dhX0KyyR4tcdTAwMDB3qzy4JVx1MDAwMUuk2GtV7JGd9FhcbvB29Vx1MDAxMrqtw3RKO7x2odPGbdg3fK/jZllcdTAwMTMvmEqcnFx1MDAwN8Owqlx1MDAxMbfDflx1MDAxYVRccqPOXHUwMDFmRqOfXHUwMDE4SduNXFyja91cdTAwMWFcctdwXCKrXHUwMDA1MDON//33P38tfbj4NadgQeHlyFx1MDAwZkNLblS9ZrK92Y/rVS03Sn1fXHUwMDA38FxyN5lQXHUwMDFjfC/MJJqIkzGsTVxmji9TXHUwMDE4vGQ5vot3lW9aXHUwMDA04bTLXHUwMDEzXHUwMDBlJjiN0otiU3t2ulx1MDAxYc7CnFCxhF9xtco4LSTj9O7eaalYvd5hXHUwMDAyt1x1MDAxZnlwlSvPvV4v9F7TV+r8XGZcYiVFjztw/H6++/l/l1x1MDAwMFx1MDAxY1x1MDAwZSJ9<!-- payload-end --></metadata><defs><style class="style-fonts">
+ @font-face { font-family: Excalifont; src: url(data:font/woff2;base64,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); }</style></defs><rect x="0" y="0" width="1581.7100067138663" height="396.2087402343751" fill="#ffffff"></rect><g stroke-linecap="round" transform="translate(10.5 61.60159737723205) rotate(0 239.5 44.5)"><path d="M22.25 0 C172.27 -2.95, 322.41 -4.72, 456.75 0 C471.75 0.91, 475.97 7.36, 479 22.25 C475 33.48, 477.82 52.77, 479 66.75 C479.46 82.75, 468.67 91.95, 456.75 89 C311.23 86.74, 161.37 85.6, 22.25 89 C6.55 91.17, 3.14 83.73, 0 66.75 C-0.67 53.75, 1.16 35.21, 0 22.25 C-0.1 7.81, 10.68 -2.06, 22.25 0" stroke="none" stroke-width="0" fill="#b2f2bb"></path><path d="M22.25 0 C164.99 -2.23, 310.15 -2.57, 456.75 0 M22.25 0 C148.45 -1.02, 274 -0.97, 456.75 0 M456.75 0 C472.54 -0.61, 477.35 8.92, 479 22.25 M456.75 0 C472.5 0.34, 477.82 8.75, 479 22.25 M479 22.25 C479.21 39.09, 478.55 54.62, 479 66.75 M479 22.25 C478.62 39.1, 479.2 57.53, 479 66.75 M479 66.75 C478.44 79.81, 471.32 87.06, 456.75 89 M479 66.75 C481.06 80.56, 472.11 90.74, 456.75 89 M456.75 89 C369.17 86.83, 281.62 88.07, 22.25 89 M456.75 89 C345.58 87.54, 234.88 87.61, 22.25 89 M22.25 89 C8.24 89.01, 0.53 80.35, 0 66.75 M22.25 89 C8.37 89.88, -0.03 82.43, 0 66.75 M0 66.75 C-0.9 55.03, -1.9 42.43, 0 22.25 M0 66.75 C-0.65 56.55, 0.87 45.79, 0 22.25 M0 22.25 C-1.2 9.23, 6.79 0.94, 22.25 0 M0 22.25 C-1.61 6.03, 6.62 -1.75, 22.25 0" stroke="#1e1e1e" stroke-width="2" fill="none"></path></g><g transform="translate(79.48002624511719 81.10159737723205) rotate(0 170.5199737548828 25)"><text x="170.5199737548828" y="17.619999999999997" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="middle" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Base Image</text><text x="170.5199737548828" y="42.62" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="middle" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">ghcr.io/boldsoftware/sketch:latest</text></g><g stroke-linecap="round" transform="translate(10 154.10159737723205) rotate(0 239.5 61.64285714285711)"><path d="M30.82 0 C167.73 -2.07, 301.92 -2.74, 448.18 0 C468.42 -1.19, 476.5 11.61, 479 30.82 C480.46 42.38, 476 57.46, 479 92.46 C476.92 115.28, 466.66 126.45, 448.18 123.29 C300.46 128.46, 151.02 125.8, 30.82 123.29 C13.2 120.91, -1.48 113.08, 0 92.46 C-3.42 70.68, -1.47 57.07, 0 30.82 C1.89 10.67, 8.24 0.38, 30.82 0" stroke="none" stroke-width="0" fill="#a5d8ff"></path><path d="M30.82 0 C158.8 1.27, 288.1 -0.79, 448.18 0 M30.82 0 C180.57 2, 330.44 2.19, 448.18 0 M448.18 0 C470.17 -0.3, 479.56 9.73, 479 30.82 M448.18 0 C466.67 1.63, 479.67 8.33, 479 30.82 M479 30.82 C479.89 47.44, 478.63 61.38, 479 92.46 M479 30.82 C478.7 44.67, 478.86 59.43, 479 92.46 M479 92.46 C478.09 112.53, 467.68 121.91, 448.18 123.29 M479 92.46 C479.95 114.04, 468.32 121.91, 448.18 123.29 M448.18 123.29 C347.06 121.06, 244.59 122.47, 30.82 123.29 M448.18 123.29 C315.36 124.85, 183.01 124.81, 30.82 123.29 M30.82 123.29 C10.87 124.69, 0.73 114.19, 0 92.46 M30.82 123.29 C12.39 123.78, -0.63 113.67, 0 92.46 M0 92.46 C0.01 76.37, -0.57 61.06, 0 30.82 M0 92.46 C0.1 76.59, 0.73 60.98, 0 30.82 M0 30.82 C-1.51 9.84, 10.93 1.4, 30.82 0 M0 30.82 C-2.06 11.88, 11.5 1.28, 30.82 0" stroke="#1e1e1e" stroke-width="2" fill="none"></path></g><g transform="translate(130.32998657226562 159.10159737723205) rotate(0 119.17001342773438 25)"><text x="119.17001342773438" y="17.619999999999997" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="middle" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Base Image + Your Repo</text><text x="119.17001342773438" y="42.62" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="middle" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">sketch-12345abcde</text></g><g transform="translate(37.92857142857133 223.60159737723217) rotate(0 158.67001342773438 20)"><text x="0" y="14.096" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="16px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Created by Sketch</text><text x="0" y="34.096000000000004" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="16px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Rebuild with -force-rebuild-container</text></g><g transform="translate(212.21428571428555 15.315883091517719) rotate(0 37.15999984741211 12.5)"><text x="0" y="17.619999999999997" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Default</text></g><g stroke-linecap="round" transform="translate(550.2942722865512 60.42302594866078) rotate(0 239.5 44.5)"><path d="M22.25 0 C187.07 1.79, 356.15 2.31, 456.75 0 C470.46 -3.51, 479.73 7.26, 479 22.25 C480.76 38.7, 477.1 53.16, 479 66.75 C480.93 80.57, 470.42 86.96, 456.75 89 C351.17 89.18, 244.97 89.45, 22.25 89 C9.28 85.45, 3.23 80.43, 0 66.75 C-2.36 58.11, 0.52 44.93, 0 22.25 C-1.15 5.76, 4.74 0.22, 22.25 0" stroke="none" stroke-width="0" fill="#b2f2bb"></path><path d="M22.25 0 C188.68 -1.49, 355.48 -1.74, 456.75 0 M22.25 0 C158.55 -0.67, 295.21 -0.68, 456.75 0 M456.75 0 C470.89 1.23, 479.51 7.83, 479 22.25 M456.75 0 C473.07 0.79, 477.99 5.99, 479 22.25 M479 22.25 C477.59 34.19, 478.46 45.21, 479 66.75 M479 22.25 C478.53 35.2, 479.53 47.46, 479 66.75 M479 66.75 C480.02 80.27, 471.31 88.52, 456.75 89 M479 66.75 C479.46 83.77, 470.91 86.92, 456.75 89 M456.75 89 C326.75 90.24, 195.54 89.29, 22.25 89 M456.75 89 C318.14 89.12, 178.99 88.96, 22.25 89 M22.25 89 C6.18 88.64, -0.38 80.72, 0 66.75 M22.25 89 C7.11 87.38, -2.11 80.67, 0 66.75 M0 66.75 C1.88 55.8, 1.38 49.5, 0 22.25 M0 66.75 C-0.13 51.72, 0.75 37.85, 0 22.25 M0 22.25 C-0.74 6.46, 5.73 -0.9, 22.25 0 M0 22.25 C0.81 5.78, 5.16 -1.53, 22.25 0" stroke="#1e1e1e" stroke-width="2" fill="none"></path></g><g transform="translate(619.2742985316684 79.92302594866078) rotate(0 170.5199737548828 25)"><text x="170.5199737548828" y="17.619999999999997" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="middle" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Base Image</text><text x="170.5199737548828" y="42.62" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="middle" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">ghcr.io/boldsoftware/sketch:latest</text></g><g stroke-linecap="round" transform="translate(551.2228437151227 251.49445452008933) rotate(0 239.5 67.35714285714289)"><path d="M32 0 C148.42 -2.31, 266.45 -0.5, 447 0 C470.5 0.22, 481.5 12.38, 479 32 C480.24 53.02, 477.15 74.03, 479 102.71 C476.96 121.28, 468.69 134.96, 447 134.71 C328.53 132.9, 209.8 134.2, 32 134.71 C9.03 132.54, -2.4 121.2, 0 102.71 C-1.17 74.57, -2.69 49.95, 0 32 C0.29 11.3, 10.52 -1.43, 32 0" stroke="none" stroke-width="0" fill="#a5d8ff"></path><path d="M32 0 C145.35 1.76, 258.97 0.58, 447 0 M32 0 C196.68 -1.79, 361.87 -2.01, 447 0 M447 0 C469.62 0.69, 478.12 9.43, 479 32 M447 0 C467.08 -0.56, 478.2 9.16, 479 32 M479 32 C478.5 58.18, 478.22 80.11, 479 102.71 M479 32 C479.67 55.16, 479.02 79.4, 479 102.71 M479 102.71 C479.4 125.95, 467.75 132.9, 447 134.71 M479 102.71 C479.01 124.57, 469.03 136.1, 447 134.71 M447 134.71 C299.57 134.3, 151.76 133.91, 32 134.71 M447 134.71 C318.93 134.09, 191.44 133.96, 32 134.71 M32 134.71 C10.4 133.31, -1.83 123.25, 0 102.71 M32 134.71 C8.72 134.07, -2.12 126.06, 0 102.71 M0 102.71 C-0.02 86.03, 1.32 69.54, 0 32 M0 102.71 C-0.86 86.21, -1.33 70.22, 0 32 M0 32 C0.71 9.24, 8.71 -1.33, 32 0 M0 32 C1.9 12.3, 9.44 -1.82, 32 0" stroke="#1e1e1e" stroke-width="2" fill="none"></path></g><g transform="translate(608.7528577532087 256.49445452008933) rotate(0 181.96998596191406 25)"><text x="181.96998596191406" y="17.619999999999997" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="middle" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Base Image + Customizations + Repo</text><text x="181.96998596191406" y="42.62" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="middle" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">sketch-12345abcde</text></g><g transform="translate(746.2942722865512 14.137311662946502) rotate(0 54.939998626708984 12.5)"><text x="0" y="17.619999999999997" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Customized</text></g><g stroke-linecap="round" transform="translate(549.3571428571424 156.53571428571433) rotate(0 239.5 44.5)"><path d="M22.25 0 C148.1 -1.21, 274.27 -0.89, 456.75 0 C470.31 -2.14, 477.3 4.49, 479 22.25 C481.55 30.13, 478.33 43.56, 479 66.75 C475.99 81.2, 472.83 85.69, 456.75 89 C329.44 93.09, 198.87 93, 22.25 89 C7.68 90.85, -0.32 82.79, 0 66.75 C-1.46 51.11, 1.85 37.64, 0 22.25 C3.23 6.42, 9.96 -1.86, 22.25 0" stroke="none" stroke-width="0" fill="#ffc9c9"></path><path d="M22.25 0 C129.54 0.64, 234.69 1.63, 456.75 0 M22.25 0 C129.82 0.83, 237.83 0.29, 456.75 0 M456.75 0 C472.1 1.16, 480.36 8.71, 479 22.25 M456.75 0 C471.45 0.98, 478.21 5.36, 479 22.25 M479 22.25 C480.69 29.34, 477.85 40.8, 479 66.75 M479 22.25 C479.15 34.34, 477.99 45.19, 479 66.75 M479 66.75 C480.25 80.65, 471.56 88.82, 456.75 89 M479 66.75 C477.78 80.17, 470.26 89.41, 456.75 89 M456.75 89 C306.49 90.14, 154.65 90.34, 22.25 89 M456.75 89 C310.21 87.34, 165.13 86.93, 22.25 89 M22.25 89 C5.78 90.86, 0.43 83.18, 0 66.75 M22.25 89 C5.56 90.23, 0.2 83.86, 0 66.75 M0 66.75 C-1.09 49.46, 1.81 33.5, 0 22.25 M0 66.75 C0.26 54.17, -0.61 41.79, 0 22.25 M0 22.25 C1.78 5.44, 5.51 -1.05, 22.25 0 M0 22.25 C1.07 8.54, 8.94 0.29, 22.25 0" stroke="#1e1e1e" stroke-width="2" fill="none"></path></g><g transform="translate(687.1371416364393 161.53571428571433) rotate(0 101.72000122070312 12.5)"><text x="101.72000122070312" y="17.619999999999997" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="middle" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Your Customizations!</text></g><g transform="translate(578.758558000837 192.46428571428584) rotate(0 212.95572771344894 20)"><text x="0" y="14.096" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="16px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">You create with "docker build -t X - < Dockerfile" and</text><text x="0" y="34.096000000000004" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="16px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">pass as -base-image X</text></g><g transform="translate(577.3299865722656 322.4642857142857) rotate(0 158.67001342773438 20)"><text x="0" y="14.096" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="16px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Created by Sketch</text><text x="0" y="34.096000000000004" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="16px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Rebuild with -force-rebuild-container</text></g><g stroke-linecap="round" transform="translate(1088.8614218575615 248.78571428571422) rotate(0 239.4999999999999 67.35714285714289)"><path d="M32 0 C115.84 3, 200.06 1.91, 447 0 C471.23 2.16, 482.33 12.06, 479 32 C480.32 51.17, 477.85 69.71, 479 102.71 C479.12 124.57, 466.5 131.64, 447 134.71 C298.75 130.75, 153.43 133.28, 32 134.71 C9.56 134.98, 2.76 120.97, 0 102.71 C3.16 76.48, 0.87 49.46, 0 32 C2.81 8.54, 7.08 3.17, 32 0" stroke="none" stroke-width="0" fill="#a5d8ff"></path><path d="M32 0 C180.09 1.13, 328.45 3.09, 447 0 M32 0 C195.9 2.17, 360.16 2.06, 447 0 M447 0 C469.08 0.87, 478.07 10.59, 479 32 M447 0 C466.23 0.73, 478.09 12.87, 479 32 M479 32 C477.31 60.84, 477.49 86.18, 479 102.71 M479 32 C480.06 53.23, 478.99 75.63, 479 102.71 M479 102.71 C479.84 123.69, 468.29 133.52, 447 134.71 M479 102.71 C481.26 124.14, 467.2 133.94, 447 134.71 M447 134.71 C299.25 135.75, 151.32 134.83, 32 134.71 M447 134.71 C344.38 134.91, 242.44 134.17, 32 134.71 M32 134.71 C9.01 135.45, -1.02 122.8, 0 102.71 M32 134.71 C10.31 133.47, 0.97 123.88, 0 102.71 M0 102.71 C-0.18 87.77, -0.63 72.05, 0 32 M0 102.71 C-0.57 81.49, -0.53 61.37, 0 32 M0 32 C-1.06 12.47, 9.76 -1.61, 32 0 M0 32 C-1.54 9.11, 12.28 -0.24, 32 0" stroke="#1e1e1e" stroke-width="2" fill="none"></path></g><g transform="translate(1216.8214209420341 253.78571428571422) rotate(0 111.54000091552723 25)"><text x="111.54000091552734" y="17.619999999999997" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="middle" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Customizations + Repo</text><text x="111.54000091552734" y="42.62" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="middle" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">sketch-12345abcde</text></g><g transform="translate(1283.9328504289906 10) rotate(0 45.23999786376953 12.5)"><text x="0" y="17.619999999999997" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Advanced</text></g><g stroke-linecap="round" transform="translate(1092.7100067138665 118.11268833705344) rotate(0 239.4999999999999 44.5)"><path d="M22.25 0 C152.96 -1.68, 280 -2.65, 456.75 0 C468.77 1.51, 478.01 6.45, 479 22.25 C477.2 29.29, 480.97 45.22, 479 66.75 C476.23 79.13, 470.03 89.49, 456.75 89 C290.26 87.73, 122.07 88.11, 22.25 89 C7.62 91.34, -0.79 81.63, 0 66.75 C-4.22 59.49, 2.17 42.55, 0 22.25 C2.14 10.47, 9.91 2.83, 22.25 0" stroke="none" stroke-width="0" fill="#ffc9c9"></path><path d="M22.25 0 C193.72 -1.4, 367.21 -1.15, 456.75 0 M22.25 0 C149.19 -0.98, 276.14 -0.8, 456.75 0 M456.75 0 C470.79 1.91, 480.62 7.7, 479 22.25 M456.75 0 C473.68 -2.06, 481.19 5.56, 479 22.25 M479 22.25 C480.38 38.06, 478.58 49.97, 479 66.75 M479 22.25 C479.17 38.26, 480.17 55.51, 479 66.75 M479 66.75 C478.02 80.91, 472.73 88.48, 456.75 89 M479 66.75 C480.03 82.53, 471.66 90.52, 456.75 89 M456.75 89 C362.15 88.47, 268.23 89.15, 22.25 89 M456.75 89 C361.2 89.49, 266.1 89.53, 22.25 89 M22.25 89 C8.26 88.85, -1.77 80.27, 0 66.75 M22.25 89 C9.4 88.26, 0.01 80.32, 0 66.75 M0 66.75 C0.3 53.76, -1.41 42.19, 0 22.25 M0 66.75 C-0.22 54.17, -0.44 42.53, 0 22.25 M0 22.25 C1.4 7.21, 5.48 -1.04, 22.25 0 M0 22.25 C-1.12 6.91, 8.63 -0.57, 22.25 0" stroke="#1e1e1e" stroke-width="2" fill="none"></path></g><g transform="translate(1230.4900054931632 123.11268833705344) rotate(0 101.72000122070312 12.5)"><text x="101.72000122070312" y="17.619999999999997" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="middle" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Your Customizations!</text></g><g transform="translate(1119.2542790004184 158.32697405133928) rotate(0 212.95572771344894 20)"><text x="0" y="14.096" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="16px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Create however you like and pass in as -base-image X</text><text x="0" y="34.096000000000004" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="16px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Sketch should work, but there may be dragons. 🐉</text></g><g transform="translate(1114.9685647147041 319.7555454799106) rotate(0 158.67001342773426 20)"><text x="0" y="14.096" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="16px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Created by Sketch</text><text x="0" y="34.096000000000004" font-family="Excalifont, Xiaolai, Segoe UI Emoji" font-size="16px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">Rebuild with -force-rebuild-container</text></g></svg>
\ No newline at end of file
diff --git a/dockerimg/dockerimg.go b/dockerimg/dockerimg.go
index b63d0e3..826778a 100644
--- a/dockerimg/dockerimg.go
+++ b/dockerimg/dockerimg.go
@@ -23,9 +23,6 @@
"golang.org/x/crypto/ssh"
"sketch.dev/browser"
- "sketch.dev/llm"
- "sketch.dev/llm/ant"
- "sketch.dev/llm/gem"
"sketch.dev/loop/server"
"sketch.dev/skribe"
"sketch.dev/webui"
@@ -69,6 +66,9 @@
// ForceRebuild forces rebuilding of the Docker image even if it exists
ForceRebuild bool
+ // BaseImage is the base Docker image to use for layering the repo
+ BaseImage string
+
// Host directory to copy container logs into, if not set to ""
ContainerLogDest string
@@ -166,7 +166,7 @@
return err
}
- imgName, err := findOrBuildDockerImage(ctx, config.Path, gitRoot, config.Model, config.ModelURL, config.ModelAPIKey, config.ForceRebuild, config.Verbose)
+ imgName, err := findOrBuildDockerImage(ctx, gitRoot, config.BaseImage, config.ForceRebuild, config.Verbose)
if err != nil {
return err
}
@@ -792,106 +792,137 @@
return nil
}
-func findOrBuildDockerImage(ctx context.Context, cwd, gitRoot, model, modelURL, modelAPIKey string, forceRebuild, verbose 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(strings.ToLower(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"]
+func findOrBuildDockerImage(ctx context.Context, gitRoot, baseImage string, forceRebuild, verbose bool) (imgName string, err error) {
+ // Default to the published sketch image if no base image is specified
+ if baseImage == "" {
+ imageTag := dockerfileBaseHash()
+ baseImage = fmt.Sprintf("%s:%s", dockerImgName, imageTag)
}
- candidates, err := findRepoDockerfiles(cwd, gitRoot)
+ // Ensure the base image exists locally, pull if necessary
+ if err := ensureBaseImageExists(ctx, baseImage); err != nil {
+ return "", fmt.Errorf("failed to ensure base image %s exists: %w", baseImage, err)
+ }
+
+ // Get the base image container ID for caching
+ baseImageID, err := getDockerImageID(ctx, baseImage)
if err != nil {
- return "", fmt.Errorf("find dockerfile: %w", err)
+ return "", fmt.Errorf("failed to get base image ID for %s: %w", baseImage, err)
}
- var initFiles map[string]string
- var dockerfilePath string
- var generatedDockerfile string
+ // Create a cache key based on base image ID and working directory
+ // Docker naming conventions restrict you to 20 characters per path component
+ // and only allow lowercase letters, digits, underscores, and dashes, so encoding
+ // the hash and the repo directory is sadly a bit of a non-starter.
+ cacheKey := createCacheKey(baseImageID, gitRoot)
+ imgName = "sketch-" + cacheKey
- // Prioritize Dockerfile.sketch over Dockerfile, then fall back to generated dockerfile
- if len(candidates) > 0 {
- dockerfilePath = prioritizeDockerfiles(candidates)
- contents, err := os.ReadFile(dockerfilePath)
- if err != nil {
- return "", err
- }
- fmt.Printf("using %s as dev env\n", dockerfilePath)
- if hashInitFiles(map[string]string{dockerfilePath: string(contents)}) == curImgInitFilesHash && !forceRebuild {
- 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 {
- return imgName, nil
- }
-
- start := time.Now()
-
- var service llm.Service
- if model == "gemini" {
- service = &gem.Service{
- URL: modelURL,
- APIKey: modelAPIKey,
- HTTPC: http.DefaultClient,
+ // Check if the cached image exists and is up to date
+ if !forceRebuild {
+ if exists, err := dockerImageExists(ctx, imgName); err != nil {
+ return "", fmt.Errorf("failed to check if image exists: %w", err)
+ } else if exists {
+ if verbose {
+ fmt.Printf("using cached image %s\n", imgName)
}
- } else {
- service = &ant.Service{
- URL: modelURL,
- APIKey: modelAPIKey,
- HTTPC: http.DefaultClient,
- }
- }
-
- generatedDockerfile, err = createDockerfile(ctx, service, initFiles, subPathWorkingDir, verbose)
- if err != nil {
- return "", fmt.Errorf("create dockerfile: %w", err)
- }
- // Create a unique temporary directory for the Dockerfile
- tmpDir, err := os.MkdirTemp("", "sketch-docker-*")
- if err != nil {
- return "", fmt.Errorf("failed to create temporary directory: %w", err)
- }
- dockerfilePath = filepath.Join(tmpDir, tmpSketchDockerfile)
- if err := os.WriteFile(dockerfilePath, []byte(generatedDockerfile), 0o666); err != nil {
- return "", err
- }
- // Remove the temporary directory and all contents when done
- defer os.RemoveAll(tmpDir)
-
- if verbose {
- fmt.Fprintf(os.Stderr, "generated Dockerfile in %s:\n\t%s\n\n", time.Since(start).Round(time.Millisecond), strings.Replace(generatedDockerfile, "\n", "\n\t", -1))
+ return imgName, nil
}
}
+ // Build the layered image
+ if err := buildLayeredImage(ctx, imgName, baseImage, gitRoot, verbose); err != nil {
+ return "", fmt.Errorf("failed to build layered image: %w", err)
+ }
+
+ return imgName, nil
+}
+
+// ensureBaseImageExists checks if the base image exists locally and pulls it if not
+func ensureBaseImageExists(ctx context.Context, imageName string) error {
+ exists, err := dockerImageExists(ctx, imageName)
+ if err != nil {
+ return fmt.Errorf("failed to check if image exists: %w", err)
+ }
+
+ if !exists {
+ fmt.Printf("🐋 pulling base image %s...\n", imageName)
+ if out, err := combinedOutput(ctx, "docker", "pull", imageName); err != nil {
+ return fmt.Errorf("docker pull %s failed: %s: %w", imageName, out, err)
+ }
+ fmt.Printf("✅ successfully pulled %s\n", imageName)
+ }
+
+ return nil
+}
+
+// getDockerImageID gets the container ID for a Docker image
+func getDockerImageID(ctx context.Context, imageName string) (string, error) {
+ out, err := combinedOutput(ctx, "docker", "inspect", "--format", "{{.Id}}", imageName)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+// createCacheKey creates a cache key from base image ID and working directory
+func createCacheKey(baseImageID, gitRoot string) string {
+ h := sha256.New()
+ h.Write([]byte(baseImageID))
+ h.Write([]byte(gitRoot))
+ return hex.EncodeToString(h.Sum(nil))[:12] // Use first 12 chars for shorter name
+}
+
+// dockerImageExists checks if a Docker image exists locally
+func dockerImageExists(ctx context.Context, imageName string) (bool, error) {
+ out, err := combinedOutput(ctx, "docker", "inspect", imageName)
+ if err != nil {
+ if strings.Contains(strings.ToLower(string(out)), "no such object") ||
+ strings.Contains(strings.ToLower(string(out)), "no such image") {
+ return false, nil
+ }
+ return false, err
+ }
+ return true, nil
+}
+
+// buildLayeredImage builds a new Docker image by layering the repo on top of the base image
+// TODO: git config stuff could be environment variables at runtime for email and username.
+// The git docs seem to say that http.postBuffer is a bug in our git proxy more than a thing
+// that's needed, but we haven't found the bug yet!
+func buildLayeredImage(ctx context.Context, imgName, baseImage, gitRoot string, _ bool) error {
+ dockerfileContent := fmt.Sprintf(`FROM %s
+ARG GIT_USER_EMAIL
+ARG GIT_USER_NAME
+RUN git config --global user.email "$GIT_USER_EMAIL" && \
+ git config --global user.name "$GIT_USER_NAME" && \
+ git config --global http.postBuffer 524288000
+COPY . /app
+WORKDIR /app
+RUN if [ -f go.mod ]; then go mod download; fi
+CMD ["/bin/sketch"]
+`, baseImage)
+
+ // Create a temporary directory for the Dockerfile
+ tmpDir, err := os.MkdirTemp("", "sketch-docker-*")
+ if err != nil {
+ return fmt.Errorf("failed to create temporary directory: %w", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ dockerfilePath := filepath.Join(tmpDir, "Dockerfile")
+ if err := os.WriteFile(dockerfilePath, []byte(dockerfileContent), 0o666); err != nil {
+ return fmt.Errorf("failed to write Dockerfile: %w", err)
+ }
+
+ // Get git user info
var gitUserEmail, gitUserName string
if out, err := combinedOutput(ctx, "git", "config", "--get", "user.email"); err != nil {
- return "", fmt.Errorf("git user.email is not set. Please run 'git config --global user.email \"your.email@example.com\"' to set your email address")
+ return fmt.Errorf("git user.email is not set. Please run 'git config --global user.email \"your.email@example.com\"' to set your email address")
} else {
gitUserEmail = strings.TrimSpace(string(out))
}
if out, err := combinedOutput(ctx, "git", "config", "--get", "user.name"); err != nil {
- return "", fmt.Errorf("git user.name is not set. Please run 'git config --global user.name \"Your Name\"' to set your name")
+ return fmt.Errorf("git user.name is not set. Please run 'git config --global user.name \"Your Name\"' to set your name")
} else {
gitUserName = strings.TrimSpace(string(out))
}
@@ -903,24 +934,9 @@
"-f", dockerfilePath,
"--build-arg", "GIT_USER_EMAIL=" + gitUserEmail,
"--build-arg", "GIT_USER_NAME=" + gitUserName,
+ ".",
}
- // Add the sketch_context label for image reuse detection
- var contextHash string
- if len(candidates) > 0 {
- // Building from Dockerfile.sketch or similar static file
- contents, err := os.ReadFile(dockerfilePath)
- if err != nil {
- return "", err
- }
- contextHash = hashInitFiles(map[string]string{dockerfilePath: string(contents)})
- } else {
- // Building from generated dockerfile
- contextHash = hashInitFiles(initFiles)
- }
- cmdArgs = append(cmdArgs, "--label", "sketch_context="+contextHash)
- cmdArgs = append(cmdArgs, ".")
-
cmd := exec.CommandContext(ctx, "docker", cmdArgs...)
cmd.Dir = gitRoot
// We print the docker build output whether or not the user
@@ -928,95 +944,14 @@
// and this gives good context.
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
- fmt.Printf("🏗️ building docker image %s... (use -verbose to see build output)\n", imgName)
+ fmt.Printf("🏗️ building docker image %s from base %s...\n", imgName, baseImage)
err = run(ctx, "docker build", cmd)
if err != nil {
- var msg string
- if generatedDockerfile != "" {
- if !verbose {
- fmt.Fprintf(os.Stderr, "Generated Dockerfile:\n\t%s\n\n", strings.Replace(generatedDockerfile, "\n", "\n\t", -1))
- }
- msg = fmt.Sprintf("\n\nThe generated Dockerfile failed to build.\nYou can override it by committing a Dockerfile to your project.")
- }
- return "", fmt.Errorf("docker build failed: %v%s", err, msg)
+ 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
-}
-
-// prioritizeDockerfiles returns the highest priority dockerfile from a list of candidates.
-// Priority order: Dockerfile.sketch > Dockerfile > other Dockerfile.*
-func prioritizeDockerfiles(candidates []string) string {
- if len(candidates) == 0 {
- return ""
- }
- if len(candidates) == 1 {
- return candidates[0]
- }
-
- // Look for Dockerfile.sketch first (case insensitive)
- for _, candidate := range candidates {
- basename := strings.ToLower(filepath.Base(candidate))
- if basename == "dockerfile.sketch" {
- return candidate
- }
- }
-
- // Look for Dockerfile second (case insensitive)
- for _, candidate := range candidates {
- basename := strings.ToLower(filepath.Base(candidate))
- if basename == "dockerfile" {
- return candidate
- }
- }
-
- // Return first remaining candidate
- return candidates[0]
-}
-
-// 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.") || strings.HasSuffix(name, ".dockerfile") {
- res = append(res, path)
- }
- return nil
- })
- if err != nil {
- return nil, err
- }
- return res, nil
+ return nil
}
func checkForEmptyGitRepo(ctx context.Context, path string) error {
diff --git a/dockerimg/dockerimg_test.go b/dockerimg/dockerimg_test.go
index c3cc65c..563d33b 100644
--- a/dockerimg/dockerimg_test.go
+++ b/dockerimg/dockerimg_test.go
@@ -1,524 +1,105 @@
package dockerimg
import (
- "cmp"
"context"
- "flag"
- "io/fs"
- "net/http"
"os"
- "path/filepath"
- "strings"
"testing"
- "testing/fstest"
-
- gcmp "github.com/google/go-cmp/cmp"
- "sketch.dev/httprr"
- "sketch.dev/llm/ant"
)
-var flagRewriteWant = flag.Bool("rewritewant", false, "rewrite the dockerfiles we want from the model")
-
-func TestCreateDockerfile(t *testing.T) {
- ctx := context.Background()
-
- tests := []struct {
- name string
- fsys fs.FS
- }{
- {
- name: "Basic repo with README",
- fsys: fstest.MapFS{
- "README.md": &fstest.MapFile{Data: []byte("# Test Project\nA Go project for testing.")},
- },
- },
- {
- // TODO: this looks bogus.
- name: "Repo with README and workflow",
- fsys: fstest.MapFS{
- "README.md": &fstest.MapFile{Data: []byte("# Test Project\nA Go project for testing.")},
- ".github/workflows/test.yml": &fstest.MapFile{Data: []byte(`name: Test
-on: [push]
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-node@v3
- with:
- node-version: '18'
- - name: Install and activate corepack
- run: |
- npm install -g corepack
- corepack enable
- - run: go test ./...`)},
- },
- },
- {
- name: "mention a devtool in the readme",
- fsys: fstest.MapFS{
- "readme.md": &fstest.MapFile{Data: []byte("# Test Project\nYou must install `dot` to run the tests.")},
- },
- },
- {
- name: "empty repo",
- fsys: fstest.MapFS{
- "main.go": &fstest.MapFile{Data: []byte("package main\n\nfunc main() {}")},
- },
- },
- {
- name: "python misery",
- fsys: fstest.MapFS{
- "README.md": &fstest.MapFile{Data: []byte("# Our amazing repo\n\nTo use this project you need python 3.11 and the dvc tool")},
- },
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- basePath := "testdata/" + strings.ToLower(strings.Replace(t.Name(), "/", "_", -1))
- rrPath := basePath + ".httprr"
- rr, err := httprr.Open(rrPath, http.DefaultTransport)
- if err != nil && !os.IsNotExist(err) {
- t.Fatal(err)
- }
- rr.ScrubReq(func(req *http.Request) error {
- req.Header.Del("x-api-key")
- return nil
- })
- initFiles, err := readInitFiles(tt.fsys)
- if err != nil {
- t.Fatal(err)
- }
- apiKey := cmp.Or(os.Getenv("OUTER_SKETCH_MODEL_API_KEY"), os.Getenv("ANTHROPIC_API_KEY"))
- srv := &ant.Service{
- APIKey: apiKey,
- HTTPC: rr.Client(),
- }
- result, err := createDockerfile(ctx, srv, initFiles, "", false)
- if err != nil {
- t.Fatal(err)
- }
-
- wantPath := basePath + ".dockerfile"
-
- if *flagRewriteWant {
- if err := os.WriteFile(wantPath, []byte(result), 0o666); err != nil {
- t.Fatal(err)
- }
- return
- }
-
- wantBytes, err := os.ReadFile(wantPath)
- if err != nil {
- t.Fatal(err)
- }
- want := string(wantBytes)
- if diff := gcmp.Diff(want, result); diff != "" {
- t.Errorf("dockerfile does not match. got:\n----\n%s\n----\n\ndiff: %s", result, diff)
- }
- })
- }
-}
-
-func TestReadInitFiles(t *testing.T) {
- testFS := fstest.MapFS{
- "README.md": &fstest.MapFile{Data: []byte("# Test Repo")},
- ".github/workflows/test.yml": &fstest.MapFile{Data: []byte("name: Test Workflow")},
- "main.go": &fstest.MapFile{Data: []byte("package main")},
- ".git/HEAD": &fstest.MapFile{Data: []byte("ref: refs/heads/main")},
- "random/README.md": &fstest.MapFile{Data: []byte("ignore me")},
- }
-
- files, err := readInitFiles(testFS)
- if err != nil {
- t.Fatalf("readInitFiles failed: %v", err)
- }
-
- // Should have 2 files: README.md and .github/workflows/test.yml
- if len(files) != 2 {
- t.Errorf("Expected 2 files, got %d", len(files))
- }
-
- if content, ok := files["README.md"]; !ok {
- t.Error("README.md not found")
- } else if content != "# Test Repo" {
- t.Errorf("README.md has incorrect content: %q", content)
- }
-
- if content, ok := files[".github/workflows/test.yml"]; !ok {
- t.Error(".github/workflows/test.yml not found")
- } else if content != "name: Test Workflow" {
- t.Errorf("Workflow file has incorrect content: %q", content)
- }
-
- if _, ok := files["main.go"]; ok {
- t.Error("main.go should not be included")
- }
-
- if _, ok := files[".git/HEAD"]; ok {
- t.Error(".git/HEAD should not be included")
- }
-}
-
-func TestReadInitFilesWithSubdir(t *testing.T) {
- // Create a file system with files in a subdirectory
- testFS := fstest.MapFS{
- "subdir/README.md": &fstest.MapFile{Data: []byte("# Test Repo")},
- "subdir/.github/workflows/test.yml": &fstest.MapFile{Data: []byte("name: Test Workflow")},
- "subdir/main.go": &fstest.MapFile{Data: []byte("package main")},
- }
-
- // Use fs.Sub to get a sub-filesystem
- subFS, err := fs.Sub(testFS, "subdir")
- if err != nil {
- t.Fatalf("fs.Sub failed: %v", err)
- }
-
- files, err := readInitFiles(subFS)
- if err != nil {
- t.Fatalf("readInitFiles failed: %v", err)
- }
-
- // Should have 2 files: README.md and .github/workflows/test.yml
- if len(files) != 2 {
- t.Errorf("Expected 2 files, got %d", len(files))
- }
-
- // Verify README.md was found
- if content, ok := files["README.md"]; !ok {
- t.Error("README.md not found")
- } else if content != "# Test Repo" {
- t.Errorf("README.md has incorrect content: %q", content)
- }
-
- // Verify workflow file was found
- if content, ok := files[".github/workflows/test.yml"]; !ok {
- t.Error(".github/workflows/test.yml not found")
- } else if content != "name: Test Workflow" {
- t.Errorf("Workflow file has incorrect content: %q", content)
- }
-}
-
-// TestDockerHashIsPushed ensures that any changes made to the
-// dockerfile template have been pushed to the default image.
+// TestDockerHashIsPushed tests that the published image hash is available
func TestDockerHashIsPushed(t *testing.T) {
- name, _, tag := DefaultImage()
+ // Skip this test if we can't reach the internet
+ if os.Getenv("CI") == "" {
+ t.Skip("Skipping test that requires internet access")
+ }
- if err := checkTagExists(tag); err != nil {
- if strings.Contains(err.Error(), "not found") {
- t.Fatalf(`Currently released docker image %s does not match dockerfileCustomTmpl.
+ if err := checkTagExists(dockerfileBaseHash()); err != nil {
+ t.Errorf("Docker image tag %s not found: %v", dockerfileBaseHash(), err)
+ }
-Inspecting the docker image shows the current hash of dockerfileBase is %s,
-but it is not published in the GitHub container registry.
-
-This means the template constants in createdockerfile.go have been
-edited (e.g. dockerfileBase changed), but a new version
-of the public default docker image has not been built and pushed.
-
-To do so:
-
- go run ./dockerimg/pushdockerimg.go
-
-`, name, tag)
- } else {
- t.Fatalf("checkTagExists: %v", err)
- }
+ // Test that the default image components are reasonable
+ name, dockerfile, tag := DefaultImage()
+ if name == "" {
+ t.Error("DefaultImage name is empty")
+ }
+ if dockerfile == "" {
+ t.Error("DefaultImage dockerfile is empty")
+ }
+ if tag == "" {
+ t.Error("DefaultImage tag is empty")
+ }
+ if len(tag) < 10 {
+ t.Errorf("DefaultImage tag suspiciously short: %s", tag)
}
}
+// TestGetHostGoCacheDirs tests that we can get the host Go cache directories
func TestGetHostGoCacheDirs(t *testing.T) {
+ if !RaceEnabled() {
+ t.Skip("Race detector not enabled, skipping test")
+ }
+
ctx := context.Background()
- // Test getHostGoCacheDir
goCacheDir, err := getHostGoCacheDir(ctx)
if err != nil {
t.Fatalf("getHostGoCacheDir failed: %v", err)
}
if goCacheDir == "" {
- t.Fatal("getHostGoCacheDir returned empty string")
+ t.Error("GOCACHE is empty")
}
- t.Logf("GOCACHE: %s", goCacheDir)
- // Test getHostGoModCacheDir
goModCacheDir, err := getHostGoModCacheDir(ctx)
if err != nil {
t.Fatalf("getHostGoModCacheDir failed: %v", err)
}
if goModCacheDir == "" {
- t.Fatal("getHostGoModCacheDir returned empty string")
+ t.Error("GOMODCACHE is empty")
}
+
+ t.Logf("GOCACHE: %s", goCacheDir)
t.Logf("GOMODCACHE: %s", goModCacheDir)
+}
- // Both should be absolute paths
- if !filepath.IsAbs(goCacheDir) {
- t.Errorf("GOCACHE is not an absolute path: %s", goCacheDir)
+// TestCreateCacheKey tests the cache key generation
+func TestCreateCacheKey(t *testing.T) {
+ key1 := createCacheKey("image1", "/path1")
+ key2 := createCacheKey("image2", "/path1")
+ key3 := createCacheKey("image1", "/path2")
+ key4 := createCacheKey("image1", "/path1")
+
+ // Different inputs should produce different keys
+ if key1 == key2 {
+ t.Error("Different base images should produce different cache keys")
}
- if !filepath.IsAbs(goModCacheDir) {
- t.Errorf("GOMODCACHE is not an absolute path: %s", goModCacheDir)
+ if key1 == key3 {
+ t.Error("Different paths should produce different cache keys")
+ }
+
+ // Same inputs should produce same key
+ if key1 != key4 {
+ t.Error("Same inputs should produce same cache key")
+ }
+
+ // Keys should be reasonably short
+ if len(key1) != 12 {
+ t.Errorf("Cache key length should be 12, got %d", len(key1))
}
}
-func TestPrioritizeDockerfiles(t *testing.T) {
- tests := []struct {
- name string
- candidates []string
- want string
- }{
- {
- name: "empty list",
- candidates: []string{},
- want: "",
- },
- {
- name: "single dockerfile",
- candidates: []string{"/path/to/Dockerfile"},
- want: "/path/to/Dockerfile",
- },
- {
- name: "dockerfile.sketch preferred over dockerfile",
- candidates: []string{"/path/to/Dockerfile", "/path/to/Dockerfile.sketch"},
- want: "/path/to/Dockerfile.sketch",
- },
- {
- name: "dockerfile.sketch preferred regardless of order",
- candidates: []string{"/path/to/Dockerfile.dev", "/path/to/Dockerfile", "/path/to/Dockerfile.sketch"},
- want: "/path/to/Dockerfile.sketch",
- },
- {
- name: "dockerfile preferred over other variations",
- candidates: []string{"/path/to/Dockerfile.dev", "/path/to/Dockerfile", "/path/to/Dockerfile.prod"},
- want: "/path/to/Dockerfile",
- },
- {
- name: "case insensitive dockerfile.sketch",
- candidates: []string{"/path/to/dockerfile", "/path/to/dockerfile.sketch"},
- want: "/path/to/dockerfile.sketch",
- },
- {
- name: "case insensitive dockerfile",
- candidates: []string{"/path/to/dockerfile.dev", "/path/to/dockerfile"},
- want: "/path/to/dockerfile",
- },
- {
- name: "first candidate when no priority matches",
- candidates: []string{"/path/to/Dockerfile.dev", "/path/to/Dockerfile.prod"},
- want: "/path/to/Dockerfile.dev",
- },
+// TestEnsureBaseImageExists tests the base image existence check and pull logic
+func TestEnsureBaseImageExists(t *testing.T) {
+ // This test would require Docker to be running and would make network calls
+ // So we'll skip it unless we're in an integration test environment
+ if testing.Short() {
+ t.Skip("Skipping integration test that requires Docker")
}
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := prioritizeDockerfiles(tt.candidates)
- if got != tt.want {
- t.Errorf("prioritizeDockerfiles() = %v, want %v", got, tt.want)
- }
- })
- }
-}
+ ctx := context.Background()
-func TestFindDirDockerfilesIntegration(t *testing.T) {
- // Create a temporary directory structure for testing
- tmpDir, err := os.MkdirTemp("", "dockerfiles-test-*")
- if err != nil {
- t.Fatal(err)
- }
- defer os.RemoveAll(tmpDir)
-
- // Create test files
- testFiles := []string{
- "Dockerfile",
- "Dockerfile.sketch",
- "Dockerfile.dev",
- "dockerfile.prod", // lowercase
- "README.md", // should be ignored
- }
-
- for _, file := range testFiles {
- path := filepath.Join(tmpDir, file)
- if err := os.WriteFile(path, []byte("# test"), 0o644); err != nil {
- t.Fatal(err)
- }
- }
-
- // Test findDirDockerfiles
- candidates, err := findDirDockerfiles(tmpDir)
- if err != nil {
- t.Fatal(err)
- }
-
- // Should find all Dockerfile* files but not README.md
- expectedCount := 4
- if len(candidates) != expectedCount {
- t.Errorf("findDirDockerfiles() found %d files, want %d", len(candidates), expectedCount)
- }
-
- // Test prioritization
- prioritized := prioritizeDockerfiles(candidates)
- expectedPriority := filepath.Join(tmpDir, "Dockerfile.sketch")
- if prioritized != expectedPriority {
- t.Errorf("prioritizeDockerfiles() = %v, want %v", prioritized, expectedPriority)
- }
-
- // Test with only Dockerfile (no Dockerfile.sketch)
- os.Remove(filepath.Join(tmpDir, "Dockerfile.sketch"))
- candidates, err = findDirDockerfiles(tmpDir)
- if err != nil {
- t.Fatal(err)
- }
- prioritized = prioritizeDockerfiles(candidates)
- expectedPriority = filepath.Join(tmpDir, "Dockerfile")
- if prioritized != expectedPriority {
- t.Errorf("prioritizeDockerfiles() without sketch = %v, want %v", prioritized, expectedPriority)
- }
-}
-
-func TestFindRepoDockerfiles(t *testing.T) {
- // Create a temporary directory structure that simulates a git repo
- tmpDir, err := os.MkdirTemp("", "repo-test-*")
- if err != nil {
- t.Fatal(err)
- }
- defer os.RemoveAll(tmpDir)
-
- // Create subdirectories
- subDir := filepath.Join(tmpDir, "subdir")
- if err := os.MkdirAll(subDir, 0o755); err != nil {
- t.Fatal(err)
- }
-
- // Create Dockerfile in subdirectory
- subDockerfile := filepath.Join(subDir, "Dockerfile")
- if err := os.WriteFile(subDockerfile, []byte("FROM ubuntu"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- // Create Dockerfile.sketch in parent directory
- rootSketchDockerfile := filepath.Join(tmpDir, "Dockerfile.sketch")
- if err := os.WriteFile(rootSketchDockerfile, []byte("FROM alpine"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- // Test: when called from subdirectory, should find subdirectory Dockerfile first
- candidates, err := findRepoDockerfiles(subDir, tmpDir)
- if err != nil {
- t.Fatal(err)
- }
-
- if len(candidates) == 0 {
- t.Fatal("expected to find at least one dockerfile")
- }
-
- // Should find the Dockerfile in the subdirectory
- if len(candidates) != 1 || candidates[0] != subDockerfile {
- t.Errorf("expected to find %s, but got %v", subDockerfile, candidates)
- }
-
- // Test: when called from root with no subdirectory dockerfile, should find root dockerfile
- os.Remove(subDockerfile) // Remove subdirectory dockerfile
- candidates, err = findRepoDockerfiles(tmpDir, tmpDir)
- if err != nil {
- t.Fatal(err)
- }
-
- if len(candidates) != 1 || candidates[0] != rootSketchDockerfile {
- t.Errorf("expected to find %s, but got %v", rootSketchDockerfile, candidates)
- }
-
- // Test prioritization: create both Dockerfile and Dockerfile.sketch in same directory
- rootDockerfile := filepath.Join(tmpDir, "Dockerfile")
- if err := os.WriteFile(rootDockerfile, []byte("FROM debian"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- candidates, err = findRepoDockerfiles(tmpDir, tmpDir)
- if err != nil {
- t.Fatal(err)
- }
-
- if len(candidates) != 2 {
- t.Errorf("expected to find 2 dockerfiles, but got %d", len(candidates))
- }
-
- // Test that prioritization works correctly
- prioritized := prioritizeDockerfiles(candidates)
- if prioritized != rootSketchDockerfile {
- t.Errorf("expected Dockerfile.sketch to be prioritized, but got %s", prioritized)
- }
-}
-
-// TestDockerfileSketchImageReuse tests that Docker images built from Dockerfile.sketch
-// are properly reused when the Dockerfile content hasn't changed.
-func TestDockerfileSketchImageReuse(t *testing.T) {
- // Create a temporary directory for the test repo
- tmpDir := t.TempDir()
-
- // Create a simple Dockerfile.sketch
- dockerfileContent := `FROM ubuntu:24.04
-LABEL test=true
-CMD ["echo", "hello"]
-`
- dockerfilePath := filepath.Join(tmpDir, "Dockerfile.sketch")
- err := os.WriteFile(dockerfilePath, []byte(dockerfileContent), 0o644)
- if err != nil {
- t.Fatalf("Failed to write Dockerfile.sketch: %v", err)
- }
-
- // Test that hashInitFiles produces consistent results
- initFiles := map[string]string{
- dockerfilePath: dockerfileContent,
- }
- hash1 := hashInitFiles(initFiles)
- hash2 := hashInitFiles(initFiles)
-
- if hash1 != hash2 {
- t.Errorf("hashInitFiles should be deterministic, got %s and %s", hash1, hash2)
- }
-
- // Test that hash changes when content changes
- modifiedFiles := map[string]string{
- dockerfilePath: dockerfileContent + "RUN echo modified\n",
- }
- hash3 := hashInitFiles(modifiedFiles)
-
- if hash1 == hash3 {
- t.Errorf("hashInitFiles should produce different hashes for different content, got %s for both", hash1)
- }
-
- t.Logf("Original hash: %s", hash1)
- t.Logf("Modified hash: %s", hash3)
-}
-
-// TestPrioritizeDockerfiles tests the Dockerfile prioritization logic
-func TestDockerfileSketchPriority(t *testing.T) {
- tests := []struct {
- name string
- candidates []string
- expected string
- }{
- {
- name: "dockerfile.sketch_wins_over_dockerfile",
- candidates: []string{"/path/Dockerfile", "/path/Dockerfile.sketch"},
- expected: "/path/Dockerfile.sketch",
- },
- {
- name: "dockerfile.sketch_case_insensitive",
- candidates: []string{"/path/dockerfile", "/path/dockerfile.sketch"},
- expected: "/path/dockerfile.sketch",
- },
- {
- name: "dockerfile_wins_over_variations",
- candidates: []string{"/path/Dockerfile.dev", "/path/Dockerfile"},
- expected: "/path/Dockerfile",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := prioritizeDockerfiles(tt.candidates)
- if result != tt.expected {
- t.Errorf("prioritizeDockerfiles(%v) = %s, want %s", tt.candidates, result, tt.expected)
- }
- })
+ // Test with a non-existent image (should fail gracefully)
+ err := ensureBaseImageExists(ctx, "nonexistent/image:tag")
+ if err == nil {
+ t.Error("Expected error for nonexistent image, got nil")
}
}
diff --git a/dockerimg/testdata/dockerfile_convo.httprr b/dockerimg/testdata/dockerfile_convo.httprr
deleted file mode 100644
index 0470323..0000000
--- a/dockerimg/testdata/dockerfile_convo.httprr
+++ /dev/null
@@ -1,30 +0,0 @@
-HTTP/1.1 200 OK
-Date: Wed, 05 Mar 2025 10:00:00 GMT
-Content-Type: application/json
-Content-Length: 987
-
-{
- "id": "msg_0123456789abcdef",
- "type": "message",
- "role": "assistant",
- "model": "claude-3-opus-20240229",
- "stop_reason": "tool_use",
- "stop_sequence": null,
- "usage": {
- "input_tokens": 256,
- "output_tokens": 180
- },
- "content": [
- {
- "type": "text",
- "text": "I'll create a Dockerfile for this Go project."
- }
- ],
- "tool_use": {
- "id": "tu_0123456789abcdef",
- "name": "dockerfile",
- "input": {
- "contents": "FROM golang:1.21-alpine\n\nWORKDIR /app\n\n# Install git for dependencies\nRUN apk add --no-cache git\n\n# Pre-copy/cache go.mod for efficient Docker caching\nCOPY go.mod go.sum* ./\nRUN go mod download\n\n# Copy the source code\nCOPY . .\n\n# Build the application\nRUN go build -o app ./cmd/sketch\n\n# Environment setup\nENV CGO_ENABLED=0\n\nCMD [\"./app\"]"
- }
- }
-}
diff --git a/dockerimg/testdata/testcreatedockerfile_basic_repo_with_readme.dockerfile b/dockerimg/testdata/testcreatedockerfile_basic_repo_with_readme.dockerfile
deleted file mode 100644
index 75ac856..0000000
--- a/dockerimg/testdata/testcreatedockerfile_basic_repo_with_readme.dockerfile
+++ /dev/null
@@ -1,25 +0,0 @@
-FROM ghcr.io/boldsoftware/sketch:82c32883426c519eada7250c0017e6b7
-
-ARG GIT_USER_EMAIL
-ARG GIT_USER_NAME
-
-RUN git config --global user.email "$GIT_USER_EMAIL" && \
- git config --global user.name "$GIT_USER_NAME" && \
- git config --global http.postBuffer 524288000
-
-LABEL sketch_context="b0ea3946bf71afcf3ab66a04750a8cc0e3c73e5395cadc8511bfdd7577d92bf5"
-COPY . /app
-RUN rm -f /app/tmp-sketch-dockerfile
-
-WORKDIR /app
-RUN if [ -f go.mod ]; then go mod download; fi
-
-# Switch to lenient shell so we are more likely to get past failing extra_cmds.
-SHELL ["/bin/bash", "-uo", "pipefail", "-c"]
-
-
-
-# Switch back to strict shell after extra_cmds.
-SHELL ["/bin/bash", "-euxo", "pipefail", "-c"]
-
-CMD ["/bin/sketch"]
diff --git a/dockerimg/testdata/testcreatedockerfile_basic_repo_with_readme.httprr b/dockerimg/testdata/testcreatedockerfile_basic_repo_with_readme.httprr
deleted file mode 100644
index 7585dd2..0000000
--- a/dockerimg/testdata/testcreatedockerfile_basic_repo_with_readme.httprr
+++ /dev/null
@@ -1,77 +0,0 @@
-httprr trace v1
-5645 1862
-POST https://api.anthropic.com/v1/messages HTTP/1.1
-Host: api.anthropic.com
-User-Agent: Go-http-client/1.1
-Content-Length: 5448
-Anthropic-Version: 2023-06-01
-Content-Type: application/json
-
-{
- "model": "claude-sonnet-4-20250514",
- "messages": [
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "\nCall the dockerfile tool to create a Dockerfile.\nThe parameters to dockerfile fill out the From and ExtraCmds\ntemplate variables in the following Go template:\n\n```\n# Stage 1: Get Chrome/Chromium from chromedp/headless-shell\nFROM docker.io/chromedp/headless-shell:stable AS chrome\n\n# Stage 2: Main application image\nFROM ubuntu:24.04\n\n# Switch from dash to bash by default.\nSHELL [\"/bin/bash\", \"-euxo\", \"pipefail\", \"-c\"]\n\n# attempt to keep package installs lean\nRUN printf '%s\\n' \\\n 'path-exclude=/usr/share/man/*' \\\n 'path-exclude=/usr/share/doc/*' \\\n 'path-exclude=/usr/share/doc-base/*' \\\n 'path-exclude=/usr/share/info/*' \\\n 'path-exclude=/usr/share/locale/*' \\\n 'path-exclude=/usr/share/groff/*' \\\n 'path-exclude=/usr/share/lintian/*' \\\n 'path-exclude=/usr/share/zoneinfo/*' \\\n \u003e /etc/dpkg/dpkg.cfg.d/01_nodoc\n\n# Install system packages (removed chromium, will use headless-shell instead)\nRUN apt-get update; \\\n\tapt-get install -y --no-install-recommends \\\n\t\tca-certificates wget \\\n\t\tgit jq sqlite3 npm nodejs gh ripgrep fzf python3 curl vim lsof iproute2 less \\\n\t\tdocker.io docker-compose-v2 docker-buildx \\\n\t\tmake python3-pip python-is-python3 tree net-tools file build-essential \\\n\t\tpipx cargo psmisc bsdmainutils openssh-client sudo \\\n\t\tunzip yarn util-linux \\\n\t\tlibglib2.0-0 libnss3 libx11-6 libxcomposite1 libxdamage1 \\\n\t\tlibxext6 libxi6 libxrandr2 libgbm1 libgtk-3-0 \\\n\t\tfonts-noto-color-emoji fonts-symbola \u0026\u0026 \\\n\tfc-cache -f -v \u0026\u0026 \\\n\tapt-get clean \u0026\u0026 \\\n\trm -rf /var/lib/apt/lists/* \u0026\u0026 \\\n\trm -rf /usr/share/{doc,doc-base,info,lintian,man,groff,locale,zoneinfo}/*\n\nRUN echo '{\"storage-driver\":\"vfs\", \"bridge\":\"none\", \"iptables\":false, \"ip-forward\": false}' \\\n\t\u003e /etc/docker/daemon.json\n\n# Install Go 1.24\nENV GO_VERSION=1.24.3\nENV GOROOT=/usr/local/go\nENV GOPATH=/go\nENV PATH=$GOROOT/bin:$GOPATH/bin:$PATH\n\nRUN ARCH=$(uname -m) \u0026\u0026 \\\n\tcase $ARCH in \\\n\t\tx86_64) GOARCH=amd64 ;; \\\n\t\taarch64) GOARCH=arm64 ;; \\\n\t\t*) echo \"Unsupported architecture: $ARCH\" \u0026\u0026 exit 1 ;; \\\n\tesac \u0026\u0026 \\\n\twget -O go.tar.gz \"https://golang.org/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz\" \u0026\u0026 \\\n\ttar -C /usr/local -xzf go.tar.gz \u0026\u0026 \\\n\trm go.tar.gz\n\n# Create GOPATH directory\nRUN mkdir -p \"$GOPATH/src\" \"$GOPATH/bin\" \u0026\u0026 chmod -R 755 \"$GOPATH\"\n\n# While these binaries install generally useful supporting packages,\n# the specific versions are rarely what a user wants so there is no\n# point polluting the base image module with them.\n\nRUN go install golang.org/x/tools/cmd/goimports@latest; \\\n\tgo install golang.org/x/tools/gopls@latest; \\\n\tgo install mvdan.cc/gofumpt@latest; \\\n\tgo clean -cache -testcache -modcache\n\n# Copy the self-contained Chrome bundle from chromedp/headless-shell\nCOPY --from=chrome /headless-shell /headless-shell\nENV PATH=\"/headless-shell:${PATH}\"\n\nENV GOTOOLCHAIN=auto\nENV SKETCH=1\n\nRUN mkdir -p /root/.cache/sketch/webui\n\nARG GIT_USER_EMAIL\nARG GIT_USER_NAME\n\nRUN git config --global user.email \"$GIT_USER_EMAIL\" \u0026\u0026 \\\n git config --global user.name \"$GIT_USER_NAME\" \u0026\u0026 \\\n git config --global http.postBuffer 524288000\n\nLABEL sketch_context=\"{{.InitFilesHash}}\"\nCOPY . /app\nRUN rm -f /app/tmp-sketch-dockerfile\n\nWORKDIR /app{{.SubDir}}\nRUN if [ -f go.mod ]; then go mod download; fi\n\n# Switch to lenient shell so we are more likely to get past failing extra_cmds.\nSHELL [\"/bin/bash\", \"-uo\", \"pipefail\", \"-c\"]\n\n{{.ExtraCmds}}\n\n# Switch back to strict shell after extra_cmds.\nSHELL [\"/bin/bash\", \"-euxo\", \"pipefail\", \"-c\"]\n\nCMD [\"/bin/sketch\"]\n\n```\n\nIn particular:\n- Assume it is primarily a Go project.\n- Python env setup is challenging and often no required, so any RUN commands involving python tooling should be written to let docker build continue if there is a failure.\n- Include any tools particular to this repository that can be inferred from the given context.\n- Append || true to any apt-get install commands in case the package does not exist.\n- MINIMIZE the number of extra_cmds generated. Straightforward environments do not need any.\n- Do NOT expose any ports.\n- Do NOT generate any CMD or ENTRYPOINT extra commands.\nHere is the content of several files from the repository that may be relevant:\n\n"
- },
- {
- "type": "text",
- "text": "Here is the contents README.md:\n\u003cfile\u003e\n# Test Project\nA Go project for testing.\n\u003c/file\u003e\n\n"
- },
- {
- "type": "text",
- "text": "Now call the dockerfile tool.",
- "cache_control": {
- "type": "ephemeral"
- }
- }
- ]
- }
- ],
- "max_tokens": 8192,
- "tools": [
- {
- "name": "dockerfile",
- "description": "Helps define a Dockerfile that sets up a dev environment for this project.",
- "input_schema": {
- "type": "object",
- "required": [
- "extra_cmds"
- ],
- "properties": {
- "extra_cmds": {
- "type": "string",
- "description": "Extra dockerfile commands to add to the dockerfile. Each command should start with RUN."
- }
- }
- }
- }
- ]
-}HTTP/2.0 200 OK
-Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
-Anthropic-Ratelimit-Input-Tokens-Limit: 200000
-Anthropic-Ratelimit-Input-Tokens-Remaining: 193000
-Anthropic-Ratelimit-Input-Tokens-Reset: 2025-07-03T02:23:13Z
-Anthropic-Ratelimit-Output-Tokens-Limit: 80000
-Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
-Anthropic-Ratelimit-Output-Tokens-Reset: 2025-07-03T02:23:13Z
-Anthropic-Ratelimit-Requests-Limit: 4000
-Anthropic-Ratelimit-Requests-Remaining: 3999
-Anthropic-Ratelimit-Requests-Reset: 2025-07-03T02:23:07Z
-Anthropic-Ratelimit-Tokens-Limit: 280000
-Anthropic-Ratelimit-Tokens-Remaining: 273000
-Anthropic-Ratelimit-Tokens-Reset: 2025-07-03T02:23:13Z
-Cf-Cache-Status: DYNAMIC
-Cf-Ray: 9592eb48cb85cee1-SJC
-Content-Type: application/json
-Date: Thu, 03 Jul 2025 02:23:13 GMT
-Request-Id: req_011CQjHkXgG2Uc4QD8k12R2Q
-Server: cloudflare
-Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
-Via: 1.1 google
-X-Robots-Tag: none
-
-{"id":"msg_01G8z6Lki9yGBu44HZZ2Zzvw","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Looking at the repository content, this appears to be a simple Go test project. The base Dockerfile template already includes comprehensive Go development environment setup with Go 1.24, common development tools, and Chrome/Chromium support.\n\nSince this is a straightforward Go project with minimal requirements based on the simple README, no additional setup commands are needed beyond what's already provided in the base template."},{"type":"tool_use","id":"toolu_018sfw6Wi4Xp4QMUgzSxeqNq","name":"dockerfile","input":{"extra_cmds":""}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":1893,"cache_read_input_tokens":0,"output_tokens":134,"service_tier":"standard"}}
\ No newline at end of file
diff --git a/dockerimg/testdata/testcreatedockerfile_empty_repo.dockerfile b/dockerimg/testdata/testcreatedockerfile_empty_repo.dockerfile
deleted file mode 100644
index 8934292..0000000
--- a/dockerimg/testdata/testcreatedockerfile_empty_repo.dockerfile
+++ /dev/null
@@ -1,25 +0,0 @@
-FROM ghcr.io/boldsoftware/sketch:82c32883426c519eada7250c0017e6b7
-
-ARG GIT_USER_EMAIL
-ARG GIT_USER_NAME
-
-RUN git config --global user.email "$GIT_USER_EMAIL" && \
- git config --global user.name "$GIT_USER_NAME" && \
- git config --global http.postBuffer 524288000
-
-LABEL sketch_context="69b535ce4ef4f44fc2994d9f117ccf1285b86ed626681c65c4c556e213cdc2f3"
-COPY . /app
-RUN rm -f /app/tmp-sketch-dockerfile
-
-WORKDIR /app
-RUN if [ -f go.mod ]; then go mod download; fi
-
-# Switch to lenient shell so we are more likely to get past failing extra_cmds.
-SHELL ["/bin/bash", "-uo", "pipefail", "-c"]
-
-RUN pip install --break-system-packages requests || true
-
-# Switch back to strict shell after extra_cmds.
-SHELL ["/bin/bash", "-euxo", "pipefail", "-c"]
-
-CMD ["/bin/sketch"]
diff --git a/dockerimg/testdata/testcreatedockerfile_empty_repo.httprr b/dockerimg/testdata/testcreatedockerfile_empty_repo.httprr
deleted file mode 100644
index 875e4ad..0000000
--- a/dockerimg/testdata/testcreatedockerfile_empty_repo.httprr
+++ /dev/null
@@ -1,73 +0,0 @@
-httprr trace v1
-5398 1683
-POST https://api.anthropic.com/v1/messages HTTP/1.1
-Host: api.anthropic.com
-User-Agent: Go-http-client/1.1
-Content-Length: 5201
-Anthropic-Version: 2023-06-01
-Content-Type: application/json
-
-{
- "model": "claude-sonnet-4-20250514",
- "messages": [
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "\nCall the dockerfile tool to create a Dockerfile.\nThe parameters to dockerfile fill out the From and ExtraCmds\ntemplate variables in the following Go template:\n\n```\n# Stage 1: Get Chrome/Chromium from chromedp/headless-shell\nFROM docker.io/chromedp/headless-shell:stable AS chrome\n\n# Stage 2: Main application image\nFROM ubuntu:24.04\n\n# Switch from dash to bash by default.\nSHELL [\"/bin/bash\", \"-euxo\", \"pipefail\", \"-c\"]\n\n# attempt to keep package installs lean\nRUN printf '%s\\n' \\\n 'path-exclude=/usr/share/man/*' \\\n 'path-exclude=/usr/share/doc/*' \\\n 'path-exclude=/usr/share/doc-base/*' \\\n 'path-exclude=/usr/share/info/*' \\\n 'path-exclude=/usr/share/locale/*' \\\n 'path-exclude=/usr/share/groff/*' \\\n 'path-exclude=/usr/share/lintian/*' \\\n 'path-exclude=/usr/share/zoneinfo/*' \\\n \u003e /etc/dpkg/dpkg.cfg.d/01_nodoc\n\n# Install system packages (removed chromium, will use headless-shell instead)\nRUN apt-get update; \\\n\tapt-get install -y --no-install-recommends \\\n\t\tca-certificates wget \\\n\t\tgit jq sqlite3 npm nodejs gh ripgrep fzf python3 curl vim lsof iproute2 less \\\n\t\tdocker.io docker-compose-v2 docker-buildx \\\n\t\tmake python3-pip python-is-python3 tree net-tools file build-essential \\\n\t\tpipx cargo psmisc bsdmainutils openssh-client sudo \\\n\t\tunzip yarn util-linux \\\n\t\tlibglib2.0-0 libnss3 libx11-6 libxcomposite1 libxdamage1 \\\n\t\tlibxext6 libxi6 libxrandr2 libgbm1 libgtk-3-0 \\\n\t\tfonts-noto-color-emoji fonts-symbola \u0026\u0026 \\\n\tfc-cache -f -v \u0026\u0026 \\\n\tapt-get clean \u0026\u0026 \\\n\trm -rf /var/lib/apt/lists/* \u0026\u0026 \\\n\trm -rf /usr/share/{doc,doc-base,info,lintian,man,groff,locale,zoneinfo}/*\n\nRUN echo '{\"storage-driver\":\"vfs\", \"bridge\":\"none\", \"iptables\":false, \"ip-forward\": false}' \\\n\t\u003e /etc/docker/daemon.json\n\n# Install Go 1.24\nENV GO_VERSION=1.24.3\nENV GOROOT=/usr/local/go\nENV GOPATH=/go\nENV PATH=$GOROOT/bin:$GOPATH/bin:$PATH\n\nRUN ARCH=$(uname -m) \u0026\u0026 \\\n\tcase $ARCH in \\\n\t\tx86_64) GOARCH=amd64 ;; \\\n\t\taarch64) GOARCH=arm64 ;; \\\n\t\t*) echo \"Unsupported architecture: $ARCH\" \u0026\u0026 exit 1 ;; \\\n\tesac \u0026\u0026 \\\n\twget -O go.tar.gz \"https://golang.org/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz\" \u0026\u0026 \\\n\ttar -C /usr/local -xzf go.tar.gz \u0026\u0026 \\\n\trm go.tar.gz\n\n# Create GOPATH directory\nRUN mkdir -p \"$GOPATH/src\" \"$GOPATH/bin\" \u0026\u0026 chmod -R 755 \"$GOPATH\"\n\n# While these binaries install generally useful supporting packages,\n# the specific versions are rarely what a user wants so there is no\n# point polluting the base image module with them.\n\nRUN go install golang.org/x/tools/cmd/goimports@latest; \\\n\tgo install golang.org/x/tools/gopls@latest; \\\n\tgo install mvdan.cc/gofumpt@latest; \\\n\tgo clean -cache -testcache -modcache\n\n# Copy the self-contained Chrome bundle from chromedp/headless-shell\nCOPY --from=chrome /headless-shell /headless-shell\nENV PATH=\"/headless-shell:${PATH}\"\n\nENV GOTOOLCHAIN=auto\nENV SKETCH=1\n\nRUN mkdir -p /root/.cache/sketch/webui\n\nARG GIT_USER_EMAIL\nARG GIT_USER_NAME\n\nRUN git config --global user.email \"$GIT_USER_EMAIL\" \u0026\u0026 \\\n git config --global user.name \"$GIT_USER_NAME\" \u0026\u0026 \\\n git config --global http.postBuffer 524288000\n\nLABEL sketch_context=\"{{.InitFilesHash}}\"\nCOPY . /app\nRUN rm -f /app/tmp-sketch-dockerfile\n\nWORKDIR /app{{.SubDir}}\nRUN if [ -f go.mod ]; then go mod download; fi\n\n# Switch to lenient shell so we are more likely to get past failing extra_cmds.\nSHELL [\"/bin/bash\", \"-uo\", \"pipefail\", \"-c\"]\n\n{{.ExtraCmds}}\n\n# Switch back to strict shell after extra_cmds.\nSHELL [\"/bin/bash\", \"-euxo\", \"pipefail\", \"-c\"]\n\nCMD [\"/bin/sketch\"]\n\n```\n\nIn particular:\n- Assume it is primarily a Go project.\n- Python env setup is challenging and often no required, so any RUN commands involving python tooling should be written to let docker build continue if there is a failure.\n- Include any tools particular to this repository that can be inferred from the given context.\n- Append || true to any apt-get install commands in case the package does not exist.\n- MINIMIZE the number of extra_cmds generated. Straightforward environments do not need any.\n- Do NOT expose any ports.\n- Do NOT generate any CMD or ENTRYPOINT extra commands.\n"
- },
- {
- "type": "text",
- "text": "Now call the dockerfile tool.",
- "cache_control": {
- "type": "ephemeral"
- }
- }
- ]
- }
- ],
- "max_tokens": 8192,
- "tools": [
- {
- "name": "dockerfile",
- "description": "Helps define a Dockerfile that sets up a dev environment for this project.",
- "input_schema": {
- "type": "object",
- "required": [
- "extra_cmds"
- ],
- "properties": {
- "extra_cmds": {
- "type": "string",
- "description": "Extra dockerfile commands to add to the dockerfile. Each command should start with RUN."
- }
- }
- }
- }
- ]
-}HTTP/2.0 200 OK
-Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
-Anthropic-Ratelimit-Input-Tokens-Limit: 200000
-Anthropic-Ratelimit-Input-Tokens-Remaining: 195000
-Anthropic-Ratelimit-Input-Tokens-Reset: 2025-07-03T02:23:27Z
-Anthropic-Ratelimit-Output-Tokens-Limit: 80000
-Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
-Anthropic-Ratelimit-Output-Tokens-Reset: 2025-07-03T02:23:27Z
-Anthropic-Ratelimit-Requests-Limit: 4000
-Anthropic-Ratelimit-Requests-Remaining: 3999
-Anthropic-Ratelimit-Requests-Reset: 2025-07-03T02:23:24Z
-Anthropic-Ratelimit-Tokens-Limit: 280000
-Anthropic-Ratelimit-Tokens-Remaining: 275000
-Anthropic-Ratelimit-Tokens-Reset: 2025-07-03T02:23:27Z
-Cf-Cache-Status: DYNAMIC
-Cf-Ray: 9592ebaf3e27cee1-SJC
-Content-Type: application/json
-Date: Thu, 03 Jul 2025 02:23:27 GMT
-Request-Id: req_011CQjHmjwEc8WR1R9czxg4J
-Server: cloudflare
-Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
-Via: 1.1 google
-X-Robots-Tag: none
-
-{"id":"msg_017MdDq5NfaUrpJaQ8t52MNi","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"I'll create a Dockerfile for this Go project. Since this appears to be a straightforward Go project and the base template already includes comprehensive tooling, I'll keep the extra commands minimal."},{"type":"tool_use","id":"toolu_01EZhm1tjFsVjaJNFVWmyfUt","name":"dockerfile","input":{"extra_cmds":"RUN pip install --break-system-packages requests || true"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":1849,"cache_read_input_tokens":0,"output_tokens":106,"service_tier":"standard"}}
\ No newline at end of file
diff --git a/dockerimg/testdata/testcreatedockerfile_mention_a_devtool_in_the_readme.dockerfile b/dockerimg/testdata/testcreatedockerfile_mention_a_devtool_in_the_readme.dockerfile
deleted file mode 100644
index 20345bc..0000000
--- a/dockerimg/testdata/testcreatedockerfile_mention_a_devtool_in_the_readme.dockerfile
+++ /dev/null
@@ -1,25 +0,0 @@
-FROM ghcr.io/boldsoftware/sketch:82c32883426c519eada7250c0017e6b7
-
-ARG GIT_USER_EMAIL
-ARG GIT_USER_NAME
-
-RUN git config --global user.email "$GIT_USER_EMAIL" && \
- git config --global user.name "$GIT_USER_NAME" && \
- git config --global http.postBuffer 524288000
-
-LABEL sketch_context="ce6ac178288b581a0150d5a276672839472977f9ac3451cfb682d5f29d9cfd06"
-COPY . /app
-RUN rm -f /app/tmp-sketch-dockerfile
-
-WORKDIR /app
-RUN if [ -f go.mod ]; then go mod download; fi
-
-# Switch to lenient shell so we are more likely to get past failing extra_cmds.
-SHELL ["/bin/bash", "-uo", "pipefail", "-c"]
-
-RUN apt-get update && apt-get install -y --no-install-recommends graphviz || true
-
-# Switch back to strict shell after extra_cmds.
-SHELL ["/bin/bash", "-euxo", "pipefail", "-c"]
-
-CMD ["/bin/sketch"]
diff --git a/dockerimg/testdata/testcreatedockerfile_mention_a_devtool_in_the_readme.httprr b/dockerimg/testdata/testcreatedockerfile_mention_a_devtool_in_the_readme.httprr
deleted file mode 100644
index b84afc0..0000000
--- a/dockerimg/testdata/testcreatedockerfile_mention_a_devtool_in_the_readme.httprr
+++ /dev/null
@@ -1,77 +0,0 @@
-httprr trace v1
-5660 1482
-POST https://api.anthropic.com/v1/messages HTTP/1.1
-Host: api.anthropic.com
-User-Agent: Go-http-client/1.1
-Content-Length: 5463
-Anthropic-Version: 2023-06-01
-Content-Type: application/json
-
-{
- "model": "claude-sonnet-4-20250514",
- "messages": [
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "\nCall the dockerfile tool to create a Dockerfile.\nThe parameters to dockerfile fill out the From and ExtraCmds\ntemplate variables in the following Go template:\n\n```\n# Stage 1: Get Chrome/Chromium from chromedp/headless-shell\nFROM docker.io/chromedp/headless-shell:stable AS chrome\n\n# Stage 2: Main application image\nFROM ubuntu:24.04\n\n# Switch from dash to bash by default.\nSHELL [\"/bin/bash\", \"-euxo\", \"pipefail\", \"-c\"]\n\n# attempt to keep package installs lean\nRUN printf '%s\\n' \\\n 'path-exclude=/usr/share/man/*' \\\n 'path-exclude=/usr/share/doc/*' \\\n 'path-exclude=/usr/share/doc-base/*' \\\n 'path-exclude=/usr/share/info/*' \\\n 'path-exclude=/usr/share/locale/*' \\\n 'path-exclude=/usr/share/groff/*' \\\n 'path-exclude=/usr/share/lintian/*' \\\n 'path-exclude=/usr/share/zoneinfo/*' \\\n \u003e /etc/dpkg/dpkg.cfg.d/01_nodoc\n\n# Install system packages (removed chromium, will use headless-shell instead)\nRUN apt-get update; \\\n\tapt-get install -y --no-install-recommends \\\n\t\tca-certificates wget \\\n\t\tgit jq sqlite3 npm nodejs gh ripgrep fzf python3 curl vim lsof iproute2 less \\\n\t\tdocker.io docker-compose-v2 docker-buildx \\\n\t\tmake python3-pip python-is-python3 tree net-tools file build-essential \\\n\t\tpipx cargo psmisc bsdmainutils openssh-client sudo \\\n\t\tunzip yarn util-linux \\\n\t\tlibglib2.0-0 libnss3 libx11-6 libxcomposite1 libxdamage1 \\\n\t\tlibxext6 libxi6 libxrandr2 libgbm1 libgtk-3-0 \\\n\t\tfonts-noto-color-emoji fonts-symbola \u0026\u0026 \\\n\tfc-cache -f -v \u0026\u0026 \\\n\tapt-get clean \u0026\u0026 \\\n\trm -rf /var/lib/apt/lists/* \u0026\u0026 \\\n\trm -rf /usr/share/{doc,doc-base,info,lintian,man,groff,locale,zoneinfo}/*\n\nRUN echo '{\"storage-driver\":\"vfs\", \"bridge\":\"none\", \"iptables\":false, \"ip-forward\": false}' \\\n\t\u003e /etc/docker/daemon.json\n\n# Install Go 1.24\nENV GO_VERSION=1.24.3\nENV GOROOT=/usr/local/go\nENV GOPATH=/go\nENV PATH=$GOROOT/bin:$GOPATH/bin:$PATH\n\nRUN ARCH=$(uname -m) \u0026\u0026 \\\n\tcase $ARCH in \\\n\t\tx86_64) GOARCH=amd64 ;; \\\n\t\taarch64) GOARCH=arm64 ;; \\\n\t\t*) echo \"Unsupported architecture: $ARCH\" \u0026\u0026 exit 1 ;; \\\n\tesac \u0026\u0026 \\\n\twget -O go.tar.gz \"https://golang.org/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz\" \u0026\u0026 \\\n\ttar -C /usr/local -xzf go.tar.gz \u0026\u0026 \\\n\trm go.tar.gz\n\n# Create GOPATH directory\nRUN mkdir -p \"$GOPATH/src\" \"$GOPATH/bin\" \u0026\u0026 chmod -R 755 \"$GOPATH\"\n\n# While these binaries install generally useful supporting packages,\n# the specific versions are rarely what a user wants so there is no\n# point polluting the base image module with them.\n\nRUN go install golang.org/x/tools/cmd/goimports@latest; \\\n\tgo install golang.org/x/tools/gopls@latest; \\\n\tgo install mvdan.cc/gofumpt@latest; \\\n\tgo clean -cache -testcache -modcache\n\n# Copy the self-contained Chrome bundle from chromedp/headless-shell\nCOPY --from=chrome /headless-shell /headless-shell\nENV PATH=\"/headless-shell:${PATH}\"\n\nENV GOTOOLCHAIN=auto\nENV SKETCH=1\n\nRUN mkdir -p /root/.cache/sketch/webui\n\nARG GIT_USER_EMAIL\nARG GIT_USER_NAME\n\nRUN git config --global user.email \"$GIT_USER_EMAIL\" \u0026\u0026 \\\n git config --global user.name \"$GIT_USER_NAME\" \u0026\u0026 \\\n git config --global http.postBuffer 524288000\n\nLABEL sketch_context=\"{{.InitFilesHash}}\"\nCOPY . /app\nRUN rm -f /app/tmp-sketch-dockerfile\n\nWORKDIR /app{{.SubDir}}\nRUN if [ -f go.mod ]; then go mod download; fi\n\n# Switch to lenient shell so we are more likely to get past failing extra_cmds.\nSHELL [\"/bin/bash\", \"-uo\", \"pipefail\", \"-c\"]\n\n{{.ExtraCmds}}\n\n# Switch back to strict shell after extra_cmds.\nSHELL [\"/bin/bash\", \"-euxo\", \"pipefail\", \"-c\"]\n\nCMD [\"/bin/sketch\"]\n\n```\n\nIn particular:\n- Assume it is primarily a Go project.\n- Python env setup is challenging and often no required, so any RUN commands involving python tooling should be written to let docker build continue if there is a failure.\n- Include any tools particular to this repository that can be inferred from the given context.\n- Append || true to any apt-get install commands in case the package does not exist.\n- MINIMIZE the number of extra_cmds generated. Straightforward environments do not need any.\n- Do NOT expose any ports.\n- Do NOT generate any CMD or ENTRYPOINT extra commands.\nHere is the content of several files from the repository that may be relevant:\n\n"
- },
- {
- "type": "text",
- "text": "Here is the contents readme.md:\n\u003cfile\u003e\n# Test Project\nYou must install `dot` to run the tests.\n\u003c/file\u003e\n\n"
- },
- {
- "type": "text",
- "text": "Now call the dockerfile tool.",
- "cache_control": {
- "type": "ephemeral"
- }
- }
- ]
- }
- ],
- "max_tokens": 8192,
- "tools": [
- {
- "name": "dockerfile",
- "description": "Helps define a Dockerfile that sets up a dev environment for this project.",
- "input_schema": {
- "type": "object",
- "required": [
- "extra_cmds"
- ],
- "properties": {
- "extra_cmds": {
- "type": "string",
- "description": "Extra dockerfile commands to add to the dockerfile. Each command should start with RUN."
- }
- }
- }
- }
- ]
-}HTTP/2.0 200 OK
-Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
-Anthropic-Ratelimit-Input-Tokens-Limit: 200000
-Anthropic-Ratelimit-Input-Tokens-Remaining: 200000
-Anthropic-Ratelimit-Input-Tokens-Reset: 2025-07-03T02:23:23Z
-Anthropic-Ratelimit-Output-Tokens-Limit: 80000
-Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
-Anthropic-Ratelimit-Output-Tokens-Reset: 2025-07-03T02:23:23Z
-Anthropic-Ratelimit-Requests-Limit: 4000
-Anthropic-Ratelimit-Requests-Remaining: 3999
-Anthropic-Ratelimit-Requests-Reset: 2025-07-03T02:23:21Z
-Anthropic-Ratelimit-Tokens-Limit: 280000
-Anthropic-Ratelimit-Tokens-Remaining: 280000
-Anthropic-Ratelimit-Tokens-Reset: 2025-07-03T02:23:23Z
-Cf-Cache-Status: DYNAMIC
-Cf-Ray: 9592eb9d9eeecee1-SJC
-Content-Type: application/json
-Date: Thu, 03 Jul 2025 02:23:23 GMT
-Request-Id: req_011CQjHmXswmoBa2Dw3R3DJf
-Server: cloudflare
-Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
-Via: 1.1 google
-X-Robots-Tag: none
-
-{"id":"msg_01EbvL8BZ1RFxmeSEfCWhu2Y","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"tool_use","id":"toolu_017GjbjokM8g5WgiZwJqHmcq","name":"dockerfile","input":{"extra_cmds":"RUN apt-get update && apt-get install -y --no-install-recommends graphviz || true"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":1898,"cache_read_input_tokens":0,"output_tokens":78,"service_tier":"standard"}}
\ No newline at end of file
diff --git a/dockerimg/testdata/testcreatedockerfile_python_misery.dockerfile b/dockerimg/testdata/testcreatedockerfile_python_misery.dockerfile
deleted file mode 100644
index 6345303..0000000
--- a/dockerimg/testdata/testcreatedockerfile_python_misery.dockerfile
+++ /dev/null
@@ -1,30 +0,0 @@
-FROM ghcr.io/boldsoftware/sketch:82c32883426c519eada7250c0017e6b7
-
-ARG GIT_USER_EMAIL
-ARG GIT_USER_NAME
-
-RUN git config --global user.email "$GIT_USER_EMAIL" && \
- git config --global user.name "$GIT_USER_NAME" && \
- git config --global http.postBuffer 524288000
-
-LABEL sketch_context="be15efa1d150735bb7ebd1cd8d9dee577f7bbad44e540822deb20a57988cbe1f"
-COPY . /app
-RUN rm -f /app/tmp-sketch-dockerfile
-
-WORKDIR /app
-RUN if [ -f go.mod ]; then go mod download; fi
-
-# Switch to lenient shell so we are more likely to get past failing extra_cmds.
-SHELL ["/bin/bash", "-uo", "pipefail", "-c"]
-
-RUN apt-get update && apt-get install -y --no-install-recommends \
- python3.11 python3.11-dev python3.11-venv python3-pip-whl || true && \
- apt-get clean && rm -rf /var/lib/apt/lists/* || true
-
-RUN python3.11 -m pip install --upgrade pip setuptools wheel || true
-RUN python3.11 -m pip install dvc || true
-
-# Switch back to strict shell after extra_cmds.
-SHELL ["/bin/bash", "-euxo", "pipefail", "-c"]
-
-CMD ["/bin/sketch"]
diff --git a/dockerimg/testdata/testcreatedockerfile_python_misery.httprr b/dockerimg/testdata/testcreatedockerfile_python_misery.httprr
deleted file mode 100644
index ff8c0bd..0000000
--- a/dockerimg/testdata/testcreatedockerfile_python_misery.httprr
+++ /dev/null
@@ -1,77 +0,0 @@
-httprr trace v1
-5683 1959
-POST https://api.anthropic.com/v1/messages HTTP/1.1
-Host: api.anthropic.com
-User-Agent: Go-http-client/1.1
-Content-Length: 5486
-Anthropic-Version: 2023-06-01
-Content-Type: application/json
-
-{
- "model": "claude-sonnet-4-20250514",
- "messages": [
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "\nCall the dockerfile tool to create a Dockerfile.\nThe parameters to dockerfile fill out the From and ExtraCmds\ntemplate variables in the following Go template:\n\n```\n# Stage 1: Get Chrome/Chromium from chromedp/headless-shell\nFROM docker.io/chromedp/headless-shell:stable AS chrome\n\n# Stage 2: Main application image\nFROM ubuntu:24.04\n\n# Switch from dash to bash by default.\nSHELL [\"/bin/bash\", \"-euxo\", \"pipefail\", \"-c\"]\n\n# attempt to keep package installs lean\nRUN printf '%s\\n' \\\n 'path-exclude=/usr/share/man/*' \\\n 'path-exclude=/usr/share/doc/*' \\\n 'path-exclude=/usr/share/doc-base/*' \\\n 'path-exclude=/usr/share/info/*' \\\n 'path-exclude=/usr/share/locale/*' \\\n 'path-exclude=/usr/share/groff/*' \\\n 'path-exclude=/usr/share/lintian/*' \\\n 'path-exclude=/usr/share/zoneinfo/*' \\\n \u003e /etc/dpkg/dpkg.cfg.d/01_nodoc\n\n# Install system packages (removed chromium, will use headless-shell instead)\nRUN apt-get update; \\\n\tapt-get install -y --no-install-recommends \\\n\t\tca-certificates wget \\\n\t\tgit jq sqlite3 npm nodejs gh ripgrep fzf python3 curl vim lsof iproute2 less \\\n\t\tdocker.io docker-compose-v2 docker-buildx \\\n\t\tmake python3-pip python-is-python3 tree net-tools file build-essential \\\n\t\tpipx cargo psmisc bsdmainutils openssh-client sudo \\\n\t\tunzip yarn util-linux \\\n\t\tlibglib2.0-0 libnss3 libx11-6 libxcomposite1 libxdamage1 \\\n\t\tlibxext6 libxi6 libxrandr2 libgbm1 libgtk-3-0 \\\n\t\tfonts-noto-color-emoji fonts-symbola \u0026\u0026 \\\n\tfc-cache -f -v \u0026\u0026 \\\n\tapt-get clean \u0026\u0026 \\\n\trm -rf /var/lib/apt/lists/* \u0026\u0026 \\\n\trm -rf /usr/share/{doc,doc-base,info,lintian,man,groff,locale,zoneinfo}/*\n\nRUN echo '{\"storage-driver\":\"vfs\", \"bridge\":\"none\", \"iptables\":false, \"ip-forward\": false}' \\\n\t\u003e /etc/docker/daemon.json\n\n# Install Go 1.24\nENV GO_VERSION=1.24.3\nENV GOROOT=/usr/local/go\nENV GOPATH=/go\nENV PATH=$GOROOT/bin:$GOPATH/bin:$PATH\n\nRUN ARCH=$(uname -m) \u0026\u0026 \\\n\tcase $ARCH in \\\n\t\tx86_64) GOARCH=amd64 ;; \\\n\t\taarch64) GOARCH=arm64 ;; \\\n\t\t*) echo \"Unsupported architecture: $ARCH\" \u0026\u0026 exit 1 ;; \\\n\tesac \u0026\u0026 \\\n\twget -O go.tar.gz \"https://golang.org/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz\" \u0026\u0026 \\\n\ttar -C /usr/local -xzf go.tar.gz \u0026\u0026 \\\n\trm go.tar.gz\n\n# Create GOPATH directory\nRUN mkdir -p \"$GOPATH/src\" \"$GOPATH/bin\" \u0026\u0026 chmod -R 755 \"$GOPATH\"\n\n# While these binaries install generally useful supporting packages,\n# the specific versions are rarely what a user wants so there is no\n# point polluting the base image module with them.\n\nRUN go install golang.org/x/tools/cmd/goimports@latest; \\\n\tgo install golang.org/x/tools/gopls@latest; \\\n\tgo install mvdan.cc/gofumpt@latest; \\\n\tgo clean -cache -testcache -modcache\n\n# Copy the self-contained Chrome bundle from chromedp/headless-shell\nCOPY --from=chrome /headless-shell /headless-shell\nENV PATH=\"/headless-shell:${PATH}\"\n\nENV GOTOOLCHAIN=auto\nENV SKETCH=1\n\nRUN mkdir -p /root/.cache/sketch/webui\n\nARG GIT_USER_EMAIL\nARG GIT_USER_NAME\n\nRUN git config --global user.email \"$GIT_USER_EMAIL\" \u0026\u0026 \\\n git config --global user.name \"$GIT_USER_NAME\" \u0026\u0026 \\\n git config --global http.postBuffer 524288000\n\nLABEL sketch_context=\"{{.InitFilesHash}}\"\nCOPY . /app\nRUN rm -f /app/tmp-sketch-dockerfile\n\nWORKDIR /app{{.SubDir}}\nRUN if [ -f go.mod ]; then go mod download; fi\n\n# Switch to lenient shell so we are more likely to get past failing extra_cmds.\nSHELL [\"/bin/bash\", \"-uo\", \"pipefail\", \"-c\"]\n\n{{.ExtraCmds}}\n\n# Switch back to strict shell after extra_cmds.\nSHELL [\"/bin/bash\", \"-euxo\", \"pipefail\", \"-c\"]\n\nCMD [\"/bin/sketch\"]\n\n```\n\nIn particular:\n- Assume it is primarily a Go project.\n- Python env setup is challenging and often no required, so any RUN commands involving python tooling should be written to let docker build continue if there is a failure.\n- Include any tools particular to this repository that can be inferred from the given context.\n- Append || true to any apt-get install commands in case the package does not exist.\n- MINIMIZE the number of extra_cmds generated. Straightforward environments do not need any.\n- Do NOT expose any ports.\n- Do NOT generate any CMD or ENTRYPOINT extra commands.\nHere is the content of several files from the repository that may be relevant:\n\n"
- },
- {
- "type": "text",
- "text": "Here is the contents README.md:\n\u003cfile\u003e\n# Our amazing repo\n\nTo use this project you need python 3.11 and the dvc tool\n\u003c/file\u003e\n\n"
- },
- {
- "type": "text",
- "text": "Now call the dockerfile tool.",
- "cache_control": {
- "type": "ephemeral"
- }
- }
- ]
- }
- ],
- "max_tokens": 8192,
- "tools": [
- {
- "name": "dockerfile",
- "description": "Helps define a Dockerfile that sets up a dev environment for this project.",
- "input_schema": {
- "type": "object",
- "required": [
- "extra_cmds"
- ],
- "properties": {
- "extra_cmds": {
- "type": "string",
- "description": "Extra dockerfile commands to add to the dockerfile. Each command should start with RUN."
- }
- }
- }
- }
- ]
-}HTTP/2.0 200 OK
-Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
-Anthropic-Ratelimit-Input-Tokens-Limit: 200000
-Anthropic-Ratelimit-Input-Tokens-Remaining: 200000
-Anthropic-Ratelimit-Input-Tokens-Reset: 2025-07-03T02:23:29Z
-Anthropic-Ratelimit-Output-Tokens-Limit: 80000
-Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
-Anthropic-Ratelimit-Output-Tokens-Reset: 2025-07-03T02:23:32Z
-Anthropic-Ratelimit-Requests-Limit: 4000
-Anthropic-Ratelimit-Requests-Remaining: 3999
-Anthropic-Ratelimit-Requests-Reset: 2025-07-03T02:23:27Z
-Anthropic-Ratelimit-Tokens-Limit: 280000
-Anthropic-Ratelimit-Tokens-Remaining: 280000
-Anthropic-Ratelimit-Tokens-Reset: 2025-07-03T02:23:29Z
-Cf-Cache-Status: DYNAMIC
-Cf-Ray: 9592ebc67c9ccee1-SJC
-Content-Type: application/json
-Date: Thu, 03 Jul 2025 02:23:32 GMT
-Request-Id: req_011CQjHn1dJMkdRcVGV6qBfs
-Server: cloudflare
-Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
-Via: 1.1 google
-X-Robots-Tag: none
-
-{"id":"msg_019b6jYNUut2Qb8b43tXtjDL","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Based on the repository content, I can see this is a Go project that also requires Python 3.11 and the DVC tool. I'll create a Dockerfile that sets up both environments while following the guidelines you specified."},{"type":"tool_use","id":"toolu_015iQvkvp7da1UTr5d1xbUBw","name":"dockerfile","input":{"extra_cmds":"RUN apt-get update && apt-get install -y --no-install-recommends \\\n python3.11 python3.11-dev python3.11-venv python3-pip-whl || true && \\\n apt-get clean && rm -rf /var/lib/apt/lists/* || true\n\nRUN python3.11 -m pip install --upgrade pip setuptools wheel || true\nRUN python3.11 -m pip install dvc || true"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":1905,"cache_read_input_tokens":0,"output_tokens":210,"service_tier":"standard"}}
\ No newline at end of file
diff --git a/dockerimg/testdata/testcreatedockerfile_repo_with_readme_and_workflow.dockerfile b/dockerimg/testdata/testcreatedockerfile_repo_with_readme_and_workflow.dockerfile
deleted file mode 100644
index 9b2c9d1..0000000
--- a/dockerimg/testdata/testcreatedockerfile_repo_with_readme_and_workflow.dockerfile
+++ /dev/null
@@ -1,25 +0,0 @@
-FROM ghcr.io/boldsoftware/sketch:82c32883426c519eada7250c0017e6b7
-
-ARG GIT_USER_EMAIL
-ARG GIT_USER_NAME
-
-RUN git config --global user.email "$GIT_USER_EMAIL" && \
- git config --global user.name "$GIT_USER_NAME" && \
- git config --global http.postBuffer 524288000
-
-LABEL sketch_context="214644764ea720ced07603791df51b9c1d41483d10171d2eb4905b9e8c40460c"
-COPY . /app
-RUN rm -f /app/tmp-sketch-dockerfile
-
-WORKDIR /app
-RUN if [ -f go.mod ]; then go mod download; fi
-
-# Switch to lenient shell so we are more likely to get past failing extra_cmds.
-SHELL ["/bin/bash", "-uo", "pipefail", "-c"]
-
-RUN corepack enable || true
-
-# Switch back to strict shell after extra_cmds.
-SHELL ["/bin/bash", "-euxo", "pipefail", "-c"]
-
-CMD ["/bin/sketch"]
diff --git a/dockerimg/testdata/testcreatedockerfile_repo_with_readme_and_workflow.httprr b/dockerimg/testdata/testcreatedockerfile_repo_with_readme_and_workflow.httprr
deleted file mode 100644
index 7bc944b..0000000
--- a/dockerimg/testdata/testcreatedockerfile_repo_with_readme_and_workflow.httprr
+++ /dev/null
@@ -1,81 +0,0 @@
-httprr trace v1
-6132 1884
-POST https://api.anthropic.com/v1/messages HTTP/1.1
-Host: api.anthropic.com
-User-Agent: Go-http-client/1.1
-Content-Length: 5935
-Anthropic-Version: 2023-06-01
-Content-Type: application/json
-
-{
- "model": "claude-sonnet-4-20250514",
- "messages": [
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "\nCall the dockerfile tool to create a Dockerfile.\nThe parameters to dockerfile fill out the From and ExtraCmds\ntemplate variables in the following Go template:\n\n```\n# Stage 1: Get Chrome/Chromium from chromedp/headless-shell\nFROM docker.io/chromedp/headless-shell:stable AS chrome\n\n# Stage 2: Main application image\nFROM ubuntu:24.04\n\n# Switch from dash to bash by default.\nSHELL [\"/bin/bash\", \"-euxo\", \"pipefail\", \"-c\"]\n\n# attempt to keep package installs lean\nRUN printf '%s\\n' \\\n 'path-exclude=/usr/share/man/*' \\\n 'path-exclude=/usr/share/doc/*' \\\n 'path-exclude=/usr/share/doc-base/*' \\\n 'path-exclude=/usr/share/info/*' \\\n 'path-exclude=/usr/share/locale/*' \\\n 'path-exclude=/usr/share/groff/*' \\\n 'path-exclude=/usr/share/lintian/*' \\\n 'path-exclude=/usr/share/zoneinfo/*' \\\n \u003e /etc/dpkg/dpkg.cfg.d/01_nodoc\n\n# Install system packages (removed chromium, will use headless-shell instead)\nRUN apt-get update; \\\n\tapt-get install -y --no-install-recommends \\\n\t\tca-certificates wget \\\n\t\tgit jq sqlite3 npm nodejs gh ripgrep fzf python3 curl vim lsof iproute2 less \\\n\t\tdocker.io docker-compose-v2 docker-buildx \\\n\t\tmake python3-pip python-is-python3 tree net-tools file build-essential \\\n\t\tpipx cargo psmisc bsdmainutils openssh-client sudo \\\n\t\tunzip yarn util-linux \\\n\t\tlibglib2.0-0 libnss3 libx11-6 libxcomposite1 libxdamage1 \\\n\t\tlibxext6 libxi6 libxrandr2 libgbm1 libgtk-3-0 \\\n\t\tfonts-noto-color-emoji fonts-symbola \u0026\u0026 \\\n\tfc-cache -f -v \u0026\u0026 \\\n\tapt-get clean \u0026\u0026 \\\n\trm -rf /var/lib/apt/lists/* \u0026\u0026 \\\n\trm -rf /usr/share/{doc,doc-base,info,lintian,man,groff,locale,zoneinfo}/*\n\nRUN echo '{\"storage-driver\":\"vfs\", \"bridge\":\"none\", \"iptables\":false, \"ip-forward\": false}' \\\n\t\u003e /etc/docker/daemon.json\n\n# Install Go 1.24\nENV GO_VERSION=1.24.3\nENV GOROOT=/usr/local/go\nENV GOPATH=/go\nENV PATH=$GOROOT/bin:$GOPATH/bin:$PATH\n\nRUN ARCH=$(uname -m) \u0026\u0026 \\\n\tcase $ARCH in \\\n\t\tx86_64) GOARCH=amd64 ;; \\\n\t\taarch64) GOARCH=arm64 ;; \\\n\t\t*) echo \"Unsupported architecture: $ARCH\" \u0026\u0026 exit 1 ;; \\\n\tesac \u0026\u0026 \\\n\twget -O go.tar.gz \"https://golang.org/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz\" \u0026\u0026 \\\n\ttar -C /usr/local -xzf go.tar.gz \u0026\u0026 \\\n\trm go.tar.gz\n\n# Create GOPATH directory\nRUN mkdir -p \"$GOPATH/src\" \"$GOPATH/bin\" \u0026\u0026 chmod -R 755 \"$GOPATH\"\n\n# While these binaries install generally useful supporting packages,\n# the specific versions are rarely what a user wants so there is no\n# point polluting the base image module with them.\n\nRUN go install golang.org/x/tools/cmd/goimports@latest; \\\n\tgo install golang.org/x/tools/gopls@latest; \\\n\tgo install mvdan.cc/gofumpt@latest; \\\n\tgo clean -cache -testcache -modcache\n\n# Copy the self-contained Chrome bundle from chromedp/headless-shell\nCOPY --from=chrome /headless-shell /headless-shell\nENV PATH=\"/headless-shell:${PATH}\"\n\nENV GOTOOLCHAIN=auto\nENV SKETCH=1\n\nRUN mkdir -p /root/.cache/sketch/webui\n\nARG GIT_USER_EMAIL\nARG GIT_USER_NAME\n\nRUN git config --global user.email \"$GIT_USER_EMAIL\" \u0026\u0026 \\\n git config --global user.name \"$GIT_USER_NAME\" \u0026\u0026 \\\n git config --global http.postBuffer 524288000\n\nLABEL sketch_context=\"{{.InitFilesHash}}\"\nCOPY . /app\nRUN rm -f /app/tmp-sketch-dockerfile\n\nWORKDIR /app{{.SubDir}}\nRUN if [ -f go.mod ]; then go mod download; fi\n\n# Switch to lenient shell so we are more likely to get past failing extra_cmds.\nSHELL [\"/bin/bash\", \"-uo\", \"pipefail\", \"-c\"]\n\n{{.ExtraCmds}}\n\n# Switch back to strict shell after extra_cmds.\nSHELL [\"/bin/bash\", \"-euxo\", \"pipefail\", \"-c\"]\n\nCMD [\"/bin/sketch\"]\n\n```\n\nIn particular:\n- Assume it is primarily a Go project.\n- Python env setup is challenging and often no required, so any RUN commands involving python tooling should be written to let docker build continue if there is a failure.\n- Include any tools particular to this repository that can be inferred from the given context.\n- Append || true to any apt-get install commands in case the package does not exist.\n- MINIMIZE the number of extra_cmds generated. Straightforward environments do not need any.\n- Do NOT expose any ports.\n- Do NOT generate any CMD or ENTRYPOINT extra commands.\nHere is the content of several files from the repository that may be relevant:\n\n"
- },
- {
- "type": "text",
- "text": "Here is the contents .github/workflows/test.yml:\n\u003cfile\u003e\nname: Test\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v2\n - uses: actions/setup-node@v3\n with:\n node-version: '18'\n - name: Install and activate corepack\n run: |\n npm install -g corepack\n corepack enable\n - run: go test ./...\n\u003c/file\u003e\n\n"
- },
- {
- "type": "text",
- "text": "Here is the contents README.md:\n\u003cfile\u003e\n# Test Project\nA Go project for testing.\n\u003c/file\u003e\n\n"
- },
- {
- "type": "text",
- "text": "Now call the dockerfile tool.",
- "cache_control": {
- "type": "ephemeral"
- }
- }
- ]
- }
- ],
- "max_tokens": 8192,
- "tools": [
- {
- "name": "dockerfile",
- "description": "Helps define a Dockerfile that sets up a dev environment for this project.",
- "input_schema": {
- "type": "object",
- "required": [
- "extra_cmds"
- ],
- "properties": {
- "extra_cmds": {
- "type": "string",
- "description": "Extra dockerfile commands to add to the dockerfile. Each command should start with RUN."
- }
- }
- }
- }
- ]
-}HTTP/2.0 200 OK
-Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
-Anthropic-Ratelimit-Input-Tokens-Limit: 200000
-Anthropic-Ratelimit-Input-Tokens-Remaining: 200000
-Anthropic-Ratelimit-Input-Tokens-Reset: 2025-07-03T02:23:17Z
-Anthropic-Ratelimit-Output-Tokens-Limit: 80000
-Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
-Anthropic-Ratelimit-Output-Tokens-Reset: 2025-07-03T02:23:20Z
-Anthropic-Ratelimit-Requests-Limit: 4000
-Anthropic-Ratelimit-Requests-Remaining: 3999
-Anthropic-Ratelimit-Requests-Reset: 2025-07-03T02:23:14Z
-Anthropic-Ratelimit-Tokens-Limit: 280000
-Anthropic-Ratelimit-Tokens-Remaining: 280000
-Anthropic-Ratelimit-Tokens-Reset: 2025-07-03T02:23:17Z
-Cf-Cache-Status: DYNAMIC
-Cf-Ray: 9592eb704f29cee1-SJC
-Content-Type: application/json
-Date: Thu, 03 Jul 2025 02:23:20 GMT
-Request-Id: req_011CQjHm1b3N7YUoEQCkLEBP
-Server: cloudflare
-Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
-Via: 1.1 google
-X-Robots-Tag: none
-
-{"id":"msg_01FuJ4WwjpF1KYe6k9RM72cJ","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Looking at the repository context, this appears to be a straightforward Go project with minimal dependencies. The GitHub workflow shows it uses Node.js 18 and corepack, but these are likely for tooling rather than core functionality. Since the base Dockerfile template already includes comprehensive Go tooling and Node.js/npm, and the project appears to be simple based on the README, I'll generate a minimal extra_cmds section."},{"type":"tool_use","id":"toolu_013AkzAFZ7VAtTUgTjgoCGWR","name":"dockerfile","input":{"extra_cmds":"RUN corepack enable || true"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":2022,"cache_read_input_tokens":0,"output_tokens":152,"service_tier":"standard"}}
\ No newline at end of file
diff --git a/dockerimg/update_tests.sh b/dockerimg/update_tests.sh
deleted file mode 100755
index 7127f3b..0000000
--- a/dockerimg/update_tests.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/bin/bash
-
-SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
-CURRENT_DIR=$(pwd)
-
-cd "$SCRIPT_DIR"
-
-go test -httprecord ".*" -rewritewant
-
-cd "$CURRENT_DIR"
diff --git a/webui/esbuild.go b/webui/esbuild.go
index ee95943..d310f95 100644
--- a/webui/esbuild.go
+++ b/webui/esbuild.go
@@ -108,6 +108,9 @@
return hashZip, err
}
+// TODO: This path being /root/.cache/sketch/webui/skui-....zip means that the Dockerfile
+// in createdockerfile.go needs to create the parent directory. Ideally we bundle the built webui
+// into the binary and avoid this altogether.
func zipPath() (cacheDir, hashZip string, err error) {
homeDir, err := os.UserHomeDir()
if err != nil {