diff --git a/cli/down.go b/cli/down.go index ec2bf9f..20e726b 100644 --- a/cli/down.go +++ b/cli/down.go @@ -32,4 +32,4 @@ Examples: return manager.Down(containerName) }, -} \ No newline at end of file +} diff --git a/cli/init.go b/cli/init.go index 41f50a0..dc39c01 100644 --- a/cli/init.go +++ b/cli/init.go @@ -31,4 +31,4 @@ Examples: return manager.Init(language) }, -} \ No newline at end of file +} diff --git a/cli/ps.go b/cli/ps.go index fc933ec..0d69fc1 100644 --- a/cli/ps.go +++ b/cli/ps.go @@ -34,4 +34,4 @@ var psCmd = &cobra.Command{ return nil }, -} \ No newline at end of file +} diff --git a/cli/root.go b/cli/root.go index bfb4e78..7ec8359 100644 --- a/cli/root.go +++ b/cli/root.go @@ -30,4 +30,4 @@ func init() { rootCmd.AddCommand(syncCmd) rootCmd.AddCommand(psCmd) rootCmd.AddCommand(downCmd) -} \ No newline at end of file +} diff --git a/cli/sync.go b/cli/sync.go index 9158865..125b58f 100644 --- a/cli/sync.go +++ b/cli/sync.go @@ -25,4 +25,4 @@ project directory.`, return manager.Sync() }, -} \ No newline at end of file +} diff --git a/cli/up.go b/cli/up.go index 23e43dc..3a75ccd 100644 --- a/cli/up.go +++ b/cli/up.go @@ -31,4 +31,4 @@ Examples: language := args[0] return manager.Up(language) }, -} \ No newline at end of file +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 5fb2ae1..dc65d5b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -36,7 +36,7 @@ func (c *Config) BaseDockerfilePath() string { // 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 } @@ -86,7 +86,7 @@ CMD ["/bin/bash"] // 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 } @@ -167,4 +167,4 @@ CMD ["/bin/bash"] default: return "", fmt.Errorf("unsupported language: %s", language) } -} \ No newline at end of file +} diff --git a/pkg/docker/client.go b/pkg/docker/client.go index 02092af..00d7ef1 100644 --- a/pkg/docker/client.go +++ b/pkg/docker/client.go @@ -3,6 +3,7 @@ package docker import ( "context" "fmt" + "log" "os" "os/exec" "path/filepath" @@ -11,6 +12,12 @@ import ( "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 @@ -19,7 +26,7 @@ type Client struct { // NewClient creates a new Docker client. func NewClient() (*Client, error) { // Check if Docker is available - cmd := exec.Command("docker", "version", "--format", "{{.Server.Version}}") + cmd := loggedCommand("docker", "version", "--format", "{{.Server.Version}}") if err := cmd.Run(); err != nil { return nil, fmt.Errorf("docker is not available: %w", err) } @@ -32,21 +39,21 @@ func NewClient() (*Client, error) { // 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 := exec.Command("docker", "build", + 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) } @@ -70,16 +77,16 @@ func (c *Client) BuildLanguageImage(language string, cfg *config.Config) error { } imageName := fmt.Sprintf("hako-userland-%s", language) - + // Build the language image - cmd := exec.Command("docker", "build", + 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) } @@ -93,97 +100,97 @@ 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 := exec.Command("docker", "start", containerName) + 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 := exec.Command("id", "-u") + 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 := exec.Command("id", "-g") + 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:/home/user/.claude.json:ro", claudeConfigPath)) + 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:/home/user/.claude", claudeDirPath)) + args = append(args, "-v", fmt.Sprintf("%s:/workspace/.claude", claudeDirPath)) } - + args = append(args, imageName, "/bin/bash") - + // Create and start the container - cmd := exec.Command("docker", args...) - + 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 := exec.Command("docker", "exec", "-it", containerName, "/bin/bash") + cmd := loggedCommand("docker", "exec", "-it", containerName, "/bin/bash") cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - + return cmd.Run() } @@ -192,68 +199,68 @@ 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 := exec.Command("docker", "ps", "-a", "--format", "{{.Names}}") + 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 := exec.Command("docker", "ps", "--filter", "name=hako-", "--format", "{{.Names}}") + 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 := exec.Command("docker", "stop", containerName) + cmd := loggedCommand("docker", "stop", containerName) if err := cmd.Run(); err != nil { return fmt.Errorf("failed to stop container: %w", err) } - + // Remove the container - cmd = exec.Command("docker", "rm", containerName) + 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 := exec.Command("docker", "images", "-q", imageName) + 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 := exec.Command("docker", "ps", "-a", "-q", "-f", fmt.Sprintf("name=%s", containerName)) + 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 } @@ -262,7 +269,7 @@ func (c *Client) containerExists(containerName string) bool { 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') { @@ -270,12 +277,12 @@ func sanitizeContainerName(path string) string { } 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) -} \ No newline at end of file +} diff --git a/pkg/git/repo.go b/pkg/git/repo.go index b033b26..698b96f 100644 --- a/pkg/git/repo.go +++ b/pkg/git/repo.go @@ -2,12 +2,27 @@ 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 @@ -22,8 +37,7 @@ func NewRepo() (*Repo, error) { } // Check if we're inside a git repository - cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree") - cmd.Dir = workDir + 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") @@ -34,8 +48,7 @@ func NewRepo() (*Repo, error) { } // Get the repository root directory - cmd = exec.Command("git", "rev-parse", "--show-toplevel") - cmd.Dir = workDir + 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) @@ -61,8 +74,7 @@ func (r *Repo) Root() string { // RelativePath returns the current directory relative to the git root. func (r *Repo) RelativePath() (string, error) { - cmd := exec.Command("git", "rev-parse", "--show-prefix") - cmd.Dir = r.workDir + 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) @@ -73,8 +85,7 @@ func (r *Repo) RelativePath() (string, error) { // ListFiles returns all tracked files in the repository. func (r *Repo) ListFiles() ([]string, error) { - cmd := exec.Command("git", "ls-files") - cmd.Dir = r.rootDir + 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) @@ -90,8 +101,7 @@ func (r *Repo) ListFiles() ([]string, error) { // ListUntrackedFiles returns all untracked files that are not ignored. func (r *Repo) ListUntrackedFiles() ([]string, error) { - cmd := exec.Command("git", "ls-files", "--others", "--exclude-standard") - cmd.Dir = r.rootDir + 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) @@ -127,20 +137,19 @@ func (r *Repo) ListAllFiles() ([]string, error) { // IsIgnored checks if a file path is ignored by git. func (r *Repo) IsIgnored(path string) (bool, error) { - cmd := exec.Command("git", "check-ignore", path) - cmd.Dir = r.rootDir + 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) } @@ -158,12 +167,12 @@ func sanitizeName(name string) string { } 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) -} \ No newline at end of file +} diff --git a/pkg/sync/sync.go b/pkg/sync/sync.go index 8542875..4d87cdd 100644 --- a/pkg/sync/sync.go +++ b/pkg/sync/sync.go @@ -2,6 +2,7 @@ package sync import ( "fmt" + "log" "os" "os/exec" "path/filepath" @@ -10,6 +11,12 @@ import ( "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 @@ -35,7 +42,7 @@ func (s *Syncer) ToContainer(containerName string) error { } // Create workspace directory in container - cmd := exec.Command("docker", "exec", containerName, "mkdir", "-p", "/workspace") + 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) } @@ -44,17 +51,17 @@ func (s *Syncer) ToContainer(containerName string) error { 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 := exec.Command("docker", "exec", containerName, "mkdir", "-p", fmt.Sprintf("/workspace/%s", 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 := exec.Command("docker", "cp", srcPath, dstPath) + + cmd := loggedCommand("docker", "cp", srcPath, dstPath) if err := cmd.Run(); err != nil { return fmt.Errorf("failed to copy %s to container: %w", file, err) } @@ -67,7 +74,7 @@ func (s *Syncer) ToContainer(containerName string) error { // FromContainer copies files from container back to host. func (s *Syncer) FromContainer(containerName string) error { // Get list of files in container workspace - cmd := exec.Command("docker", "exec", containerName, "find", "/workspace", "-type", "f", "-not", "-path", "/workspace/.git/*") + 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) @@ -99,18 +106,18 @@ func (s *Syncer) FromContainer(containerName string) error { // 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 := exec.Command("docker", "cp", srcPath, dstPath) + + 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++ } @@ -133,15 +140,15 @@ func (s *Syncer) getWorkspaceFiles() ([]string, error) { 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 -} \ No newline at end of file +}