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