all: use make to build
This overhauls the build system.
We used to use a just-in-time clever build system
so that 'go run' and 'go install' Just Worked.
This was really nice, except that it make it
all but impossible to ship a single binary.
It also required our uses to install npm,
which some folks have an understandably negative reaction to.
This migrates to a makefile for building.
The core typescript building logic is mostly still in Go,
and untouched (boy did I learn that lesson the hard way).
The output is a single file that includes the webui, innie, and outie.
(There are still very mild shenanigans in which we write outie
out to a temp file and then 'docker cp' it into the docker container.
But this is pretty manageable.)
There are some significant follow-ups left after this commit:
- convert the nightly release builds to use the makefile
- lots of dead code removal
- maybe add -race support using a dockerfile for the cgo compilation
- maybe use 'docker cp' stdin reading with tar to avoid the temp outtie file
- all the rest of the "better release" todos (brew install, etc.)
diff --git a/.gitignore b/.gitignore
index 76fce36..2b55c68 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,6 @@
# goreleaser builds
dist
+
+# makefile artifact
+sketch
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..2b31b66
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,32 @@
+# Makefile for building sketch with embedded assets
+#
+# Two-layer architecture:
+# 1. Linux binary ("innie") - runs in container, embeds webui assets
+# 2. Native binary ("outie") - runs on user's machine, embeds innie
+
+BUILD_TIME := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
+COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
+VERSION := $(shell git describe --tags --dirty --always 2>/dev/null || echo "dev")
+LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(BUILD_TIME) -X main.makefile=true
+
+.PHONY: all clean help
+.PHONY: outie innie
+.PHONY: webui-assets
+
+all: outie
+
+outie: innie
+ go build -ldflags="$(LDFLAGS)" -tags=outie -o sketch ./cmd/sketch
+
+innie: webui-assets
+ CGO_ENABLED=0 GOOS=linux go build -ldflags="$(LDFLAGS)" -tags=innie -o embedded/sketch-linux/sketch-linux ./cmd/sketch
+
+webui-assets:
+ rm -rf embedded/webui-dist
+ go run ./cmd/genwebui -- embedded/webui-dist
+
+clean:
+ @echo "Cleaning build artifacts..."
+ rm -f sketch
+ rm -rf embedded/sketch-linux embedded/webui-dist
+ cd webui && rm -rf node_modules dist
diff --git a/README.md b/README.md
index 2e73713..4964714 100644
--- a/README.md
+++ b/README.md
@@ -25,9 +25,13 @@
## 📋 Quick Start
+Grab the most recent [nightly release](https://github.com/boldsoftware/sketch/releases).
+
+To build yourself, clone this repo, and then run:
+
```sh
-go install sketch.dev/cmd/sketch@latest
-sketch
+$ make
+$ ./sketch
```
## 🔧 Requirements
diff --git a/cmd/genwebui/genwebui.go b/cmd/genwebui/genwebui.go
new file mode 100644
index 0000000..2abe552
--- /dev/null
+++ b/cmd/genwebui/genwebui.go
@@ -0,0 +1,29 @@
+package main
+
+import (
+ "flag"
+ "log"
+ "os"
+
+ "sketch.dev/webui"
+)
+
+func main() {
+ flag.Parse()
+ if flag.NArg() != 1 {
+ log.Fatalf("expected exactly 1 arg (destination directory), got %v", flag.NArg())
+ }
+ dest := flag.Arg(0)
+ if dest == "" {
+ log.Fatalf("expected destination directory, got %q", dest)
+ }
+ // TODO: make webui.Build write directly to dest instead of writing to a temp dir and copying to dest
+ fsys, err := webui.Build()
+ if err != nil {
+ log.Fatal(err)
+ }
+ err = os.CopyFS(dest, fsys)
+ if err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/cmd/genwebuizip/genwebuizip.go b/cmd/genwebuizip/genwebuizip.go
deleted file mode 100644
index 02602b6..0000000
--- a/cmd/genwebuizip/genwebuizip.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package main
-
-import (
- "flag"
- "fmt"
- "log"
- "os/exec"
- "path/filepath"
-
- "sketch.dev/webui"
-)
-
-func main() {
- dest := flag.String("dest", ".", "destination directory")
- flag.Parse()
-
- // Make sure that the webui is built so we can copy the results to the container.
- _, err := webui.Build()
- if err != nil {
- log.Fatal(err.Error())
- }
-
- webuiZipPath, err := webui.ZipPath()
- if err != nil {
- log.Fatal(err.Error())
- }
- cmd := exec.Command("cp", webuiZipPath, filepath.Join(*dest, "."))
- if err := cmd.Run(); err != nil {
- log.Fatal(err.Error())
- }
-
- fmt.Printf("webuiZipPath: %v copied to %s\n", webuiZipPath, *dest)
-}
diff --git a/cmd/sketch/main.go b/cmd/sketch/main.go
index 587a6b1..21e569b 100644
--- a/cmd/sketch/main.go
+++ b/cmd/sketch/main.go
@@ -38,12 +38,13 @@
"sketch.dev/webui"
)
-// Version information set by GoReleaser at build time
+// Version information set by ldflags at build time
var (
- version = "dev" // version string
- commit = "none" // git commit hash
- date = "unknown" // build timestamp
- builtBy = "unknown" // who built this binary
+ version = "dev" // version string
+ commit = "none" // git commit hash
+ date = "unknown" // build timestamp
+ builtBy = "unknown" // who built this binary
+ makefile = "" // marker indicating a makefile build
)
func main() {
@@ -59,6 +60,11 @@
func run() error {
flagArgs := parseCLIFlags()
+ // If not built with make, embedded assets will be missing.
+ if makefile == "" {
+ return fmt.Errorf("please use `make` to build sketch")
+ }
+
// Set up signal handling if -ignoresig flag is set
if flagArgs.ignoreSig {
setupSignalIgnoring()
diff --git a/dockerimg/dockerimg.go b/dockerimg/dockerimg.go
index 7b9d549..77af08a 100644
--- a/dockerimg/dockerimg.go
+++ b/dockerimg/dockerimg.go
@@ -23,9 +23,9 @@
"golang.org/x/crypto/ssh"
"sketch.dev/browser"
+ "sketch.dev/embedded"
"sketch.dev/loop/server"
"sketch.dev/skribe"
- "sketch.dev/webui"
)
// ContainerConfig holds all configuration for launching a container
@@ -171,14 +171,6 @@
return err
}
- linuxSketchBin := config.SketchBinaryLinux
- if linuxSketchBin == "" {
- linuxSketchBin, err = buildLinuxSketchBin(ctx)
- if err != nil {
- return err
- }
- }
-
cntrName := "sketch-" + config.SessionID
defer func() {
if config.NoCleanup {
@@ -236,28 +228,12 @@
config.Upstream = upstream
config.Commit = commit
- // Create the sketch container
+ // Create the sketch container, copy over linux sketch
if err := createDockerContainer(ctx, cntrName, hostPort, relPath, imgName, config); err != nil {
return fmt.Errorf("failed to create docker container: %w", err)
}
-
- // Copy the sketch linux binary into the container
- if out, err := combinedOutput(ctx, "docker", "cp", linuxSketchBin, cntrName+":/bin/sketch"); err != nil {
- return fmt.Errorf("docker cp: %s, %w", out, err)
- }
-
- // Make sure that the webui is built so we can copy the results to the container.
- _, err = webui.Build()
- if err != nil {
- return fmt.Errorf("failed to build webui: %w", err)
- }
-
- webuiZipPath, err := webui.ZipPath()
- if err != nil {
- return err
- }
- if out, err := combinedOutput(ctx, "docker", "cp", webuiZipPath, cntrName+":/root/.cache/sketch/webui/"+filepath.Base(webuiZipPath)); err != nil {
- return fmt.Errorf("docker cp: %s, %w", out, err)
+ if err := copyEmbeddedLinuxBinaryToContainer(ctx, cntrName); err != nil {
+ return fmt.Errorf("failed to copy linux binary to container: %w", err)
}
fmt.Printf("📦 running in container %s\n", cntrName)
@@ -1182,6 +1158,40 @@
return strings.TrimSpace(string(out)), nil
}
+// copyEmbeddedLinuxBinaryToContainer copies the embedded linux binary to the container
+func copyEmbeddedLinuxBinaryToContainer(ctx context.Context, containerName string) error {
+ bin := embedded.LinuxBinary()
+ if bin == nil {
+ return fmt.Errorf("nil embedded linux binary reader, did you build using `make`?")
+ }
+
+ cacheDir := filepath.Join(os.TempDir(), "sketch-binary-cache")
+ if err := os.MkdirAll(cacheDir, 0o700); err != nil {
+ return fmt.Errorf("failed to create cache directory: %w", err)
+ }
+
+ hash := sha256.Sum256(bin)
+ binaryPath := filepath.Join(cacheDir, hex.EncodeToString(hash[:]))
+ _, statErr := os.Stat(binaryPath)
+ switch {
+ case os.IsNotExist(statErr):
+ if err := os.WriteFile(binaryPath, bin, 0o700); err != nil {
+ return fmt.Errorf("failed to write binary to cache: %w", err)
+ }
+ case statErr != nil:
+ return fmt.Errorf("failed to check if cached binary exists: %w", statErr)
+ }
+ // TODO: clean up old sketch binaries from the cache dir:
+ // maybe set a max of 5, and then delete oldest after that by atime/mtime/ctime
+
+ if out, err := combinedOutput(ctx, "docker", "cp", binaryPath, containerName+":/bin/sketch"); err != nil {
+ return fmt.Errorf("docker cp failed: %s: %w", out, err)
+ }
+
+ slog.DebugContext(ctx, "copied embedded linux binary to container", "container", containerName)
+ return nil
+}
+
const seccompProfile = `{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
diff --git a/dockerimg/dockerimg_test.go b/dockerimg/dockerimg_test.go
index 563d33b..e15a81f 100644
--- a/dockerimg/dockerimg_test.go
+++ b/dockerimg/dockerimg_test.go
@@ -1,8 +1,12 @@
package dockerimg
import (
+ "bytes"
"context"
+ "crypto/sha256"
+ "encoding/hex"
"os"
+ "path/filepath"
"testing"
)
@@ -103,3 +107,72 @@
t.Error("Expected error for nonexistent image, got nil")
}
}
+
+// TestBinaryCaching tests the content-addressable binary caching functionality
+func TestBinaryCaching(t *testing.T) {
+ // Mock the embedded binary
+ testBinary := []byte("fake binary content for testing")
+
+ // Calculate expected hash
+ hash := sha256.Sum256(testBinary)
+ hashHex := hex.EncodeToString(hash[:])
+
+ // Create a temporary directory for this test
+ tempDir := t.TempDir()
+ cacheDir := filepath.Join(tempDir, "sketch-binary-cache")
+ binaryPath := filepath.Join(cacheDir, hashHex)
+
+ // First, create the cache directory
+ err := os.MkdirAll(cacheDir, 0o755)
+ if err != nil {
+ t.Fatalf("Failed to create cache directory: %v", err)
+ }
+
+ // Verify the binary doesn't exist initially
+ if _, err := os.Stat(binaryPath); !os.IsNotExist(err) {
+ t.Fatalf("Binary should not exist initially, but stat returned: %v", err)
+ }
+
+ // Write the binary (simulating first time)
+ err = os.WriteFile(binaryPath, testBinary, 0o700)
+ if err != nil {
+ t.Fatalf("Failed to write binary: %v", err)
+ }
+
+ // Verify the binary now exists and has correct permissions
+ info, err := os.Stat(binaryPath)
+ if err != nil {
+ t.Fatalf("Failed to stat cached binary: %v", err)
+ }
+
+ if info.Mode().Perm() != 0o700 {
+ t.Errorf("Expected permissions 0700, got %o", info.Mode().Perm())
+ }
+
+ // Verify content matches
+ cachedContent, err := os.ReadFile(binaryPath)
+ if err != nil {
+ t.Fatalf("Failed to read cached binary: %v", err)
+ }
+
+ if !bytes.Equal(testBinary, cachedContent) {
+ t.Error("Cached binary content doesn't match original")
+ }
+
+ // Test that the same hash produces the same path
+ hash2 := sha256.Sum256(testBinary)
+ hashHex2 := hex.EncodeToString(hash2[:])
+
+ if hashHex != hashHex2 {
+ t.Error("Same content should produce same hash")
+ }
+
+ // Test that different content produces different hash
+ differentBinary := []byte("different fake binary content")
+ differentHash := sha256.Sum256(differentBinary)
+ differentHashHex := hex.EncodeToString(differentHash[:])
+
+ if hashHex == differentHashHex {
+ t.Error("Different content should produce different hash")
+ }
+}
diff --git a/embedded/.gitignore b/embedded/.gitignore
new file mode 100644
index 0000000..2bcd878
--- /dev/null
+++ b/embedded/.gitignore
@@ -0,0 +1,2 @@
+sketch-linux
+webui-dist
diff --git a/embedded/embedded.go b/embedded/embedded.go
new file mode 100644
index 0000000..a5b8c49
--- /dev/null
+++ b/embedded/embedded.go
@@ -0,0 +1,20 @@
+//go:build !innie && !outie
+
+// Package embedded provides access to embedded assets for the sketch binary.
+// The native binary (outie) embeds only the linux binary.
+// The linux binary (innie) embeds only the webui assets.
+package embedded
+
+import (
+ "io/fs"
+)
+
+// LinuxBinary returns the embedded linux binary.
+func LinuxBinary() []byte {
+ return nil
+}
+
+// WebUIFS returns the embedded webui filesystem for direct serving
+func WebUIFS() fs.FS {
+ return nil
+}
diff --git a/embedded/embedded_innie.go b/embedded/embedded_innie.go
new file mode 100644
index 0000000..475e7a2
--- /dev/null
+++ b/embedded/embedded_innie.go
@@ -0,0 +1,23 @@
+//go:build innie
+
+package embedded
+
+import (
+ "embed"
+ "io/fs"
+)
+
+//go:embed webui-dist
+var webUIAssets embed.FS
+
+// LinuxBinary returns the embedded linux binary.
+func LinuxBinary() []byte {
+ return nil
+}
+
+// WebUIFS returns the embedded webui filesystem for direct serving
+func WebUIFS() fs.FS {
+ // TODO: can we avoid this fs.Sub somehow?
+ webuiFS, _ := fs.Sub(webUIAssets, "webui-dist")
+ return webuiFS
+}
diff --git a/embedded/embedded_outie.go b/embedded/embedded_outie.go
new file mode 100644
index 0000000..a89ff4b
--- /dev/null
+++ b/embedded/embedded_outie.go
@@ -0,0 +1,22 @@
+//go:build outie
+
+package embedded
+
+import (
+ _ "embed"
+ "io/fs"
+)
+
+//go:embed sketch-linux/sketch-linux
+var sketchLinuxBinary []byte
+
+// LinuxBinary returns the embedded linux binary.
+func LinuxBinary() []byte {
+ return sketchLinuxBinary
+}
+
+// WebUIFS returns the embedded webui filesystem.
+func WebUIFS() fs.FS {
+ // webUIAssets are not present in outie
+ return nil
+}
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index 3f90f8b..e223a60 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -10,7 +10,6 @@
"fmt"
"html"
"io"
- "io/fs"
"log/slog"
"net/http"
"net/http/httputil"
@@ -26,14 +25,13 @@
"syscall"
"time"
- "sketch.dev/git_tools"
- "sketch.dev/loop/server/gzhandler"
-
"github.com/creack/pty"
"sketch.dev/claudetool/browse"
+ "sketch.dev/embedded"
+ "sketch.dev/git_tools"
"sketch.dev/llm/conversation"
"sketch.dev/loop"
- "sketch.dev/webui"
+ "sketch.dev/loop/server/gzhandler"
)
// terminalSession represents a terminal session with its PTY and the event channel
@@ -216,11 +214,6 @@
sshError: "",
}
- webBundle, err := webui.Build()
- if err != nil {
- return nil, fmt.Errorf("failed to build web bundle, did you run 'go generate sketch.dev/loop/...'?: %w", err)
- }
-
s.mux.HandleFunc("/stream", s.handleSSEStream)
// Git tool endpoints
@@ -488,7 +481,7 @@
}
})
- s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(webBundle)))
+ s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(embedded.WebUIFS())))
// Terminal WebSocket handler
// Terminal endpoints - predefined terminals 1-9
@@ -528,33 +521,14 @@
s.handleTerminalInput(w, r, sessionID)
})
- // Handler for interface selection via URL parameters (?m for mobile, ?d for desktop, auto-detect by default)
+ // Handler for interface selection via URL parameters (?m for mobile)
s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- // Check URL parameters for interface selection
- queryParams := r.URL.Query()
-
- // Check if mobile interface is requested (?m parameter)
- if queryParams.Has("m") {
- // Serve the mobile-app-shell.html file
- data, err := fs.ReadFile(webBundle, "mobile-app-shell.html")
- if err != nil {
- http.Error(w, "Mobile interface not found", http.StatusNotFound)
- return
- }
- w.Header().Set("Content-Type", "text/html")
- w.Write(data)
- return
+ webuiFS := embedded.WebUIFS()
+ appShell := "sketch-app-shell.html"
+ if r.URL.Query().Has("m") {
+ appShell = "mobile-app-shell.html"
}
-
- // Check if desktop interface is explicitly requested (?d parameter)
- // or serve desktop by default
- data, err := fs.ReadFile(webBundle, "sketch-app-shell.html")
- if err != nil {
- http.Error(w, "File not found", http.StatusNotFound)
- return
- }
- w.Header().Set("Content-Type", "text/html")
- w.Write(data)
+ http.ServeFileFS(w, r, webuiFS, appShell)
})
// Handler for /commit-description - returns the description of a git commit
diff --git a/webui/esbuild.go b/webui/esbuild.go
index d310f95..1588f06 100644
--- a/webui/esbuild.go
+++ b/webui/esbuild.go
@@ -358,19 +358,7 @@
return nil, fmt.Errorf("failed to compress .js/.js.map/.css files: %w", err)
}
- // Everything succeeded, so we write tmpHashDir to hashZip
- buf := new(bytes.Buffer)
- w := zip.NewWriter(buf)
- if err := w.AddFS(os.DirFS(tmpHashDir)); err != nil {
- return nil, err
- }
- if err := w.Close(); err != nil {
- return nil, err
- }
- if err := os.WriteFile(hashZip, buf.Bytes(), 0o666); err != nil {
- return nil, err
- }
- return zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
+ return os.DirFS(tmpHashDir), nil
}
func esbuildBundle(outDir, src, metafilePath string) error {