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/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")
+	}
+}