Compare commits
10 commits
d0159c2ba4
...
a62df0ee5f
Author | SHA1 | Date | |
---|---|---|---|
a62df0ee5f | |||
c59527c8d8 | |||
ed8a6f2d81 | |||
cd1f23527e | |||
30a510cb17 | |||
33f45eae02 | |||
a14bf027cd | |||
bd5abff936 | |||
ed3c0eae14 | |||
a62cf65376 |
23 changed files with 880 additions and 1163 deletions
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(rg:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -46,3 +46,4 @@ Thumbs.db
|
|||
# Logs
|
||||
*.log
|
||||
.claude/
|
||||
./hako
|
||||
|
|
48
CLAUDE.md
Normal file
48
CLAUDE.md
Normal file
|
@ -0,0 +1,48 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Hako is a Docker container management tool designed specifically for Claude Code. It provides sandboxed environments to prevent Claude from accidentally affecting files outside the current working directory.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **main.go**: CLI entry point using Cobra framework with commands: init, up, down, ps, sync
|
||||
- **commands.go**: Command implementations that orchestrate Docker operations and file syncing
|
||||
- **docker.go**: Core Docker operations (image building, container lifecycle, file copying)
|
||||
- **git.go**: Git integration for project detection and container naming
|
||||
- **utils.go**: Utility functions for command execution with logging to stderr
|
||||
|
||||
## Key Design Patterns
|
||||
|
||||
- Container names follow pattern: `hako-{language}-{project}-{subdirs}` (sanitized, max 60 chars)
|
||||
- All Docker commands are logged to stderr for transparency
|
||||
- File syncing respects .gitignore and excludes .git directory for security
|
||||
- Images are versioned (HAKO-VERSION=1, as a Docker label) for upgrade management
|
||||
- NOTE: changes to the base Dockerfiles should involve bumping the hako version
|
||||
|
||||
## Development Commands
|
||||
|
||||
Build and run:
|
||||
```bash
|
||||
go run . <command>
|
||||
```
|
||||
|
||||
Build binary:
|
||||
```bash
|
||||
go build -o hako
|
||||
```
|
||||
|
||||
## Core Workflows
|
||||
|
||||
1. **Image Management**: Base image (hako-userland) + language variants (hako-userland-{lang})
|
||||
2. **Container Lifecycle**: Create → Start → Copy workspace → Enter shell
|
||||
3. **File Syncing**: Uses git ls-files to copy only tracked/untracked (non-ignored) files
|
||||
4. **Project Isolation**: Must run within git repositories, containers scoped to project+path
|
||||
|
||||
## Configuration
|
||||
|
||||
- Base Dockerfile stored in `~/.config/hako/Dockerfile.base`
|
||||
- Language Dockerfiles in `~/.config/hako/Dockerfile.{lang}`
|
||||
- Mounts Claude config directory/file if present for seamless integration
|
65
TODO.md
65
TODO.md
|
@ -1,65 +0,0 @@
|
|||
# Hako Implementation TODO
|
||||
|
||||
## 🏗️ Core Architecture
|
||||
- [x] **Package Structure Design** - Library + CLI separation
|
||||
- [x] **Public API Design** - Manager interface with clean methods
|
||||
- [ ] **Project Setup** - go.mod, basic file structure
|
||||
|
||||
## 🐳 Docker Integration
|
||||
- [ ] **Base Image Building** - Dockerfile templates for hako-userland
|
||||
- [ ] **Language Variants** - Template system for go/py/node/etc extensions
|
||||
- [ ] **Image Management** - Build, tag, cleanup operations
|
||||
- [ ] **Container Lifecycle** - Create, start, stop, remove operations
|
||||
|
||||
## 🔧 Git Operations (CLI-based)
|
||||
- [ ] **Repository Detection** - `git rev-parse --is-inside-work-tree`
|
||||
- [ ] **Path Resolution** - Get repo root and relative paths
|
||||
- [ ] **File Listing** - Integration with `git ls-files` for sync
|
||||
- [ ] **Gitignore Support** - Use `git check-ignore` for filtering
|
||||
|
||||
## 📁 File Synchronization
|
||||
- [ ] **Host → Container** - Copy workspace files on `hako up`
|
||||
- [ ] **Container → Host** - `hako sync` with gitignore filtering
|
||||
- [ ] **Permission Preservation** - Maintain file modes and ownership
|
||||
- [ ] **Incremental Sync** - Only copy changed files (optimization)
|
||||
|
||||
## 🎯 CLI Commands
|
||||
- [ ] **hako init [lang]** - Image building command
|
||||
- [ ] **hako up <lang>** - Container creation and shell access
|
||||
- [ ] **hako sync** - File synchronization back to host
|
||||
- [ ] **hako ps** - List running containers
|
||||
- [ ] **hako down [name]** - Container teardown
|
||||
|
||||
## 🔍 Container Management
|
||||
- [ ] **Naming Strategy** - `hako-<lang>-<sanitized-path>` format
|
||||
- [ ] **Path Sanitization** - Safe container names from filesystem paths
|
||||
- [ ] **Container Discovery** - Find existing containers by labels
|
||||
- [ ] **Resource Limits** - Sensible defaults for CPU/memory
|
||||
|
||||
## ⚙️ Configuration
|
||||
- [ ] **Base Dockerfile** - Store in `~/.config/hako/Dockerfile.base`
|
||||
- [ ] **User Customization** - Allow editing base image before builds
|
||||
- [ ] **Language Templates** - Configurable language-specific additions
|
||||
- [ ] **Settings Persistence** - User preferences and defaults
|
||||
|
||||
## 🛡️ Security & Safety
|
||||
- [ ] **Git Repo Enforcement** - Fail outside of git repositories
|
||||
- [ ] **Container Isolation** - Limited host filesystem access
|
||||
- [ ] **Sync Filtering** - Respect .gitignore, exclude .git directory
|
||||
- [ ] **Path Validation** - Prevent directory traversal attacks
|
||||
|
||||
## 🧪 Testing & Quality
|
||||
- [ ] **Unit Tests** - Core functionality coverage
|
||||
- [ ] **Integration Tests** - End-to-end CLI workflows
|
||||
- [ ] **Docker Mocks** - Testable without actual containers
|
||||
- [ ] **Error Handling** - Graceful failures with helpful messages
|
||||
|
||||
## 📚 Documentation
|
||||
- [ ] **README** - Installation and usage guide
|
||||
- [ ] **API Docs** - Package documentation for library users
|
||||
- [ ] **Examples** - Common workflow demonstrations
|
||||
- [ ] **Troubleshooting** - Common issues and solutions
|
||||
|
||||
---
|
||||
|
||||
**Current Status:** Architecture approved ✅ | Ready for implementation 🚀
|
35
cli/down.go
35
cli/down.go
|
@ -1,35 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"l4.pm/hako/pkg"
|
||||
)
|
||||
|
||||
var downCmd = &cobra.Command{
|
||||
Use: "down [container-name]",
|
||||
Short: "Stop and remove a hako container",
|
||||
Long: `Stop and remove a hako container.
|
||||
|
||||
If no container name is provided, it will stop the container
|
||||
for the current project directory.
|
||||
|
||||
Examples:
|
||||
hako down # Stop current project's container
|
||||
hako down hako-go-myproject # Stop specific container`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
manager, err := hako.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create hako manager: %w", err)
|
||||
}
|
||||
|
||||
containerName := ""
|
||||
if len(args) > 0 {
|
||||
containerName = args[0]
|
||||
}
|
||||
|
||||
return manager.Down(containerName)
|
||||
},
|
||||
}
|
34
cli/init.go
34
cli/init.go
|
@ -1,34 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"l4.pm/hako/pkg"
|
||||
)
|
||||
|
||||
var initCmd = &cobra.Command{
|
||||
Use: "init [language]",
|
||||
Short: "Build hako images",
|
||||
Long: `Build the base hako image and optionally a language-specific variant.
|
||||
|
||||
Examples:
|
||||
hako init # Build base image only
|
||||
hako init go # Build base + Go image
|
||||
hako init python # Build base + Python image
|
||||
hako init rust # Build base + Rust image`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
manager, err := hako.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create hako manager: %w", err)
|
||||
}
|
||||
|
||||
language := ""
|
||||
if len(args) > 0 {
|
||||
language = args[0]
|
||||
}
|
||||
|
||||
return manager.Init(language)
|
||||
},
|
||||
}
|
37
cli/ps.go
37
cli/ps.go
|
@ -1,37 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"l4.pm/hako/pkg"
|
||||
)
|
||||
|
||||
var psCmd = &cobra.Command{
|
||||
Use: "ps",
|
||||
Short: "List running hako containers",
|
||||
Long: `Show all currently running hako containers.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
manager, err := hako.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create hako manager: %w", err)
|
||||
}
|
||||
|
||||
containers, err := manager.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(containers) == 0 {
|
||||
fmt.Println("No hako containers running")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("Running hako containers:")
|
||||
for _, container := range containers {
|
||||
fmt.Printf(" %s\n", container)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
33
cli/root.go
33
cli/root.go
|
@ -1,33 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "hako",
|
||||
Short: "Containerized development environments for Claude Code",
|
||||
Long: `Hako provides sandboxed Docker containers for Claude Code development.
|
||||
|
||||
It creates isolated environments that prevent Claude from accidentally
|
||||
modifying files outside your current project directory.`,
|
||||
}
|
||||
|
||||
// Execute runs the root command.
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(initCmd)
|
||||
rootCmd.AddCommand(upCmd)
|
||||
rootCmd.AddCommand(syncCmd)
|
||||
rootCmd.AddCommand(psCmd)
|
||||
rootCmd.AddCommand(downCmd)
|
||||
}
|
28
cli/sync.go
28
cli/sync.go
|
@ -1,28 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"l4.pm/hako/pkg"
|
||||
)
|
||||
|
||||
var syncCmd = &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Sync files from container to host",
|
||||
Long: `Copy files from the container workspace back to your host filesystem.
|
||||
|
||||
This respects .gitignore patterns and excludes .git directory to prevent
|
||||
any security issues.
|
||||
|
||||
Files are synced from /workspace in the container to your current
|
||||
project directory.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
manager, err := hako.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create hako manager: %w", err)
|
||||
}
|
||||
|
||||
return manager.Sync()
|
||||
},
|
||||
}
|
34
cli/up.go
34
cli/up.go
|
@ -1,34 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"l4.pm/hako/pkg"
|
||||
)
|
||||
|
||||
var upCmd = &cobra.Command{
|
||||
Use: "up <language>",
|
||||
Short: "Start a hako container",
|
||||
Long: `Create and start a hako container for the current project.
|
||||
|
||||
This will:
|
||||
1. Create a container if it doesn't exist
|
||||
2. Copy your project files to the container
|
||||
3. Drop you into an interactive shell
|
||||
|
||||
Examples:
|
||||
hako up go # Start Go development container
|
||||
hako up python # Start Python development container
|
||||
hako up rust # Start Rust development container`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
manager, err := hako.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create hako manager: %w", err)
|
||||
}
|
||||
|
||||
language := args[0]
|
||||
return manager.Up(language)
|
||||
},
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
package main
|
||||
|
||||
import "l4.pm/hako/cli"
|
||||
|
||||
func main() {
|
||||
cli.Execute()
|
||||
}
|
295
commands.go
Normal file
295
commands.go
Normal file
|
@ -0,0 +1,295 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func getContainerForCurrentDirectory() (string, error) {
|
||||
// Get all hako containers
|
||||
output, err := runCommandOutput("docker", "ps", "-a", "--filter", "name=hako-", "--format", "{{.Names}}")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
containers := strings.Split(strings.TrimSpace(output), "\n")
|
||||
if len(containers) == 0 || containers[0] == "" {
|
||||
return "", fmt.Errorf("no hako containers found")
|
||||
}
|
||||
|
||||
// Try to find containers that match any supported language for the current directory
|
||||
supportedLangs := []string{"go", "py", "python"}
|
||||
|
||||
for _, lang := range supportedLangs {
|
||||
expectedName, err := getProjectContainerName(lang)
|
||||
if err != nil {
|
||||
continue // Skip if we can't generate a name for this language
|
||||
}
|
||||
|
||||
// Check if this expected container name exists in our list
|
||||
for _, container := range containers {
|
||||
if strings.TrimSpace(container) == expectedName {
|
||||
return expectedName, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match found, return the first container as fallback
|
||||
return strings.TrimSpace(containers[0]), nil
|
||||
}
|
||||
|
||||
func initCommand(lang string) error {
|
||||
if lang == "" {
|
||||
fmt.Println("Building base hako image...")
|
||||
return buildBaseImage()
|
||||
}
|
||||
|
||||
if !imageExists("hako-userland") {
|
||||
fmt.Println("Base image not found, building it first...")
|
||||
if err := buildBaseImage(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Check base image version
|
||||
checkVersionMismatch("hako-userland")
|
||||
}
|
||||
|
||||
fmt.Printf("Building %s language image...\n", lang)
|
||||
return buildLanguageImage(lang)
|
||||
}
|
||||
|
||||
func upCommand(lang string) error {
|
||||
containerName, err := getProjectContainerName(lang)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
imageName := fmt.Sprintf("hako-userland-%s", lang)
|
||||
if !imageExists(imageName) {
|
||||
return fmt.Errorf("image %s not found, run 'hako init %s' first", imageName, lang)
|
||||
}
|
||||
|
||||
// Check for version mismatch
|
||||
checkVersionMismatch(imageName)
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if containerExists(containerName) {
|
||||
if !containerIsRunning(containerName) {
|
||||
fmt.Printf("Starting existing container %s...\n", containerName)
|
||||
if err := startContainer(containerName); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Container %s is already running\n", containerName)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Creating new container %s...\n", containerName)
|
||||
if err := createContainer(containerName, imageName, cwd); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := startContainer(containerName); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Copying workspace to container...\n")
|
||||
if err := copyWorkspaceToContainer(containerName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Entering container %s...\n", containerName)
|
||||
return execContainer(containerName)
|
||||
}
|
||||
|
||||
func downCommand(container string) error {
|
||||
var containerName string
|
||||
|
||||
if container != "" {
|
||||
containerName = container
|
||||
} else {
|
||||
// Find the appropriate hako container for current directory
|
||||
var err error
|
||||
containerName, err = getContainerForCurrentDirectory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("no hako containers found for current project: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !containerExists(containerName) {
|
||||
return fmt.Errorf("container %s does not exist", containerName)
|
||||
}
|
||||
|
||||
fmt.Printf("Stopping container %s...\n", containerName)
|
||||
if containerIsRunning(containerName) {
|
||||
if err := stopContainer(containerName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Removing container %s...\n", containerName)
|
||||
return removeContainer(containerName)
|
||||
}
|
||||
|
||||
func psCommand() error {
|
||||
fmt.Println("Hako containers:")
|
||||
if err := listHakoContainers(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for version mismatches in running containers
|
||||
output, err := runCommandOutput("docker", "ps", "-a", "--filter", "name=hako-", "--format", "{{.Image}}")
|
||||
if err == nil {
|
||||
images := strings.Split(strings.TrimSpace(output), "\n")
|
||||
checkedImages := make(map[string]bool)
|
||||
for _, image := range images {
|
||||
if image != "" && !checkedImages[image] {
|
||||
checkVersionMismatch(image)
|
||||
checkedImages[image] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncCommand() error {
|
||||
// Find the appropriate hako container for current directory
|
||||
containerName, err := getContainerForCurrentDirectory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("no hako containers found for current project: %v", err)
|
||||
}
|
||||
|
||||
if !containerExists(containerName) {
|
||||
return fmt.Errorf("container %s does not exist", containerName)
|
||||
}
|
||||
|
||||
fmt.Printf("Syncing files from container %s...\n", containerName)
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "hako-sync-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
if err := copyFromContainer(containerName, "/workspace/.", tempDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return syncFiles(tempDir, ".")
|
||||
}
|
||||
|
||||
func syncFiles(srcDir, destDir string) error {
|
||||
gitignore, err := NewGitIgnore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse .gitignore: %v", err)
|
||||
}
|
||||
|
||||
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to walk %q: %v", path, err)
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(srcDir, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to calculate relative path for %q: %v", path, err)
|
||||
}
|
||||
|
||||
// Skip .git directory and its contents
|
||||
if relPath == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// Skip files that start with .git (like .gitignore, .gitmodules, etc.)
|
||||
if strings.HasPrefix(filepath.Base(relPath), ".git") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the file should be ignored according to .gitignore
|
||||
if gitignore.IsIgnored(relPath) {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
destPath := filepath.Join(destDir, relPath)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(destPath, info.Mode())
|
||||
}
|
||||
|
||||
fmt.Println("cp", path, destPath)
|
||||
srcFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to open %s: %v\n", path, err)
|
||||
return nil
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to create %s: %v\n", destPath, err)
|
||||
return nil
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
_, err = io.Copy(destFile, srcFile)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to copy %s to %s: %v\n", path, destPath, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func copyWorkspaceToContainer(containerName string) error {
|
||||
// Get all files that should be copied (tracked + untracked, excluding ignored)
|
||||
trackedFiles, err := runCommandOutput("git", "ls-files")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
untrackedFiles, err := runCommandOutput("git", "ls-files", "--others", "--exclude-standard")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
allFiles := []string{}
|
||||
if trackedFiles != "" {
|
||||
allFiles = append(allFiles, strings.Split(trackedFiles, "\n")...)
|
||||
}
|
||||
if untrackedFiles != "" {
|
||||
allFiles = append(allFiles, strings.Split(untrackedFiles, "\n")...)
|
||||
}
|
||||
|
||||
if len(allFiles) == 0 {
|
||||
return fmt.Errorf("no files to copy")
|
||||
}
|
||||
|
||||
// Copy files one by one to preserve directory structure
|
||||
for _, file := range allFiles {
|
||||
if file == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create directory structure in container if needed
|
||||
dir := filepath.Dir(file)
|
||||
if dir != "." {
|
||||
if err := runCommand("docker", "exec", containerName, "mkdir", "-p", "/workspace/"+dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Copy the file
|
||||
if err := copyToContainer(containerName, file, "/workspace/"+file); err != nil {
|
||||
return fmt.Errorf("failed to copy %s: %v", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
226
docker.go
Normal file
226
docker.go
Normal file
|
@ -0,0 +1,226 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const hakoVersion = "1"
|
||||
|
||||
func getConfigDir() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
configDir := filepath.Join(homeDir, ".config", "hako")
|
||||
return configDir, nil
|
||||
}
|
||||
|
||||
func ensureConfigDir() error {
|
||||
configDir, err := getConfigDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(configDir, 0755)
|
||||
}
|
||||
|
||||
func getBaseDockerfile() string {
|
||||
return fmt.Sprintf(`FROM debian:trixie-20250610
|
||||
LABEL HAKO-VERSION=%s
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
git \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
fish \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js LTS
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
# Install Claude Code
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# configure some things
|
||||
RUN mkdir /workspace
|
||||
RUN git config --global --add safe.directory /workspace
|
||||
|
||||
WORKDIR /workspace
|
||||
CMD ["/usr/bin/fish"]`, hakoVersion)
|
||||
}
|
||||
|
||||
func getLanguageDockerfile(lang string) string {
|
||||
switch lang {
|
||||
case "go":
|
||||
return fmt.Sprintf(`FROM hako-userland
|
||||
LABEL HAKO-VERSION=%s
|
||||
|
||||
RUN curl -fsSL https://go.dev/dl/go1.21.5.linux-amd64.tar.gz | tar -C /usr/local -xzf -
|
||||
ENV PATH="/usr/local/go/bin:${PATH}"
|
||||
|
||||
WORKDIR /workspace
|
||||
CMD ["/usr/bin/fish"]`, hakoVersion)
|
||||
case "py", "python":
|
||||
return fmt.Sprintf(`FROM hako-userland
|
||||
LABEL HAKO-VERSION=%s
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN ln -s /usr/bin/python3 /usr/bin/python
|
||||
|
||||
WORKDIR /workspace
|
||||
CMD ["/usr/bin/fish"]`, hakoVersion)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildBaseImage() error {
|
||||
if err := ensureConfigDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configDir, _ := getConfigDir()
|
||||
dockerfilePath := filepath.Join(configDir, "Dockerfile.base")
|
||||
|
||||
dockerfile := getBaseDockerfile()
|
||||
if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Building base image hako-userland...\n")
|
||||
return runCommand("docker", "build", "-f", dockerfilePath, "-t", "hako-userland", ".")
|
||||
}
|
||||
|
||||
func buildLanguageImage(lang string) error {
|
||||
dockerfile := getLanguageDockerfile(lang)
|
||||
if dockerfile == "" {
|
||||
return fmt.Errorf("unsupported language: %s", lang)
|
||||
}
|
||||
|
||||
configDir, err := getConfigDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dockerfilePath := filepath.Join(configDir, fmt.Sprintf("Dockerfile.%s", lang))
|
||||
if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
imageName := fmt.Sprintf("hako-userland-%s", lang)
|
||||
fmt.Printf("Building %s image %s...\n", lang, imageName)
|
||||
return runCommand("docker", "build", "-f", dockerfilePath, "-t", imageName, ".")
|
||||
}
|
||||
|
||||
func imageExists(imageName string) bool {
|
||||
err := runCommandSilent("docker", "image", "inspect", imageName)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func containerExists(containerName string) bool {
|
||||
err := runCommandSilent("docker", "container", "inspect", containerName)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func containerIsRunning(containerName string) bool {
|
||||
output, err := runCommandOutput("docker", "container", "inspect", "-f", "{{.State.Running}}", containerName)
|
||||
return err == nil && strings.TrimSpace(output) == "true"
|
||||
}
|
||||
|
||||
func createContainer(containerName, imageName, workspaceDir string) error {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
args := []string{"create", "--name", containerName}
|
||||
|
||||
// No mounting - workspace is isolated and copied separately
|
||||
args = append(args, "-it", imageName)
|
||||
|
||||
if err := runCommand("docker", args...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy Claude config directory if it exists
|
||||
claudeDir := filepath.Join(homeDir, ".claude")
|
||||
if _, err := os.Stat(claudeDir); err == nil {
|
||||
if err := copyToContainer(containerName, claudeDir, "/root/.claude"); err != nil {
|
||||
return fmt.Errorf("failed to copy Claude config directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy Claude config file if it exists
|
||||
claudeFile := filepath.Join(homeDir, ".claude.json")
|
||||
if _, err := os.Stat(claudeFile); err == nil {
|
||||
if err := copyToContainer(containerName, claudeFile, "/root/.claude.json"); err != nil {
|
||||
return fmt.Errorf("failed to copy Claude config file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func startContainer(containerName string) error {
|
||||
return runCommand("docker", "start", containerName)
|
||||
}
|
||||
|
||||
func stopContainer(containerName string) error {
|
||||
return runCommand("docker", "stop", containerName)
|
||||
}
|
||||
|
||||
func removeContainer(containerName string) error {
|
||||
return runCommand("docker", "rm", containerName)
|
||||
}
|
||||
|
||||
func execContainer(containerName string) error {
|
||||
cmd := exec.Command("docker", "exec", "-it", containerName, "/usr/bin/fish")
|
||||
fmt.Fprintf(os.Stderr, "+ %s %s\n", "docker", strings.Join([]string{"exec", "-it", containerName, "/usr/bin/fish"}, " "))
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func copyToContainer(containerName, srcPath, destPath string) error {
|
||||
return runCommand("docker", "cp", srcPath, containerName+":"+destPath)
|
||||
}
|
||||
|
||||
func copyFromContainer(containerName, srcPath, destPath string) error {
|
||||
return runCommand("docker", "cp", containerName+":"+srcPath, destPath)
|
||||
}
|
||||
|
||||
func listHakoContainers() error {
|
||||
return runCommand("docker", "ps", "-a", "--filter", "name=hako-", "--format", "table {{.Names}}\\t{{.Status}}\\t{{.Image}}")
|
||||
}
|
||||
|
||||
func getImageVersion(imageName string) (string, error) {
|
||||
output, err := runCommandOutput("docker", "image", "inspect", "-f", "{{index .Config.Labels \"HAKO-VERSION\"}}", imageName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(output), nil
|
||||
}
|
||||
|
||||
func checkVersionMismatch(imageName string) {
|
||||
imageVersion, err := getImageVersion(imageName)
|
||||
if err != nil {
|
||||
// If we can't get the version, don't warn (might be old image without version)
|
||||
return
|
||||
}
|
||||
|
||||
if imageVersion != "" && imageVersion != hakoVersion {
|
||||
fmt.Printf("\033[33mWarning: Image %s has version %s but hako executable is version %s\033[0m\n", imageName, imageVersion, hakoVersion)
|
||||
fmt.Printf("\033[33mConsider running 'hako init' to rebuild with the current version\033[0m\n")
|
||||
}
|
||||
}
|
68
git.go
Normal file
68
git.go
Normal file
|
@ -0,0 +1,68 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func isGitRepository() bool {
|
||||
_, err := os.Stat(".git")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func getGitRootPath() (string, error) {
|
||||
output, err := runCommandOutput("git", "rev-parse", "--show-toplevel")
|
||||
if err != nil {
|
||||
return "", errors.New("not in a git repository")
|
||||
}
|
||||
return strings.TrimSpace(output), nil
|
||||
}
|
||||
|
||||
func getProjectContainerName(lang string) (string, error) {
|
||||
if !isGitRepository() {
|
||||
return "", errors.New("must be run inside a git repository")
|
||||
}
|
||||
|
||||
gitRoot, err := getGitRootPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(gitRoot, cwd)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
projectName := filepath.Base(gitRoot)
|
||||
|
||||
var pathComponents []string
|
||||
if relPath != "." {
|
||||
pathComponents = strings.Split(relPath, string(os.PathSeparator))
|
||||
}
|
||||
|
||||
containerName := "hako-" + lang + "-" + sanitizeName(projectName)
|
||||
for _, component := range pathComponents {
|
||||
containerName += "-" + sanitizeName(component)
|
||||
}
|
||||
|
||||
if len(containerName) > 60 {
|
||||
return "", errors.New("container name too long, try running from a shallower directory")
|
||||
}
|
||||
|
||||
return containerName, nil
|
||||
}
|
||||
|
||||
func sanitizeName(name string) string {
|
||||
result := strings.ToLower(name)
|
||||
result = strings.ReplaceAll(result, " ", "_")
|
||||
result = strings.ReplaceAll(result, ".", "_")
|
||||
result = strings.ReplaceAll(result, "-", "_")
|
||||
return result
|
||||
}
|
2
go.mod
2
go.mod
|
@ -1,4 +1,4 @@
|
|||
module l4.pm/hako
|
||||
module hako
|
||||
|
||||
go 1.21
|
||||
|
||||
|
|
2
idea.md
2
idea.md
|
@ -10,6 +10,8 @@ proposed UI:
|
|||
# builds the base hako image (debian:bookworm + node (latest lts) + npm install -g @anthropic-ai/claude-code + gcc)
|
||||
# this writes to ~/.config/hako/Dockerfile.base with the defaults
|
||||
# if you want to customize the base image, then edit the dockerfile and rerun `hako init`
|
||||
# NOTE: dockerfiles have some special metadata on the first comment (say `# HAKO-VERSION=1`, this is important because if we want to edit the base one we would have to require
|
||||
# users to wipe their dockerfiles first and then do it. we should be able to _warn_ them of that while running hako commands, and then init recreates the dockerfile on the latest version)
|
||||
# target image name: hako-userland
|
||||
hako init
|
||||
|
||||
|
|
99
main.go
Normal file
99
main.go
Normal file
|
@ -0,0 +1,99 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "hako",
|
||||
Short: "Docker container management for Claude Code",
|
||||
Long: "A tool that manages Docker containers providing sandboxed environments for Claude Code",
|
||||
}
|
||||
|
||||
var initCmd = &cobra.Command{
|
||||
Use: "init [language]",
|
||||
Short: "Initialize Docker images",
|
||||
Long: "Build base Docker image or language-specific variants",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
var lang string
|
||||
if len(args) > 0 {
|
||||
lang = args[0]
|
||||
}
|
||||
if err := initCommand(lang); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var upCmd = &cobra.Command{
|
||||
Use: "up <language>",
|
||||
Short: "Start and enter a container",
|
||||
Long: "Create or start a container for the current project and drop into shell",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := upCommand(args[0]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var downCmd = &cobra.Command{
|
||||
Use: "down [container]",
|
||||
Short: "Stop a container",
|
||||
Long: "Stop and remove a container",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
var container string
|
||||
if len(args) > 0 {
|
||||
container = args[0]
|
||||
}
|
||||
if err := downCommand(container); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var psCmd = &cobra.Command{
|
||||
Use: "ps",
|
||||
Short: "List running containers",
|
||||
Long: "Show all running hako containers",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := psCommand(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var syncCmd = &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Sync files from container",
|
||||
Long: "Copy files from container workspace to working directory",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := syncCommand(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(initCmd)
|
||||
rootCmd.AddCommand(upCmd)
|
||||
rootCmd.AddCommand(downCmd)
|
||||
rootCmd.AddCommand(psCmd)
|
||||
rootCmd.AddCommand(syncCmd)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
|
@ -1,170 +0,0 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Config holds hako configuration.
|
||||
type Config struct {
|
||||
ConfigDir string
|
||||
}
|
||||
|
||||
// Load loads or creates the hako configuration.
|
||||
func Load() (*Config, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
configDir := filepath.Join(homeDir, ".config", "hako")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
return &Config{
|
||||
ConfigDir: configDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BaseDockerfilePath returns the path to the base Dockerfile.
|
||||
func (c *Config) BaseDockerfilePath() string {
|
||||
return filepath.Join(c.ConfigDir, "Dockerfile.base")
|
||||
}
|
||||
|
||||
// EnsureBaseDockerfile creates the base Dockerfile if it doesn't exist.
|
||||
func (c *Config) EnsureBaseDockerfile() error {
|
||||
path := c.BaseDockerfilePath()
|
||||
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil // File already exists
|
||||
}
|
||||
|
||||
content := `FROM debian:bookworm
|
||||
|
||||
# Install basic dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
ca-certificates \
|
||||
gnupg \
|
||||
lsb-release \
|
||||
git \
|
||||
gcc \
|
||||
g++ \
|
||||
make \
|
||||
sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js (latest LTS)
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
# Install Claude Code CLI
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Configure passwordless sudo for all users
|
||||
RUN echo 'ALL ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers
|
||||
|
||||
# Create workspace directory
|
||||
RUN mkdir -p /workspace
|
||||
WORKDIR /workspace
|
||||
|
||||
# Default command
|
||||
CMD ["/bin/bash"]
|
||||
`
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write base dockerfile: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created base Dockerfile at: %s\n", path)
|
||||
fmt.Println("You can edit this file to customize the base image before running 'hako init'")
|
||||
return nil
|
||||
}
|
||||
|
||||
// LanguageDockerfile returns the path to a language-specific Dockerfile.
|
||||
func (c *Config) LanguageDockerfile(language string) (string, error) {
|
||||
path := filepath.Join(c.ConfigDir, fmt.Sprintf("Dockerfile.%s", language))
|
||||
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path, nil // File already exists
|
||||
}
|
||||
|
||||
content, err := c.getLanguageDockerfileContent(language)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write %s dockerfile: %w", language, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created %s Dockerfile at: %s\n", language, path)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// getLanguageDockerfileContent returns the Dockerfile content for a language.
|
||||
func (c *Config) getLanguageDockerfileContent(language string) (string, error) {
|
||||
switch language {
|
||||
case "go":
|
||||
return `FROM hako-userland
|
||||
|
||||
# Install Go
|
||||
RUN curl -fsSL https://go.dev/dl/go1.21.0.linux-amd64.tar.gz | tar -xzC /usr/local
|
||||
ENV PATH=/usr/local/go/bin:$PATH
|
||||
|
||||
# Verify Go installation
|
||||
RUN go version
|
||||
|
||||
CMD ["/bin/bash"]
|
||||
`, nil
|
||||
|
||||
case "py", "python":
|
||||
return `FROM hako-userland
|
||||
|
||||
# Install Python and pip
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create python3 symlink
|
||||
RUN ln -sf /usr/bin/python3 /usr/bin/python
|
||||
|
||||
# Verify Python installation
|
||||
RUN python --version && pip3 --version
|
||||
|
||||
CMD ["/bin/bash"]
|
||||
`, nil
|
||||
|
||||
case "rust":
|
||||
return `FROM hako-userland
|
||||
|
||||
# Install Rust
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
ENV PATH=/root/.cargo/bin:$PATH
|
||||
|
||||
# Verify Rust installation
|
||||
RUN rustc --version && cargo --version
|
||||
|
||||
CMD ["/bin/bash"]
|
||||
`, nil
|
||||
|
||||
case "node", "js":
|
||||
return `FROM hako-userland
|
||||
|
||||
# Node.js is already installed in the base image
|
||||
# Add any additional Node.js specific tools here
|
||||
|
||||
# Install common development tools
|
||||
RUN npm install -g typescript ts-node nodemon
|
||||
|
||||
CMD ["/bin/bash"]
|
||||
`, nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported language: %s", language)
|
||||
}
|
||||
}
|
|
@ -1,288 +0,0 @@
|
|||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"l4.pm/hako/pkg/config"
|
||||
)
|
||||
|
||||
// loggedCommand creates a command and logs it to stderr
|
||||
func loggedCommand(name string, args ...string) *exec.Cmd {
|
||||
log.Printf("exec: %s %s", name, strings.Join(args, " "))
|
||||
return exec.Command(name, args...)
|
||||
}
|
||||
|
||||
// Client wraps Docker operations.
|
||||
type Client struct {
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// NewClient creates a new Docker client.
|
||||
func NewClient() (*Client, error) {
|
||||
// Check if Docker is available
|
||||
cmd := loggedCommand("docker", "version", "--format", "{{.Server.Version}}")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("docker is not available: %w", err)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
ctx: context.Background(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildBaseImage builds the base hako image.
|
||||
func (c *Client) BuildBaseImage(cfg *config.Config) error {
|
||||
dockerfile := cfg.BaseDockerfilePath()
|
||||
|
||||
// Ensure base dockerfile exists
|
||||
if err := cfg.EnsureBaseDockerfile(); err != nil {
|
||||
return fmt.Errorf("failed to create base dockerfile: %w", err)
|
||||
}
|
||||
|
||||
// Build the base image
|
||||
cmd := loggedCommand("docker", "build",
|
||||
"-f", dockerfile,
|
||||
"-t", "hako-userland",
|
||||
filepath.Dir(dockerfile))
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to build base image: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("✅ Built base image: hako-userland")
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildLanguageImage builds a language-specific image.
|
||||
func (c *Client) BuildLanguageImage(language string, cfg *config.Config) error {
|
||||
// First ensure base image exists
|
||||
if !c.imageExists("hako-userland") {
|
||||
if err := c.BuildBaseImage(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
dockerfile, err := cfg.LanguageDockerfile(language)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get language dockerfile: %w", err)
|
||||
}
|
||||
|
||||
imageName := fmt.Sprintf("hako-userland-%s", language)
|
||||
|
||||
// Build the language image
|
||||
cmd := loggedCommand("docker", "build",
|
||||
"-f", dockerfile,
|
||||
"-t", imageName,
|
||||
filepath.Dir(dockerfile))
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to build %s image: %w", language, err)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Built language image: %s\n", imageName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateContainerName creates a container name from language and path.
|
||||
func (c *Client) GenerateContainerName(language, path string) (string, error) {
|
||||
// Sanitize the path for container naming
|
||||
sanitized := sanitizeContainerName(path)
|
||||
name := fmt.Sprintf("hako-%s-%s", language, sanitized)
|
||||
|
||||
// Docker container names have a 63 character limit
|
||||
if len(name) > 63 {
|
||||
return "", fmt.Errorf("container name too long: %s (max 63 chars)", name)
|
||||
}
|
||||
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// StartShell creates/starts a container and drops into a shell.
|
||||
func (c *Client) StartShell(containerName, language string, syncer interface{ ToContainer(string) error }) error {
|
||||
imageName := fmt.Sprintf("hako-userland-%s", language)
|
||||
|
||||
// Check if container exists
|
||||
if c.containerExists(containerName) {
|
||||
fmt.Printf("Starting existing container: %s\n", containerName)
|
||||
|
||||
// Start the container if it's stopped
|
||||
cmd := loggedCommand("docker", "start", containerName)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to start container: %w", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Creating new container: %s\n", containerName)
|
||||
|
||||
// Get current user info for running container as host user
|
||||
uid := os.Getenv("UID")
|
||||
if uid == "" {
|
||||
cmd := loggedCommand("id", "-u")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user ID: %w", err)
|
||||
}
|
||||
uid = strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
gid := os.Getenv("GID")
|
||||
if gid == "" {
|
||||
cmd := loggedCommand("id", "-g")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get group ID: %w", err)
|
||||
}
|
||||
gid = strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
// Get home directory for mounting Claude auth
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
args := []string{"run", "-it", "-d",
|
||||
"--name", containerName,
|
||||
"--user", fmt.Sprintf("%s:%s", uid, gid),
|
||||
"-w", "/workspace"}
|
||||
|
||||
// Mount Claude config file if it exists
|
||||
claudeConfigPath := filepath.Join(homeDir, ".claude.json")
|
||||
if _, err := os.Stat(claudeConfigPath); err == nil {
|
||||
args = append(args, "-v", fmt.Sprintf("%s:/workspace/.claude.json:ro", claudeConfigPath))
|
||||
}
|
||||
|
||||
// Mount Claude directory if it exists
|
||||
claudeDirPath := filepath.Join(homeDir, ".claude")
|
||||
if _, err := os.Stat(claudeDirPath); err == nil {
|
||||
args = append(args, "-v", fmt.Sprintf("%s:/workspace/.claude", claudeDirPath))
|
||||
}
|
||||
|
||||
args = append(args, imageName, "/bin/bash")
|
||||
|
||||
// Create and start the container
|
||||
cmd := loggedCommand("docker", args...)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to create container: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy workspace files to container
|
||||
if err := syncer.ToContainer(containerName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Execute interactive shell
|
||||
fmt.Printf("Dropping into shell in container: %s\n", containerName)
|
||||
cmd := loggedCommand("docker", "exec", "-it", containerName, "/bin/bash")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// GetCurrentContainer finds the container for the current directory.
|
||||
func (c *Client) GetCurrentContainer(path string) (string, error) {
|
||||
// This would need to search through containers to find one matching the path
|
||||
// For now, we'll implement a simple version
|
||||
sanitized := sanitizeContainerName(path)
|
||||
|
||||
// Try to find a container that matches this path pattern
|
||||
cmd := loggedCommand("docker", "ps", "-a", "--format", "{{.Names}}")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list containers: %w", err)
|
||||
}
|
||||
|
||||
names := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
for _, name := range names {
|
||||
if strings.Contains(name, sanitized) {
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no container found for current directory")
|
||||
}
|
||||
|
||||
// ListContainers returns all running hako containers.
|
||||
func (c *Client) ListContainers() ([]string, error) {
|
||||
cmd := loggedCommand("docker", "ps", "--filter", "name=hako-", "--format", "{{.Names}}")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list containers: %w", err)
|
||||
}
|
||||
|
||||
names := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(names) == 1 && names[0] == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// StopContainer stops and removes a container.
|
||||
func (c *Client) StopContainer(containerName string) error {
|
||||
// Stop the container
|
||||
cmd := loggedCommand("docker", "stop", containerName)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to stop container: %w", err)
|
||||
}
|
||||
|
||||
// Remove the container
|
||||
cmd = loggedCommand("docker", "rm", containerName)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to remove container: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Stopped and removed container: %s\n", containerName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// imageExists checks if a Docker image exists locally.
|
||||
func (c *Client) imageExists(imageName string) bool {
|
||||
cmd := loggedCommand("docker", "images", "-q", imageName)
|
||||
output, err := cmd.Output()
|
||||
return err == nil && len(strings.TrimSpace(string(output))) > 0
|
||||
}
|
||||
|
||||
// containerExists checks if a container exists.
|
||||
func (c *Client) containerExists(containerName string) bool {
|
||||
cmd := loggedCommand("docker", "ps", "-a", "-q", "-f", fmt.Sprintf("name=%s", containerName))
|
||||
output, err := cmd.Output()
|
||||
return err == nil && len(strings.TrimSpace(string(output))) > 0
|
||||
}
|
||||
|
||||
// sanitizeContainerName converts a path to a safe container name component.
|
||||
func sanitizeContainerName(path string) string {
|
||||
// Get just the directory name, not the full path
|
||||
base := filepath.Base(path)
|
||||
|
||||
// Replace unsafe characters with hyphens
|
||||
result := strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
return r
|
||||
}
|
||||
return '-'
|
||||
}, base)
|
||||
|
||||
// Remove leading/trailing hyphens and collapse multiple hyphens
|
||||
result = strings.Trim(result, "-")
|
||||
for strings.Contains(result, "--") {
|
||||
result = strings.ReplaceAll(result, "--", "-")
|
||||
}
|
||||
|
||||
return strings.ToLower(result)
|
||||
}
|
178
pkg/git/repo.go
178
pkg/git/repo.go
|
@ -1,178 +0,0 @@
|
|||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// loggedCommand creates a command and logs it to stderr
|
||||
func loggedCommand(name string, args ...string) *exec.Cmd {
|
||||
log.Printf("exec: %s %s", name, strings.Join(args, " "))
|
||||
return exec.Command(name, args...)
|
||||
}
|
||||
|
||||
// loggedCommandWithDir creates a command with a working directory and logs it
|
||||
func loggedCommandWithDir(dir, name string, args ...string) *exec.Cmd {
|
||||
log.Printf("exec (in %s): %s %s", dir, name, strings.Join(args, " "))
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Dir = dir
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Repo represents a git repository.
|
||||
type Repo struct {
|
||||
workDir string
|
||||
rootDir string
|
||||
}
|
||||
|
||||
// NewRepo creates a new git repository instance from the current directory.
|
||||
func NewRepo() (*Repo, error) {
|
||||
workDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if we're inside a git repository
|
||||
cmd := loggedCommandWithDir(workDir, "git", "rev-parse", "--is-inside-work-tree")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("not inside a git repository")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(string(output)) != "true" {
|
||||
return nil, fmt.Errorf("not inside a git repository")
|
||||
}
|
||||
|
||||
// Get the repository root directory
|
||||
cmd = loggedCommandWithDir(workDir, "git", "rev-parse", "--show-toplevel")
|
||||
output, err = cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get git root directory: %w", err)
|
||||
}
|
||||
|
||||
rootDir := strings.TrimSpace(string(output))
|
||||
|
||||
return &Repo{
|
||||
workDir: workDir,
|
||||
rootDir: rootDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Path returns the current working directory.
|
||||
func (r *Repo) Path() string {
|
||||
return r.workDir
|
||||
}
|
||||
|
||||
// Root returns the git repository root directory.
|
||||
func (r *Repo) Root() string {
|
||||
return r.rootDir
|
||||
}
|
||||
|
||||
// RelativePath returns the current directory relative to the git root.
|
||||
func (r *Repo) RelativePath() (string, error) {
|
||||
cmd := loggedCommandWithDir(r.workDir, "git", "rev-parse", "--show-prefix")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get relative path: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
|
||||
// ListFiles returns all tracked files in the repository.
|
||||
func (r *Repo) ListFiles() ([]string, error) {
|
||||
cmd := loggedCommandWithDir(r.rootDir, "git", "ls-files")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list git files: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(lines) == 1 && lines[0] == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// ListUntrackedFiles returns all untracked files that are not ignored.
|
||||
func (r *Repo) ListUntrackedFiles() ([]string, error) {
|
||||
cmd := loggedCommandWithDir(r.rootDir, "git", "ls-files", "--others", "--exclude-standard")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list untracked files: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(lines) == 1 && lines[0] == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// ListAllFiles returns both tracked and untracked files.
|
||||
func (r *Repo) ListAllFiles() ([]string, error) {
|
||||
trackedFiles, err := r.ListFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
untrackedFiles, err := r.ListUntrackedFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Combine both lists
|
||||
allFiles := make([]string, 0, len(trackedFiles)+len(untrackedFiles))
|
||||
allFiles = append(allFiles, trackedFiles...)
|
||||
allFiles = append(allFiles, untrackedFiles...)
|
||||
|
||||
return allFiles, nil
|
||||
}
|
||||
|
||||
// IsIgnored checks if a file path is ignored by git.
|
||||
func (r *Repo) IsIgnored(path string) (bool, error) {
|
||||
cmd := loggedCommandWithDir(r.rootDir, "git", "check-ignore", path)
|
||||
err := cmd.Run()
|
||||
|
||||
if err == nil {
|
||||
return true, nil // File is ignored
|
||||
}
|
||||
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
if exitError.ExitCode() == 1 {
|
||||
return false, nil // File is not ignored
|
||||
}
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("failed to check if file is ignored: %w", err)
|
||||
}
|
||||
|
||||
// ProjectName returns a sanitized project name based on the git root directory.
|
||||
func (r *Repo) ProjectName() string {
|
||||
return sanitizeName(filepath.Base(r.rootDir))
|
||||
}
|
||||
|
||||
// sanitizeName converts a path component to a safe container name part.
|
||||
func sanitizeName(name string) string {
|
||||
// Replace unsafe characters with hyphens
|
||||
result := strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
return r
|
||||
}
|
||||
return '-'
|
||||
}, name)
|
||||
|
||||
// Remove leading/trailing hyphens and collapse multiple hyphens
|
||||
result = strings.Trim(result, "-")
|
||||
for strings.Contains(result, "--") {
|
||||
result = strings.ReplaceAll(result, "--", "-")
|
||||
}
|
||||
|
||||
return strings.ToLower(result)
|
||||
}
|
89
pkg/hako.go
89
pkg/hako.go
|
@ -1,89 +0,0 @@
|
|||
// Package hako provides containerized development environments for Claude Code.
|
||||
package hako
|
||||
|
||||
import (
|
||||
"l4.pm/hako/pkg/config"
|
||||
"l4.pm/hako/pkg/docker"
|
||||
"l4.pm/hako/pkg/git"
|
||||
"l4.pm/hako/pkg/sync"
|
||||
)
|
||||
|
||||
// Manager manages hako containers and operations.
|
||||
type Manager struct {
|
||||
docker *docker.Client
|
||||
git *git.Repo
|
||||
sync *sync.Syncer
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// New creates a new hako manager.
|
||||
func New() (*Manager, error) {
|
||||
dockerClient, err := docker.NewClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gitRepo, err := git.NewRepo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
syncer := sync.New(gitRepo)
|
||||
|
||||
return &Manager{
|
||||
docker: dockerClient,
|
||||
git: gitRepo,
|
||||
sync: syncer,
|
||||
config: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Init builds the base image and optionally a language variant.
|
||||
func (m *Manager) Init(language string) error {
|
||||
if language == "" {
|
||||
return m.docker.BuildBaseImage(m.config)
|
||||
}
|
||||
return m.docker.BuildLanguageImage(language, m.config)
|
||||
}
|
||||
|
||||
// Up creates and starts a container, dropping into a shell.
|
||||
func (m *Manager) Up(language string) error {
|
||||
containerName, err := m.docker.GenerateContainerName(language, m.git.Path())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.docker.StartShell(containerName, language, m.sync)
|
||||
}
|
||||
|
||||
// Sync copies files from container back to host.
|
||||
func (m *Manager) Sync() error {
|
||||
containerName, err := m.docker.GetCurrentContainer(m.git.Path())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.sync.FromContainer(containerName)
|
||||
}
|
||||
|
||||
// List returns all running hako containers.
|
||||
func (m *Manager) List() ([]string, error) {
|
||||
return m.docker.ListContainers()
|
||||
}
|
||||
|
||||
// Down stops and removes a container.
|
||||
func (m *Manager) Down(containerName string) error {
|
||||
if containerName == "" {
|
||||
var err error
|
||||
containerName, err = m.docker.GetCurrentContainer(m.git.Path())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return m.docker.StopContainer(containerName)
|
||||
}
|
154
pkg/sync/sync.go
154
pkg/sync/sync.go
|
@ -1,154 +0,0 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"l4.pm/hako/pkg/git"
|
||||
)
|
||||
|
||||
// loggedCommand creates a command and logs it to stderr
|
||||
func loggedCommand(name string, args ...string) *exec.Cmd {
|
||||
log.Printf("exec: %s %s", name, strings.Join(args, " "))
|
||||
return exec.Command(name, args...)
|
||||
}
|
||||
|
||||
// Syncer handles file synchronization between host and containers.
|
||||
type Syncer struct {
|
||||
repo *git.Repo
|
||||
}
|
||||
|
||||
// New creates a new syncer.
|
||||
func New(repo *git.Repo) *Syncer {
|
||||
return &Syncer{
|
||||
repo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
// ToContainer copies files from host to container.
|
||||
func (s *Syncer) ToContainer(containerName string) error {
|
||||
workspaceFiles, err := s.getWorkspaceFiles()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get workspace files: %w", err)
|
||||
}
|
||||
|
||||
if len(workspaceFiles) == 0 {
|
||||
fmt.Println("No files to copy to container")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create workspace directory in container
|
||||
cmd := loggedCommand("docker", "exec", containerName, "mkdir", "-p", "/workspace")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to create workspace directory in container: %w", err)
|
||||
}
|
||||
|
||||
// Copy files to container
|
||||
for _, file := range workspaceFiles {
|
||||
srcPath := filepath.Join(s.repo.Root(), file)
|
||||
dstPath := fmt.Sprintf("%s:/workspace/%s", containerName, file)
|
||||
|
||||
// Ensure directory exists in container
|
||||
dir := filepath.Dir(file)
|
||||
if dir != "." {
|
||||
cmd := loggedCommand("docker", "exec", containerName, "mkdir", "-p", fmt.Sprintf("/workspace/%s", dir))
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s in container: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
cmd := loggedCommand("docker", "cp", srcPath, dstPath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to copy %s to container: %w", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Copied %d files to container\n", len(workspaceFiles))
|
||||
return nil
|
||||
}
|
||||
|
||||
// FromContainer copies files from container back to host.
|
||||
func (s *Syncer) FromContainer(containerName string) error {
|
||||
// Get list of files in container workspace
|
||||
cmd := loggedCommand("docker", "exec", containerName, "find", "/workspace", "-type", "f", "-not", "-path", "/workspace/.git/*")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list files in container: %w", err)
|
||||
}
|
||||
|
||||
containerFiles := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(containerFiles) == 1 && containerFiles[0] == "" {
|
||||
fmt.Println("No files found in container workspace")
|
||||
return nil
|
||||
}
|
||||
|
||||
copied := 0
|
||||
for _, containerFile := range containerFiles {
|
||||
// Remove /workspace prefix to get relative path
|
||||
relPath := strings.TrimPrefix(containerFile, "/workspace/")
|
||||
if relPath == containerFile {
|
||||
continue // Skip files not in workspace
|
||||
}
|
||||
|
||||
// Check if file should be ignored
|
||||
ignored, err := s.repo.IsIgnored(relPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to check if %s is ignored: %v\n", relPath, err)
|
||||
}
|
||||
if ignored {
|
||||
continue
|
||||
}
|
||||
|
||||
// Copy file from container to host
|
||||
srcPath := fmt.Sprintf("%s:%s", containerName, containerFile)
|
||||
dstPath := filepath.Join(s.repo.Root(), relPath)
|
||||
|
||||
// Ensure directory exists on host
|
||||
dir := filepath.Dir(dstPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
cmd := loggedCommand("docker", "cp", srcPath, dstPath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to copy %s from container: %w", relPath, err)
|
||||
}
|
||||
|
||||
copied++
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Synced %d files from container\n", copied)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getWorkspaceFiles returns all files that should be copied to the workspace.
|
||||
func (s *Syncer) getWorkspaceFiles() ([]string, error) {
|
||||
// Get all files (tracked + untracked) from git
|
||||
files, err := s.repo.ListAllFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter out files that shouldn't be copied
|
||||
var workspaceFiles []string
|
||||
for _, file := range files {
|
||||
// Skip .git directory
|
||||
if strings.HasPrefix(file, ".git/") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if file exists (might have been deleted)
|
||||
fullPath := filepath.Join(s.repo.Root(), file)
|
||||
if _, err := os.Stat(fullPath); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
workspaceFiles = append(workspaceFiles, file)
|
||||
}
|
||||
|
||||
return workspaceFiles, nil
|
||||
}
|
140
utils.go
Normal file
140
utils.go
Normal file
|
@ -0,0 +1,140 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func runCommand(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
fmt.Fprintf(os.Stderr, "+ %s %s\n", name, strings.Join(args, " "))
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func runCommandSilent(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
fmt.Fprintf(os.Stderr, "+ %s %s\n", name, strings.Join(args, " "))
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func runCommandOutput(name string, args ...string) (string, error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
fmt.Fprintf(os.Stderr, "+ %s %s\n", name, strings.Join(args, " "))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
|
||||
// GitIgnore represents a .gitignore parser
|
||||
type GitIgnore struct {
|
||||
patterns []string
|
||||
}
|
||||
|
||||
// NewGitIgnore creates a new GitIgnore parser by reading .gitignore file
|
||||
func NewGitIgnore() (*GitIgnore, error) {
|
||||
gi := &GitIgnore{}
|
||||
|
||||
file, err := os.Open(".gitignore")
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return gi, nil // No .gitignore file is fine
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line != "" && !strings.HasPrefix(line, "#") {
|
||||
gi.patterns = append(gi.patterns, line)
|
||||
}
|
||||
}
|
||||
|
||||
return gi, scanner.Err()
|
||||
}
|
||||
|
||||
// IsIgnored checks if a file path should be ignored according to .gitignore patterns
|
||||
func (gi *GitIgnore) IsIgnored(path string) bool {
|
||||
for _, pattern := range gi.patterns {
|
||||
if gi.matchPattern(pattern, path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchPattern implements basic gitignore pattern matching
|
||||
func (gi *GitIgnore) matchPattern(pattern, path string) bool {
|
||||
// Handle negation patterns (starting with !)
|
||||
if strings.HasPrefix(pattern, "!") {
|
||||
return false // Negation patterns would require more complex logic
|
||||
}
|
||||
|
||||
// Convert gitignore pattern to filepath.Match compatible pattern
|
||||
// Handle directory patterns (ending with /)
|
||||
if strings.HasSuffix(pattern, "/") {
|
||||
pattern = strings.TrimSuffix(pattern, "/")
|
||||
// Check if any directory component matches
|
||||
parts := strings.Split(path, string(filepath.Separator))
|
||||
for _, part := range parts {
|
||||
if matched, _ := filepath.Match(pattern, part); matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Handle patterns with path separators
|
||||
if strings.Contains(pattern, "/") {
|
||||
// Exact path match
|
||||
if matched, _ := filepath.Match(pattern, path); matched {
|
||||
return true
|
||||
}
|
||||
// Check if pattern matches any suffix of the path
|
||||
pathParts := strings.Split(path, string(filepath.Separator))
|
||||
patternParts := strings.Split(pattern, "/")
|
||||
|
||||
if len(patternParts) <= len(pathParts) {
|
||||
for i := 0; i <= len(pathParts)-len(patternParts); i++ {
|
||||
match := true
|
||||
for j, patternPart := range patternParts {
|
||||
if matched, _ := filepath.Match(patternPart, pathParts[i+j]); !matched {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Simple filename pattern - check against basename and any path component
|
||||
basename := filepath.Base(path)
|
||||
if matched, _ := filepath.Match(pattern, basename); matched {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if pattern matches any directory component
|
||||
parts := strings.Split(path, string(filepath.Separator))
|
||||
for _, part := range parts {
|
||||
if matched, _ := filepath.Match(pattern, part); matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue