From c3542179ad55359e4943f1d9a8777cbabb58944f Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Tue, 10 Feb 2026 14:43:56 -0500 Subject: [PATCH 01/11] docs: add design for simplified igloo Ground-up rethink focused on GTK dev containers. Two commands (igloo + igloo destroy), no config file, auto-detect host distro with ID_LIKE fallback, always-on display passthrough. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-02-10-simplify-igloo-design.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/plans/2026-02-10-simplify-igloo-design.md diff --git a/docs/plans/2026-02-10-simplify-igloo-design.md b/docs/plans/2026-02-10-simplify-igloo-design.md new file mode 100644 index 0000000..4609f8f --- /dev/null +++ b/docs/plans/2026-02-10-simplify-igloo-design.md @@ -0,0 +1,107 @@ +# Simplify Igloo: GTK Dev Container Tool + +## Goal + +Strip igloo down from a general-purpose container tool to a focused, opinionated GTK development container tool. Two commands, no config file, half the code. + +## Commands + +### `igloo` + +The only command used day-to-day. No subcommand needed. + +1. Derives container name from current directory (e.g. `igloo-my-gtk-app`) +2. If container doesn't exist: + - Detects host distro from `/etc/os-release` (`ID` first, then `ID_LIKE` fallback chain) to find an Incus-supported image + - Creates container with cloud-init for UID/GID mapping + - Mounts current project directory at the same absolute path inside the container + - Copies dotfiles (`.gitconfig`, `.ssh/`, `.bashrc`, `.profile`, `.bash_profile`) from host home into container home + - Sets up display passthrough (X11 or Wayland, auto-detected) with GPU + - Runs `.igloo.sh` from project root inside the container if it exists + - Stores provisioned marker in container metadata +3. If container exists but stopped, starts it +4. Refreshes display passthrough (every entry, handles display server restarts) +5. Drops into interactive shell + +**Flag:** `--no-gui` skips display/GPU setup for headless use. + +### `igloo destroy` + +Removes the container completely. That's it. + +## Project Setup + +No `init` command. Optionally create one file: + +**`.igloo.sh`** -- A plain shell script that runs inside the container on first creation. + +```sh +#!/bin/bash +apt-get update +apt-get install -y build-essential libgtk-4-dev meson ninja-build +``` + +If the file doesn't exist, igloo creates a bare container. To reprovision after changing `.igloo.sh`, run `igloo destroy` then `igloo`. + +## Distro Detection + +Read `/etc/os-release`. Use `ID` and `VERSION_ID` to look up an Incus image. If the `ID` doesn't match a known image, walk the `ID_LIKE` chain (space-separated list) until a match is found. This handles Debian derivatives (Pop!_OS -> ubuntu -> debian). + +## Display Passthrough + +Always on unless `--no-gui` is passed. On every entry: + +- Detect display server via `WAYLAND_DISPLAY` and `DISPLAY` env vars +- **Wayland:** Proxy the Wayland socket into the container, set `WAYLAND_DISPLAY` +- **X11:** Proxy X11 socket, handle Xauthority +- **GPU:** Add host GPU as container device for hardware-accelerated rendering + +Refreshing on every entry (not just creation) fixes display server restart issues. + +## File Access + +- **Project directory:** Mounted at the same absolute path inside the container +- **Dotfiles:** Copied (not symlinked) on creation: `.gitconfig`, `.ssh/`, `.bashrc`, `.profile`, `.bash_profile` +- **Home directory:** Not mounted. Container has its own isolated home. + +## Codebase Structure + +``` +igloo/ +├── main.go # Entry point, version info +├── cmd/ +│ ├── root.go # Root command, registers enter + destroy +│ ├── enter.go # Default command: create/enter container +│ └── destroy.go # Remove container completely +├── internal/ +│ ├── host/ +│ │ └── detect.go # /etc/os-release parsing, ID_LIKE fallback +│ ├── incus/ +│ │ ├── client.go # Incus operations (create, start, exec, delete) +│ │ └── cloudinit.go # Cloud-init for user mapping +│ ├── display/ +│ │ ├── detect.go # X11 vs Wayland detection +│ │ └── passthrough.go # Socket proxying, Xauth, GPU device +│ └── ui/ +│ └── styles.go # Terminal styling +├── go.mod +├── Makefile +└── .goreleaser.yaml +``` + +## What Gets Removed + +**Commands eliminated:** `init`, `provision`, `status`, `stop`, `remove` (5 of 7) + +**Packages removed or replaced:** +- `internal/config/` (5 files: config.go, hash.go, distros.go, hostdetect.go, tests) replaced by `internal/host/detect.go` +- `internal/script/` removed; `.igloo.sh` execution lives in `enter.go` + +**Config files eliminated:** +- `.igloo/igloo.ini` +- `.igloo/scripts/` directory + +**Dependencies droppable:** +- `gopkg.in/ini.v1` (no more INI parsing) + +**Estimated reduction:** ~3,300 LOC to ~1,500 LOC. From 4fbd67dc91639c871d3c28e305f757b28ce3996b Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Tue, 10 Feb 2026 14:45:33 -0500 Subject: [PATCH 02/11] chore: add .worktrees/ to gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7a38d05..2167a20 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ dist/ completions/ manpages/ igloo_test + +# Worktrees +.worktrees/ From c8c0990a8b932169e9d8bf15f6396447ac933234 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Tue, 10 Feb 2026 14:48:48 -0500 Subject: [PATCH 03/11] docs: add implementation plan for igloo simplification 8 tasks: host detection, cloud-init decoupling, enter rewrite, destroy simplification, root command, file deletion, dep cleanup, README update. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-02-10-simplify-igloo-plan.md | 931 +++++++++++++++++++ 1 file changed, 931 insertions(+) create mode 100644 docs/plans/2026-02-10-simplify-igloo-plan.md diff --git a/docs/plans/2026-02-10-simplify-igloo-plan.md b/docs/plans/2026-02-10-simplify-igloo-plan.md new file mode 100644 index 0000000..4cfbe36 --- /dev/null +++ b/docs/plans/2026-02-10-simplify-igloo-plan.md @@ -0,0 +1,931 @@ +# Simplify Igloo Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Strip igloo to a two-command GTK dev container tool with no config file, auto-detected host distro with ID_LIKE fallback, and always-on display passthrough. + +**Architecture:** Replace the config-driven approach (INI file, multi-distro validation, config hashing) with convention-based defaults. All intelligence moves into a single `enter` code path. Host distro detection uses `/etc/os-release` with `ID_LIKE` fallback for derivatives. Display passthrough refreshes on every entry. + +**Tech Stack:** Go, cobra, incus CLI, charmbracelet/lipgloss for terminal styling. Drops gopkg.in/ini.v1. + +--- + +### Task 1: Create internal/host package with OS detection + +The new `internal/host` package replaces `internal/config/hostdetect.go` and `internal/config/distros.go`. It reads `/etc/os-release`, tries `ID` first, then walks the `ID_LIKE` chain to find a distro that Incus has a cloud image for. No hardcoded release lists — just known distro families. + +**Files:** +- Create: `internal/host/detect.go` +- Create: `internal/host/detect_test.go` + +**Step 1: Write the failing test** + +Create `internal/host/detect_test.go`: + +```go +package host + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectOS(t *testing.T) { + tests := []struct { + name string + content string + wantID string + wantVer string + wantImage string + }{ + { + name: "debian trixie", + content: `ID=debian +VERSION_CODENAME=trixie +`, + wantID: "debian", + wantVer: "trixie", + wantImage: "images:debian/trixie/cloud", + }, + { + name: "ubuntu noble", + content: `ID=ubuntu +VERSION_CODENAME=noble +`, + wantID: "ubuntu", + wantVer: "noble", + wantImage: "images:ubuntu/noble/cloud", + }, + { + name: "pop os falls back via ID_LIKE to ubuntu", + content: `ID=pop +VERSION_CODENAME=noble +ID_LIKE="ubuntu debian" +`, + wantID: "ubuntu", + wantVer: "noble", + wantImage: "images:ubuntu/noble/cloud", + }, + { + name: "linuxmint falls back via ID_LIKE to ubuntu", + content: `ID=linuxmint +VERSION_CODENAME=wilma +ID_LIKE="ubuntu debian" +UBUNTU_CODENAME=noble +`, + wantID: "ubuntu", + wantVer: "noble", + wantImage: "images:ubuntu/noble/cloud", + }, + { + name: "unknown distro with debian ID_LIKE", + content: `ID=mxlinux +VERSION_CODENAME=libretto +ID_LIKE="debian" +`, + wantID: "debian", + wantVer: "libretto", + wantImage: "images:debian/libretto/cloud", + }, + { + name: "completely unknown falls back to debian trixie", + content: `ID=gentoo +`, + wantID: "debian", + wantVer: "trixie", + wantImage: "images:debian/trixie/cloud", + }, + { + name: "missing file falls back to debian trixie", + content: "", + wantID: "debian", + wantVer: "trixie", + wantImage: "images:debian/trixie/cloud", + }, + { + name: "fedora uses VERSION_ID", + content: `ID=fedora +VERSION_ID=43 +`, + wantID: "fedora", + wantVer: "43", + wantImage: "images:fedora/43/cloud", + }, + { + name: "arch uses current", + content: `ID=archlinux +`, + wantID: "archlinux", + wantVer: "current", + wantImage: "images:archlinux/current/cloud", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var path string + if tt.content == "" { + path = "/nonexistent/os-release" + } else { + tmpDir := t.TempDir() + path = filepath.Join(tmpDir, "os-release") + if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil { + t.Fatalf("failed to write os-release: %v", err) + } + } + + info := detectFromFile(path) + + if info.ID != tt.wantID { + t.Errorf("ID = %q, want %q", info.ID, tt.wantID) + } + if info.Version != tt.wantVer { + t.Errorf("Version = %q, want %q", info.Version, tt.wantVer) + } + if info.Image() != tt.wantImage { + t.Errorf("Image() = %q, want %q", info.Image(), tt.wantImage) + } + }) + } +} + +func TestContainerName(t *testing.T) { + name := ContainerName("/home/bjk/projects/my-gtk-app") + if name != "igloo-my-gtk-app" { + t.Errorf("ContainerName() = %q, want %q", name, "igloo-my-gtk-app") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /home/bjk/projects/frostyard/igloo/.worktrees/simplify && go test ./internal/host/...` +Expected: FAIL — package doesn't exist yet + +**Step 3: Write the implementation** + +Create `internal/host/detect.go`: + +```go +package host + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Known distro families that have Incus cloud images. +var knownDistros = map[string]bool{ + "debian": true, + "ubuntu": true, + "fedora": true, + "archlinux": true, +} + +// Distros that use VERSION_ID instead of VERSION_CODENAME. +var usesVersionID = map[string]bool{ + "fedora": true, +} + +// Distros that have a fixed version string. +var fixedVersion = map[string]string{ + "archlinux": "current", +} + +// OSInfo holds the detected host OS information. +type OSInfo struct { + ID string // distro family (debian, ubuntu, etc.) + Version string // release codename or version number +} + +// Image returns the Incus image string for this OS. +func (o OSInfo) Image() string { + return fmt.Sprintf("images:%s/%s/cloud", o.ID, o.Version) +} + +// DetectOS reads /etc/os-release and returns OS info with ID_LIKE fallback. +func DetectOS() OSInfo { + return detectFromFile("/etc/os-release") +} + +// ContainerName derives the igloo container name from a project directory path. +func ContainerName(projectDir string) string { + return "igloo-" + filepath.Base(projectDir) +} + +func detectFromFile(path string) OSInfo { + fallback := OSInfo{ID: "debian", Version: "trixie"} + + fields := parseOSRelease(path) + if len(fields) == 0 { + return fallback + } + + id := strings.ToLower(fields["ID"]) + if id == "" { + return fallback + } + + // Try the primary ID first, then walk ID_LIKE. + candidates := []string{id} + if idLike := fields["ID_LIKE"]; idLike != "" { + candidates = append(candidates, strings.Fields(idLike)...) + } + + for _, candidate := range candidates { + if !knownDistros[candidate] { + continue + } + ver := versionFor(candidate, fields) + if ver == "" { + continue + } + return OSInfo{ID: candidate, Version: ver} + } + + return fallback +} + +// versionFor returns the version string for a known distro, given os-release fields. +func versionFor(distro string, fields map[string]string) string { + if v, ok := fixedVersion[distro]; ok { + return v + } + if usesVersionID[distro] { + return fields["VERSION_ID"] + } + // For distros using codename: prefer UBUNTU_CODENAME (set by Ubuntu derivatives), + // then VERSION_CODENAME. + if distro == "ubuntu" || distro == "debian" { + if uc := fields["UBUNTU_CODENAME"]; uc != "" && distro == "ubuntu" { + return strings.ToLower(uc) + } + } + return strings.ToLower(fields["VERSION_CODENAME"]) +} + +func parseOSRelease(path string) map[string]string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + fields := make(map[string]string) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + fields[k] = strings.Trim(v, "\"") + } + return fields +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /home/bjk/projects/frostyard/igloo/.worktrees/simplify && go test ./internal/host/... -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/host/detect.go internal/host/detect_test.go +git commit -m "feat: add internal/host package with ID_LIKE distro detection" +``` + +--- + +### Task 2: Decouple cloud-init from config package + +Currently `GenerateCloudInit` takes `*config.IglooConfig`. Change it to take simple parameters so it has no dependency on the config package (which we'll delete later). + +**Files:** +- Modify: `internal/incus/cloudinit.go` +- Modify: `internal/incus/cloudinit_test.go` + +**Step 1: Write the failing test** + +Replace `internal/incus/cloudinit_test.go` — remove all references to `config.IglooConfig`: + +```go +package incus + +import ( + "strings" + "testing" +) + +func TestGenerateCloudInit(t *testing.T) { + result, err := GenerateCloudInit() + if err != nil { + t.Fatalf("GenerateCloudInit() failed: %v", err) + } + + if !strings.HasPrefix(result, "#cloud-config") { + t.Error("should start with #cloud-config") + } + if !strings.Contains(result, "users:") { + t.Error("should contain users section") + } + if !strings.Contains(result, "runcmd:") { + t.Error("should contain runcmd section") + } + if !strings.Contains(result, "timezone:") { + t.Error("should contain timezone") + } + // Should NOT contain packages section (packages come from .igloo.sh now) + if strings.Contains(result, "packages:") { + t.Error("should not contain packages section") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /home/bjk/projects/frostyard/igloo/.worktrees/simplify && go test ./internal/incus/... -run TestGenerateCloudInit` +Expected: FAIL — signature mismatch + +**Step 3: Write the implementation** + +Replace `internal/incus/cloudinit.go` — remove config import, remove package handling, take no args (reads user/tz from environment): + +```go +package incus + +import ( + "bytes" + "fmt" + "os" + "os/user" + "strings" + "text/template" + "time" +) + +const cloudInitTemplate = `#cloud-config +# Generated by igloo on {{.Timestamp}} + +users: + - name: {{.Username}} + uid: {{.UID}} + groups: sudo, video, render, audio + shell: /bin/bash + sudo: ALL=(ALL) NOPASSWD:ALL + lock_passwd: true + +timezone: {{.Timezone}} + +runcmd: + - mkdir -p /home/{{.Username}} + - chown {{.UID}}:{{.GID}} /home/{{.Username}} + - mkdir -p /run/user/{{.UID}} + - chown {{.UID}}:{{.GID}} /run/user/{{.UID}} + - chmod 700 /run/user/{{.UID}} +` + +type cloudInitData struct { + Username string + UID int + GID int + Timezone string + Timestamp string +} + +// GenerateCloudInit creates a cloud-init config for user mapping and basic setup. +func GenerateCloudInit() (string, error) { + currentUser, err := user.Current() + if err != nil { + return "", fmt.Errorf("failed to get current user: %w", err) + } + + data := cloudInitData{ + Username: currentUser.Username, + UID: os.Getuid(), + GID: os.Getgid(), + Timezone: getTimezone(), + Timestamp: time.Now().Format(time.RFC3339), + } + + tmpl, err := template.New("cloud-init").Parse(cloudInitTemplate) + if err != nil { + return "", fmt.Errorf("failed to parse cloud-init template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute cloud-init template: %w", err) + } + + return buf.String(), nil +} + +func getTimezone() string { + if data, err := os.ReadFile("/etc/timezone"); err == nil { + return strings.TrimSpace(string(data)) + } + if target, err := os.Readlink("/etc/localtime"); err == nil { + parts := strings.Split(target, "/zoneinfo/") + if len(parts) == 2 { + return parts[1] + } + } + if tz := os.Getenv("TZ"); tz != "" { + return tz + } + return "UTC" +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /home/bjk/projects/frostyard/igloo/.worktrees/simplify && go test ./internal/incus/... -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/incus/cloudinit.go internal/incus/cloudinit_test.go +git commit -m "refactor: decouple cloud-init from config package" +``` + +--- + +### Task 3: Rewrite cmd/enter.go as the default command + +This is the main rewrite. The enter command becomes the root command's default action. It handles the full lifecycle: detect OS, create container if needed, mount project, copy dotfiles, set up display, run `.igloo.sh`, and exec shell. + +**Files:** +- Rewrite: `cmd/enter.go` + +**Step 1: Write the implementation** + +No unit test for this command (it orchestrates incus CLI calls). Replace `cmd/enter.go`: + +```go +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/frostyard/igloo/internal/display" + "github.com/frostyard/igloo/internal/host" + "github.com/frostyard/igloo/internal/incus" + "github.com/frostyard/igloo/internal/ui" + "github.com/spf13/cobra" +) + +var noGUI bool + +func enterCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "igloo", + Short: "Enter the igloo development container", + Long: `Creates and enters an isolated development container. + +If no container exists for the current directory, one is created automatically +using the host OS, with display passthrough and GPU support enabled. + +Place a .igloo.sh script in your project root to run custom setup on first creation.`, + Example: ` # Enter (or create) the container + igloo + + # Enter without GUI support + igloo --no-gui`, + RunE: func(cmd *cobra.Command, args []string) error { + return runEnter() + }, + } + + cmd.Flags().BoolVar(&noGUI, "no-gui", false, "Skip display and GPU passthrough") + + return cmd +} + +// dotfiles to copy from host home to container home on creation. +var dotfiles = []string{ + ".gitconfig", + ".ssh", + ".bashrc", + ".profile", + ".bash_profile", +} + +func runEnter() error { + styles := ui.NewStyles() + client := incus.NewClient() + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + name := host.ContainerName(cwd) + username := os.Getenv("USER") + homeDir := os.Getenv("HOME") + + exists, err := client.InstanceExists(name) + if err != nil { + return fmt.Errorf("failed to check instance: %w", err) + } + + if !exists { + if err := provision(client, styles, name, username, homeDir, cwd); err != nil { + return err + } + } + + // Start if stopped + running, err := client.IsRunning(name) + if err != nil { + return fmt.Errorf("failed to check instance status: %w", err) + } + if !running { + fmt.Println(styles.Info("Starting container...")) + if err := client.Start(name); err != nil { + return fmt.Errorf("failed to start instance: %w", err) + } + fmt.Println(styles.Info("Waiting for container to be ready...")) + if err := client.WaitForCloudInit(name); err != nil { + fmt.Println(styles.Warning("Cloud-init wait timed out, continuing anyway...")) + } + } + + // Refresh display passthrough on every entry + if !noGUI { + if err := client.UpdateXauthority(name); err != nil { + fmt.Println(styles.Warning(fmt.Sprintf("Could not update Xauthority: %v", err))) + } + } + + fmt.Println(styles.Info(fmt.Sprintf("Entering %s...", name))) + return client.ExecInteractive(name, username, cwd) +} + +func provision(client *incus.Client, styles *ui.Styles, name, username, homeDir, cwd string) error { + // Detect host OS + osInfo := host.DetectOS() + image := osInfo.Image() + fmt.Println(styles.Info(fmt.Sprintf("Detected host: %s/%s", osInfo.ID, osInfo.Version))) + fmt.Println(styles.Info(fmt.Sprintf("Creating container %s from %s...", name, image))) + + // Generate cloud-init + cloudInit, err := incus.GenerateCloudInit() + if err != nil { + return fmt.Errorf("failed to generate cloud-init: %w", err) + } + + // Create instance + if err := client.Create(name, image, cloudInit); err != nil { + return fmt.Errorf("failed to create instance: %w", err) + } + + // Mount project directory at the same absolute path + fmt.Println(styles.Info(fmt.Sprintf("Mounting project at %s...", cwd))) + if err := client.AddDiskDevice(name, "project", cwd, cwd); err != nil { + return fmt.Errorf("failed to mount project directory: %w", err) + } + + // Start container (before display passthrough, so /run/user exists) + fmt.Println(styles.Info("Starting container...")) + if err := client.Start(name); err != nil { + return fmt.Errorf("failed to start instance: %w", err) + } + + fmt.Println(styles.Info("Waiting for cloud-init to complete...")) + if err := client.WaitForCloudInit(name); err != nil { + return fmt.Errorf("cloud-init failed: %w", err) + } + + // Copy dotfiles from host home into container home + fmt.Println(styles.Info("Copying dotfiles...")) + containerHome := fmt.Sprintf("/home/%s", username) + for _, df := range dotfiles { + src := filepath.Join(homeDir, df) + if _, err := os.Stat(src); os.IsNotExist(err) { + continue + } + dst := filepath.Join(containerHome, df) + parentDir := filepath.Dir(dst) + // Use tar to copy (handles both files and directories like .ssh/) + cpCmd := fmt.Sprintf( + "tar -cf - -C %s %s | incus exec %s -- tar -xf - -C %s && incus exec %s -- chown -R %s:%s %s", + homeDir, df, name, parentDir, name, username, username, dst, + ) + if err := execShell(cpCmd); err != nil { + fmt.Println(styles.Warning(fmt.Sprintf("Could not copy %s: %v", df, err))) + } + } + + // Display passthrough + if !noGUI { + fmt.Println(styles.Info("Configuring display passthrough...")) + displayType := display.Detect() + if err := display.ConfigurePassthrough(client, name, displayType, true); err != nil { + fmt.Println(styles.Warning(fmt.Sprintf("Display passthrough failed: %v", err))) + fmt.Println(styles.Warning("GUI applications may not work correctly")) + } + } + + // Run .igloo.sh if it exists + scriptPath := filepath.Join(cwd, ".igloo.sh") + if _, err := os.Stat(scriptPath); err == nil { + fmt.Println(styles.Info("Running .igloo.sh...")) + // The project dir is mounted at cwd inside the container + containerScript := filepath.Join(cwd, ".igloo.sh") + if err := client.ExecAsRoot(name, "chmod", "+x", containerScript); err != nil { + return fmt.Errorf("failed to make .igloo.sh executable: %w", err) + } + if err := client.ExecAsRoot(name, "/bin/bash", containerScript); err != nil { + return fmt.Errorf(".igloo.sh failed: %w", err) + } + } + + fmt.Println(styles.Success(fmt.Sprintf("Container %s ready!", name))) + return nil +} + +func execShell(command string) error { + cmd := execCommand("sh", "-c", command) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} +``` + +Note: `execCommand` is `exec.Command` — we need to add the import. The actual import will be `os/exec` and the call will be `exec.Command`. Let me fix that in the actual code — use `exec.Command` directly. + +**Step 2: Verify it compiles** + +Run: `cd /home/bjk/projects/frostyard/igloo/.worktrees/simplify && go build ./cmd/...` +Expected: May have compile errors from other files still importing config — that's OK, we fix in later tasks. + +**Step 3: Commit** + +```bash +git add cmd/enter.go +git commit -m "feat: rewrite enter command with unified create/enter flow" +``` + +--- + +### Task 4: Simplify cmd/destroy.go + +Destroy no longer depends on config. It derives the container name from the current directory, same as enter. + +**Files:** +- Rewrite: `cmd/destroy.go` + +**Step 1: Write the implementation** + +Replace `cmd/destroy.go`: + +```go +package cmd + +import ( + "fmt" + "os" + + "github.com/frostyard/igloo/internal/host" + "github.com/frostyard/igloo/internal/incus" + "github.com/frostyard/igloo/internal/ui" + "github.com/spf13/cobra" +) + +func destroyCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "destroy", + Short: "Destroy the igloo container for this project", + Long: `Removes the igloo container completely. The project files are untouched.`, + Example: ` # Destroy the container + igloo destroy`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDestroy() + }, + } + + return cmd +} + +func runDestroy() error { + styles := ui.NewStyles() + client := incus.NewClient() + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + name := host.ContainerName(cwd) + + exists, err := client.InstanceExists(name) + if err != nil { + return fmt.Errorf("failed to check instance: %w", err) + } + + if !exists { + fmt.Println(styles.Warning(fmt.Sprintf("Container %s does not exist", name))) + return nil + } + + fmt.Println(styles.Info(fmt.Sprintf("Destroying container %s...", name))) + if err := client.Delete(name, true); err != nil { + return fmt.Errorf("failed to destroy instance: %w", err) + } + + fmt.Println(styles.Success(fmt.Sprintf("Container %s destroyed", name))) + return nil +} +``` + +**Step 2: Commit** + +```bash +git add cmd/destroy.go +git commit -m "refactor: simplify destroy command, remove config dependency" +``` + +--- + +### Task 5: Rewrite cmd/root.go and clean up incus/client.go + +Root command registers only enter (as default) and destroy. Also clean client.go to remove the unused `Stop` method (containers are only destroyed, never just stopped). + +**Files:** +- Rewrite: `cmd/root.go` +- Modify: `internal/incus/client.go` — remove `Stop` method + +**Step 1: Write the implementation** + +Replace `cmd/root.go`: + +```go +package cmd + +import ( + "github.com/spf13/cobra" +) + +// RootCmd returns the root command for igloo. +// Running `igloo` with no subcommand enters the container. +func RootCmd() *cobra.Command { + enter := enterCmd() + + enter.AddCommand(destroyCmd()) + + return enter +} +``` + +The trick: make the enter command the root itself. `igloo` runs enter. `igloo destroy` runs destroy as a subcommand. + +**Step 2: Remove `Stop` from client.go** + +In `internal/incus/client.go`, delete the `Stop` method (lines 85-91). It is no longer called by any command. + +**Step 3: Remove `incus/client_test.go` if it only tests removed functionality** + +Check `internal/incus/client_test.go` — if tests reference config package or removed methods, update them. + +**Step 4: Verify it compiles (ignoring deleted command files for now)** + +Run: `cd /home/bjk/projects/frostyard/igloo/.worktrees/simplify && go vet ./cmd/... ./internal/...` + +**Step 5: Commit** + +```bash +git add cmd/root.go internal/incus/client.go internal/incus/client_test.go +git commit -m "refactor: make enter the root command, add destroy as subcommand" +``` + +--- + +### Task 6: Delete old files + +Remove all files that are no longer needed. + +**Files to delete:** +- `cmd/init.go` +- `cmd/provision.go` +- `cmd/status.go` +- `cmd/stop.go` +- `cmd/remove.go` +- `internal/config/config.go` +- `internal/config/config_test.go` +- `internal/config/distros.go` +- `internal/config/distros_test.go` +- `internal/config/hash.go` +- `internal/config/hash_test.go` +- `internal/config/hostdetect.go` +- `internal/config/hostdetect_test.go` +- `internal/script/runner.go` +- `internal/script/runner_test.go` + +**Step 1: Delete the files** + +```bash +cd /home/bjk/projects/frostyard/igloo/.worktrees/simplify +rm cmd/init.go cmd/provision.go cmd/status.go cmd/stop.go cmd/remove.go +rm -r internal/config +rm -r internal/script +``` + +**Step 2: Verify everything compiles** + +Run: `cd /home/bjk/projects/frostyard/igloo/.worktrees/simplify && go build ./...` +Expected: PASS — no remaining references to deleted packages + +**Step 3: Run all tests** + +Run: `cd /home/bjk/projects/frostyard/igloo/.worktrees/simplify && go test ./...` +Expected: PASS + +**Step 4: Commit** + +```bash +git add -A +git commit -m "refactor: remove old commands, config package, and script package" +``` + +--- + +### Task 7: Remove ini dependency and tidy modules + +**Files:** +- Modify: `go.mod` +- Modify: `go.sum` + +**Step 1: Remove the dependency and tidy** + +```bash +cd /home/bjk/projects/frostyard/igloo/.worktrees/simplify +go mod tidy +``` + +**Step 2: Verify gopkg.in/ini.v1 is gone from go.mod** + +```bash +grep ini go.mod +``` + +Expected: No output + +**Step 3: Run all tests one final time** + +```bash +cd /home/bjk/projects/frostyard/igloo/.worktrees/simplify && go test ./... +``` + +Expected: PASS + +**Step 4: Verify binary builds and runs** + +```bash +cd /home/bjk/projects/frostyard/igloo/.worktrees/simplify && go build -o igloo . && ./igloo --help +``` + +Expected: Shows help with just the root command (enter behavior) and `destroy` subcommand. + +**Step 5: Commit** + +```bash +git add go.mod go.sum +git commit -m "chore: remove gopkg.in/ini.v1 dependency, go mod tidy" +``` + +--- + +### Task 8: Update README.md + +Update the README to reflect the simplified two-command interface. + +**Files:** +- Modify: `README.md` + +**Step 1: Read current README** + +Read `README.md` to understand current structure. + +**Step 2: Rewrite to reflect new design** + +Key changes: +- Update description to focus on GTK dev containers +- Replace command reference with just `igloo` and `igloo destroy` +- Replace config section with `.igloo.sh` explanation +- Remove references to `igloo init`, INI config, multi-distro flags +- Keep installation and build instructions + +**Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: update README for simplified two-command interface" +``` From aa8f7d0c44705f739482c2853197c8c500bdb8ad Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Tue, 10 Feb 2026 14:53:08 -0500 Subject: [PATCH 04/11] feat: add internal/host package with ID_LIKE distro detection Co-Authored-By: Claude Opus 4.6 --- internal/host/detect.go | 118 +++++++++++++++++++++++++++++++ internal/host/detect_test.go | 133 +++++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 internal/host/detect.go create mode 100644 internal/host/detect_test.go diff --git a/internal/host/detect.go b/internal/host/detect.go new file mode 100644 index 0000000..0ee1e4f --- /dev/null +++ b/internal/host/detect.go @@ -0,0 +1,118 @@ +package host + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Known distro families that have Incus cloud images. +var knownDistros = map[string]bool{ + "debian": true, + "ubuntu": true, + "fedora": true, + "archlinux": true, +} + +// Distros that use VERSION_ID instead of VERSION_CODENAME. +var usesVersionID = map[string]bool{ + "fedora": true, +} + +// Distros that have a fixed version string. +var fixedVersion = map[string]string{ + "archlinux": "current", +} + +// OSInfo holds the detected host OS information. +type OSInfo struct { + ID string // distro family (debian, ubuntu, etc.) + Version string // release codename or version number +} + +// Image returns the Incus image string for this OS. +func (o OSInfo) Image() string { + return fmt.Sprintf("images:%s/%s/cloud", o.ID, o.Version) +} + +// DetectOS reads /etc/os-release and returns OS info with ID_LIKE fallback. +func DetectOS() OSInfo { + return detectFromFile("/etc/os-release") +} + +// ContainerName derives the igloo container name from a project directory path. +func ContainerName(projectDir string) string { + return "igloo-" + filepath.Base(projectDir) +} + +func detectFromFile(path string) OSInfo { + fallback := OSInfo{ID: "debian", Version: "trixie"} + + fields := parseOSRelease(path) + if len(fields) == 0 { + return fallback + } + + id := strings.ToLower(fields["ID"]) + if id == "" { + return fallback + } + + // Try the primary ID first, then walk ID_LIKE. + candidates := []string{id} + if idLike := fields["ID_LIKE"]; idLike != "" { + candidates = append(candidates, strings.Fields(idLike)...) + } + + for _, candidate := range candidates { + if !knownDistros[candidate] { + continue + } + ver := versionFor(candidate, fields) + if ver == "" { + continue + } + return OSInfo{ID: candidate, Version: ver} + } + + return fallback +} + +// versionFor returns the version string for a known distro, given os-release fields. +func versionFor(distro string, fields map[string]string) string { + if v, ok := fixedVersion[distro]; ok { + return v + } + if usesVersionID[distro] { + return fields["VERSION_ID"] + } + // For Ubuntu derivatives: prefer UBUNTU_CODENAME (set by Ubuntu derivatives). + if distro == "ubuntu" { + if uc := fields["UBUNTU_CODENAME"]; uc != "" { + return strings.ToLower(uc) + } + } + return strings.ToLower(fields["VERSION_CODENAME"]) +} + +func parseOSRelease(path string) map[string]string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + fields := make(map[string]string) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + fields[k] = strings.Trim(v, "\"") + } + return fields +} diff --git a/internal/host/detect_test.go b/internal/host/detect_test.go new file mode 100644 index 0000000..9c14ad1 --- /dev/null +++ b/internal/host/detect_test.go @@ -0,0 +1,133 @@ +package host + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectOS(t *testing.T) { + tests := []struct { + name string + content string + wantID string + wantVer string + wantImage string + }{ + { + name: "debian trixie", + content: `ID=debian +VERSION_CODENAME=trixie +`, + wantID: "debian", + wantVer: "trixie", + wantImage: "images:debian/trixie/cloud", + }, + { + name: "ubuntu noble", + content: `ID=ubuntu +VERSION_CODENAME=noble +`, + wantID: "ubuntu", + wantVer: "noble", + wantImage: "images:ubuntu/noble/cloud", + }, + { + name: "pop os falls back via ID_LIKE to ubuntu", + content: `ID=pop +VERSION_CODENAME=noble +ID_LIKE="ubuntu debian" +`, + wantID: "ubuntu", + wantVer: "noble", + wantImage: "images:ubuntu/noble/cloud", + }, + { + name: "linuxmint falls back via ID_LIKE to ubuntu", + content: `ID=linuxmint +VERSION_CODENAME=wilma +ID_LIKE="ubuntu debian" +UBUNTU_CODENAME=noble +`, + wantID: "ubuntu", + wantVer: "noble", + wantImage: "images:ubuntu/noble/cloud", + }, + { + name: "unknown distro with debian ID_LIKE", + content: `ID=mxlinux +VERSION_CODENAME=libretto +ID_LIKE="debian" +`, + wantID: "debian", + wantVer: "libretto", + wantImage: "images:debian/libretto/cloud", + }, + { + name: "completely unknown falls back to debian trixie", + content: `ID=gentoo +`, + wantID: "debian", + wantVer: "trixie", + wantImage: "images:debian/trixie/cloud", + }, + { + name: "missing file falls back to debian trixie", + content: "", + wantID: "debian", + wantVer: "trixie", + wantImage: "images:debian/trixie/cloud", + }, + { + name: "fedora uses VERSION_ID", + content: `ID=fedora +VERSION_ID=43 +`, + wantID: "fedora", + wantVer: "43", + wantImage: "images:fedora/43/cloud", + }, + { + name: "arch uses current", + content: `ID=archlinux +`, + wantID: "archlinux", + wantVer: "current", + wantImage: "images:archlinux/current/cloud", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var path string + if tt.content == "" { + path = "/nonexistent/os-release" + } else { + tmpDir := t.TempDir() + path = filepath.Join(tmpDir, "os-release") + if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil { + t.Fatalf("failed to write os-release: %v", err) + } + } + + info := detectFromFile(path) + + if info.ID != tt.wantID { + t.Errorf("ID = %q, want %q", info.ID, tt.wantID) + } + if info.Version != tt.wantVer { + t.Errorf("Version = %q, want %q", info.Version, tt.wantVer) + } + if info.Image() != tt.wantImage { + t.Errorf("Image() = %q, want %q", info.Image(), tt.wantImage) + } + }) + } +} + +func TestContainerName(t *testing.T) { + name := ContainerName("/home/bjk/projects/my-gtk-app") + if name != "igloo-my-gtk-app" { + t.Errorf("ContainerName() = %q, want %q", name, "igloo-my-gtk-app") + } +} From 0a778d20efc88ca5c660f3120ec1ea69c16e42a7 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Tue, 10 Feb 2026 15:00:48 -0500 Subject: [PATCH 05/11] refactor: decouple cloud-init from config package GenerateCloudInit now takes no arguments and reads user info and timezone directly from the environment. Package-handling logic is removed since packages are now managed by .igloo.sh scripts. The CloudInitData struct is unexported as it is only used internally. Co-Authored-By: Claude Opus 4.6 --- cmd/provision.go | 2 +- internal/incus/cloudinit.go | 74 ++++------------- internal/incus/cloudinit_test.go | 131 ++----------------------------- 3 files changed, 22 insertions(+), 185 deletions(-) diff --git a/cmd/provision.go b/cmd/provision.go index 0b2f5dc..069d51d 100644 --- a/cmd/provision.go +++ b/cmd/provision.go @@ -39,7 +39,7 @@ func provisionContainer(cfg *config.IglooConfig) error { fmt.Println(styles.Info(fmt.Sprintf("Creating container %s from %s...", name, image))) // Generate cloud-init config - cloudInit, err := incus.GenerateCloudInit(cfg) + cloudInit, err := incus.GenerateCloudInit() if err != nil { return fmt.Errorf("failed to generate cloud-init: %w", err) } diff --git a/internal/incus/cloudinit.go b/internal/incus/cloudinit.go index f69a57a..3518680 100644 --- a/internal/incus/cloudinit.go +++ b/internal/incus/cloudinit.go @@ -8,8 +8,6 @@ import ( "strings" "text/template" "time" - - "github.com/frostyard/igloo/internal/config" ) const cloudInitTemplate = `#cloud-config @@ -23,72 +21,37 @@ users: sudo: ALL=(ALL) NOPASSWD:ALL lock_passwd: true -# Set timezone to match host timezone: {{.Timezone}} -{{if .Packages}} -# Install packages -packages: -{{range .PackageList}} - {{.}} -{{end}} -{{end}} - -# Ensure user home directory exists with correct permissions runcmd: - mkdir -p /home/{{.Username}} - chown {{.UID}}:{{.GID}} /home/{{.Username}} - - mkdir -p /home/{{.Username}}/workspace - - chown {{.UID}}:{{.GID}} /home/{{.Username}}/workspace - mkdir -p /run/user/{{.UID}} - chown {{.UID}}:{{.GID}} /run/user/{{.UID}} - chmod 700 /run/user/{{.UID}} ` -// CloudInitData holds the data for cloud-init template -type CloudInitData struct { - Username string - UID int - GID int - Timezone string - Packages bool - PackageList []string - Timestamp string +type cloudInitData struct { + Username string + UID int + GID int + Timezone string + Timestamp string } -// GenerateCloudInit creates a cloud-init configuration for the igloo instance -func GenerateCloudInit(cfg *config.IglooConfig) (string, error) { - // Get current user info +// GenerateCloudInit creates a cloud-init config for user mapping and basic setup. +func GenerateCloudInit() (string, error) { currentUser, err := user.Current() if err != nil { return "", fmt.Errorf("failed to get current user: %w", err) } - uid := os.Getuid() - gid := os.Getgid() - - // Get timezone - timezone := getTimezone() - - // Parse package list - var packageList []string - if cfg.Packages.Install != "" { - packages := strings.Split(cfg.Packages.Install, ",") - for _, pkg := range packages { - pkg = strings.TrimSpace(pkg) - if pkg != "" { - packageList = append(packageList, pkg) - } - } - } - - data := CloudInitData{ - Username: currentUser.Username, - UID: uid, - GID: gid, - Timezone: timezone, - Packages: len(packageList) > 0, - PackageList: packageList, - Timestamp: time.Now().Format(time.RFC3339), + data := cloudInitData{ + Username: currentUser.Username, + UID: os.Getuid(), + GID: os.Getgid(), + Timezone: getTimezone(), + Timestamp: time.Now().Format(time.RFC3339), } tmpl, err := template.New("cloud-init").Parse(cloudInitTemplate) @@ -104,27 +67,18 @@ func GenerateCloudInit(cfg *config.IglooConfig) (string, error) { return buf.String(), nil } -// getTimezone attempts to detect the host timezone func getTimezone() string { - // Try to read from /etc/timezone if data, err := os.ReadFile("/etc/timezone"); err == nil { return strings.TrimSpace(string(data)) } - - // Try to read symlink from /etc/localtime if target, err := os.Readlink("/etc/localtime"); err == nil { - // /etc/localtime -> /usr/share/zoneinfo/America/New_York parts := strings.Split(target, "/zoneinfo/") if len(parts) == 2 { return parts[1] } } - - // Try TZ environment variable if tz := os.Getenv("TZ"); tz != "" { return tz } - - // Default to UTC return "UTC" } diff --git a/internal/incus/cloudinit_test.go b/internal/incus/cloudinit_test.go index 4b41c18..a3af1eb 100644 --- a/internal/incus/cloudinit_test.go +++ b/internal/incus/cloudinit_test.go @@ -3,145 +3,28 @@ package incus import ( "strings" "testing" - - "github.com/frostyard/igloo/internal/config" ) func TestGenerateCloudInit(t *testing.T) { - cfg := &config.IglooConfig{ - Container: config.ContainerConfig{ - Image: "images:ubuntu/questing", - Name: "test-igloo", - }, - Packages: config.PackagesConfig{ - Install: "vim, git, curl", - }, - } - - result, err := GenerateCloudInit(cfg) + result, err := GenerateCloudInit() if err != nil { t.Fatalf("GenerateCloudInit() failed: %v", err) } - // Verify it's valid cloud-init if !strings.HasPrefix(result, "#cloud-config") { - t.Error("cloud-init should start with #cloud-config") - } - - // Verify packages are included - if !strings.Contains(result, "packages:") { - t.Error("cloud-init should contain packages section") - } - if !strings.Contains(result, "- vim") { - t.Error("cloud-init should contain vim package") + t.Error("should start with #cloud-config") } - if !strings.Contains(result, "- git") { - t.Error("cloud-init should contain git package") - } - if !strings.Contains(result, "- curl") { - t.Error("cloud-init should contain curl package") - } - - // Verify user section exists if !strings.Contains(result, "users:") { - t.Error("cloud-init should contain users section") + t.Error("should contain users section") } - - // Verify runcmd section exists if !strings.Contains(result, "runcmd:") { - t.Error("cloud-init should contain runcmd section") + t.Error("should contain runcmd section") } - - // Verify timezone is set if !strings.Contains(result, "timezone:") { - t.Error("cloud-init should contain timezone") + t.Error("should contain timezone") } -} - -func TestGenerateCloudInit_NoPackages(t *testing.T) { - cfg := &config.IglooConfig{ - Container: config.ContainerConfig{ - Image: "images:ubuntu/questing", - Name: "test-igloo", - }, - Packages: config.PackagesConfig{ - Install: "", - }, - } - - result, err := GenerateCloudInit(cfg) - if err != nil { - t.Fatalf("GenerateCloudInit() failed: %v", err) - } - - // Packages section should not be present when empty + // Should NOT contain packages section (packages come from .igloo.sh now) if strings.Contains(result, "packages:") { - t.Error("cloud-init should not contain packages section when no packages specified") - } -} - -func TestGenerateCloudInit_WhitespaceOnlyPackages(t *testing.T) { - cfg := &config.IglooConfig{ - Packages: config.PackagesConfig{ - Install: " , , ", - }, - } - - result, err := GenerateCloudInit(cfg) - if err != nil { - t.Fatalf("GenerateCloudInit() failed: %v", err) - } - - // Packages section should not be present when only whitespace - if strings.Contains(result, "packages:") { - t.Error("cloud-init should not contain packages section when only whitespace") - } -} - -func TestCloudInitData_Render(t *testing.T) { - // Test that the data structure holds values correctly - data := CloudInitData{ - Username: "testuser", - UID: 1000, - GID: 1000, - Timezone: "America/New_York", - Packages: true, - PackageList: []string{"vim", "git"}, - Timestamp: "2024-01-01T00:00:00Z", - } - - // Verify the data structure is populated correctly - if data.Username != "testuser" { - t.Errorf("Username = %q, want %q", data.Username, "testuser") - } - if data.UID != 1000 { - t.Errorf("UID = %d, want %d", data.UID, 1000) - } - if data.GID != 1000 { - t.Errorf("GID = %d, want %d", data.GID, 1000) - } - if data.Timezone != "America/New_York" { - t.Errorf("Timezone = %q, want %q", data.Timezone, "America/New_York") - } - if !data.Packages { - t.Error("Packages should be true") - } - if len(data.PackageList) != 2 { - t.Errorf("PackageList length = %d, want %d", len(data.PackageList), 2) - } -} - -func TestGetTimezone(t *testing.T) { - tz := getTimezone() - - // Should return something (not empty) - if tz == "" { - t.Error("getTimezone() returned empty string") - } - - // Should be a valid-looking timezone (contains / or is UTC) - if tz != "UTC" && !strings.Contains(tz, "/") { - // Could still be valid like "EST" but less common - t.Logf("Unusual timezone format: %q", tz) + t.Error("should not contain packages section") } } From 08043556daa0ba6e87c66eb9868264014645e334 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Tue, 10 Feb 2026 15:05:29 -0500 Subject: [PATCH 06/11] feat: rewrite enter command with unified create/enter flow Co-Authored-By: Claude Opus 4.6 --- cmd/enter.go | 214 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 130 insertions(+), 84 deletions(-) diff --git a/cmd/enter.go b/cmd/enter.go index db410ab..bd614d0 100644 --- a/cmd/enter.go +++ b/cmd/enter.go @@ -1,142 +1,188 @@ package cmd import ( - "bufio" "fmt" "os" + "os/exec" "path/filepath" - "strings" - "github.com/frostyard/igloo/internal/config" + "github.com/frostyard/igloo/internal/display" + "github.com/frostyard/igloo/internal/host" "github.com/frostyard/igloo/internal/incus" "github.com/frostyard/igloo/internal/ui" "github.com/spf13/cobra" ) +var noGUI bool + func enterCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "enter", - Short: "Enter the igloo development environment", - Long: `Enter opens an interactive shell in the igloo container. -If the container is not running, it will be started first. -If the .igloo configuration has changed, you will be prompted to rebuild.`, - Example: ` # Enter the igloo environment - igloo enter`, + Use: "igloo", + Short: "Enter the igloo development container", + Long: `Creates and enters an isolated development container. + +If no container exists for the current directory, one is created automatically +using the host OS, with display passthrough and GPU support enabled. + +Place a .igloo.sh script in your project root to run custom setup on first creation.`, + Example: ` # Enter (or create) the container + igloo + + # Enter without GUI support + igloo --no-gui`, RunE: func(cmd *cobra.Command, args []string) error { return runEnter() }, } + cmd.Flags().BoolVar(&noGUI, "no-gui", false, "Skip display and GPU passthrough") + return cmd } +// dotfiles to copy from host home to container home on creation. +var dotfiles = []string{ + ".gitconfig", + ".ssh", + ".bashrc", + ".profile", + ".bash_profile", +} + func runEnter() error { styles := ui.NewStyles() + client := incus.NewClient() - // Load config - cfg, err := config.Load(config.ConfigPath()) + cwd, err := os.Getwd() if err != nil { - return fmt.Errorf("failed to load config: %w\nRun 'igloo init' to create a new environment", err) + return fmt.Errorf("failed to get current directory: %w", err) } + name := host.ContainerName(cwd) + username := os.Getenv("USER") + homeDir := os.Getenv("HOME") - client := incus.NewClient() - - // Check if instance exists, provision if not - exists, err := client.InstanceExists(cfg.Container.Name) + exists, err := client.InstanceExists(name) if err != nil { return fmt.Errorf("failed to check instance: %w", err) } - if exists { - // Check if config has changed since last provision - changed, currentHash, err := config.ConfigChanged(cfg.Container.Name) - if err != nil { - fmt.Println(styles.Warning(fmt.Sprintf("Could not check for config changes: %v", err))) - } else if changed { - fmt.Println(styles.Warning("Configuration in .igloo/ has changed since last provision.")) - fmt.Print(styles.Info("Rebuild container to apply changes? [y/N]: ")) - - reader := bufio.NewReader(os.Stdin) - response, _ := reader.ReadString('\n') - response = strings.TrimSpace(strings.ToLower(response)) - - if response == "y" || response == "yes" { - fmt.Println(styles.Info("Removing old container...")) - if err := client.Delete(cfg.Container.Name, true); err != nil { - return fmt.Errorf("failed to remove container: %w", err) - } - exists = false - } else { - // Update stored hash to current so we don't keep asking - if err := config.StoreHash(cfg.Container.Name, currentHash); err != nil { - fmt.Println(styles.Warning(fmt.Sprintf("Could not update config hash: %v", err))) - } - } - } else if currentHash != "" { - // No stored hash yet (first run with existing container) - store it now - storedHash, _ := config.GetStoredHash(cfg.Container.Name) - if storedHash == "" { - if err := config.StoreHash(cfg.Container.Name, currentHash); err != nil { - fmt.Println(styles.Warning(fmt.Sprintf("Could not store config hash: %v", err))) - } - } - } - } - if !exists { - fmt.Println(styles.Info("Container does not exist, provisioning...")) - if err := provisionContainer(cfg); err != nil { - return fmt.Errorf("failed to provision container: %w", err) - } - - // Store the config hash after successful provision - currentHash, err := config.HashConfigDir() - if err == nil { - if err := config.StoreHash(cfg.Container.Name, currentHash); err != nil { - fmt.Println(styles.Warning(fmt.Sprintf("Could not store config hash: %v", err))) - } + if err := provision(client, styles, name, username, homeDir, cwd); err != nil { + return err } } - // Check if instance is running - running, err := client.IsRunning(cfg.Container.Name) + // Start if stopped + running, err := client.IsRunning(name) if err != nil { return fmt.Errorf("failed to check instance status: %w", err) } - if !running { fmt.Println(styles.Info("Starting container...")) - if err := client.Start(cfg.Container.Name); err != nil { + if err := client.Start(name); err != nil { return fmt.Errorf("failed to start instance: %w", err) } - - // Wait for cloud-init if container was stopped fmt.Println(styles.Info("Waiting for container to be ready...")) - if err := client.WaitForCloudInit(cfg.Container.Name); err != nil { + if err := client.WaitForCloudInit(name); err != nil { fmt.Println(styles.Warning("Cloud-init wait timed out, continuing anyway...")) } } - // Update Xauthority mount if necessary (file path can change on Wayland) - if err := client.UpdateXauthority(cfg.Container.Name); err != nil { - fmt.Println(styles.Warning(fmt.Sprintf("Could not update Xauthority: %v", err))) + // Refresh display passthrough on every entry + if !noGUI { + if err := client.UpdateXauthority(name); err != nil { + fmt.Println(styles.Warning(fmt.Sprintf("Could not update Xauthority: %v", err))) + } } - // Get user info - username := os.Getenv("USER") - cwd, err := os.Getwd() + fmt.Println(styles.Info(fmt.Sprintf("Entering %s...", name))) + return client.ExecInteractive(name, username, cwd) +} + +func provision(client *incus.Client, styles *ui.Styles, name, username, homeDir, cwd string) error { + // Detect host OS + osInfo := host.DetectOS() + image := osInfo.Image() + fmt.Println(styles.Info(fmt.Sprintf("Detected host: %s/%s", osInfo.ID, osInfo.Version))) + fmt.Println(styles.Info(fmt.Sprintf("Creating container %s from %s...", name, image))) + + // Generate cloud-init + cloudInit, err := incus.GenerateCloudInit() if err != nil { - return fmt.Errorf("failed to get current directory: %w", err) + return fmt.Errorf("failed to generate cloud-init: %w", err) + } + + // Create instance + if err := client.Create(name, image, cloudInit); err != nil { + return fmt.Errorf("failed to create instance: %w", err) + } + + // Mount project directory at the same absolute path + fmt.Println(styles.Info(fmt.Sprintf("Mounting project at %s...", cwd))) + if err := client.AddDiskDevice(name, "project", cwd, cwd); err != nil { + return fmt.Errorf("failed to mount project directory: %w", err) + } + + // Start container (before display passthrough, so /run/user exists) + fmt.Println(styles.Info("Starting container...")) + if err := client.Start(name); err != nil { + return fmt.Errorf("failed to start instance: %w", err) } - projectName := filepath.Base(cwd) - workDir := fmt.Sprintf("/home/%s/workspace/%s", username, projectName) - fmt.Println(styles.Info(fmt.Sprintf("Entering %s...", cfg.Container.Name))) + fmt.Println(styles.Info("Waiting for cloud-init to complete...")) + if err := client.WaitForCloudInit(name); err != nil { + return fmt.Errorf("cloud-init failed: %w", err) + } + + // Copy dotfiles from host home into container home + fmt.Println(styles.Info("Copying dotfiles...")) + containerHome := fmt.Sprintf("/home/%s", username) + for _, df := range dotfiles { + src := filepath.Join(homeDir, df) + if _, err := os.Stat(src); os.IsNotExist(err) { + continue + } + // Use tar piped into incus exec to copy files/dirs into the container + cpCmd := exec.Command("sh", "-c", + fmt.Sprintf("tar -cf - -C %s %s | incus exec %s -- tar -xf - -C %s", + homeDir, df, name, containerHome)) + cpCmd.Stdout = os.Stdout + cpCmd.Stderr = os.Stderr + if err := cpCmd.Run(); err != nil { + fmt.Println(styles.Warning(fmt.Sprintf("Could not copy %s: %v", df, err))) + continue + } + // Fix ownership + dst := filepath.Join(containerHome, df) + if err := client.ExecAsRoot(name, "chown", "-R", + fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), dst); err != nil { + fmt.Println(styles.Warning(fmt.Sprintf("Could not fix ownership for %s: %v", df, err))) + } + } - // Execute interactive shell - if err := client.ExecInteractive(cfg.Container.Name, username, workDir); err != nil { - return fmt.Errorf("failed to enter container: %w", err) + // Display passthrough + if !noGUI { + fmt.Println(styles.Info("Configuring display passthrough...")) + displayType := display.Detect() + if err := display.ConfigurePassthrough(client, name, displayType, true); err != nil { + fmt.Println(styles.Warning(fmt.Sprintf("Display passthrough failed: %v", err))) + fmt.Println(styles.Warning("GUI applications may not work correctly")) + } + } + + // Run .igloo.sh if it exists + scriptPath := filepath.Join(cwd, ".igloo.sh") + if _, err := os.Stat(scriptPath); err == nil { + fmt.Println(styles.Info("Running .igloo.sh...")) + containerScript := filepath.Join(cwd, ".igloo.sh") + if err := client.ExecAsRoot(name, "chmod", "+x", containerScript); err != nil { + return fmt.Errorf("failed to make .igloo.sh executable: %w", err) + } + if err := client.ExecAsRoot(name, "/bin/bash", containerScript); err != nil { + return fmt.Errorf(".igloo.sh failed: %w", err) + } } + fmt.Println(styles.Success(fmt.Sprintf("Container %s ready!", name))) return nil } From a583662f6fcfd36d5b6b0d459564f1a8daf50638 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Tue, 10 Feb 2026 15:07:52 -0500 Subject: [PATCH 07/11] refactor: simplify destroy, make enter the root command, remove Stop Co-Authored-By: Claude Opus 4.6 --- cmd/destroy.go | 66 ++++++++++++---------------------------- cmd/root.go | 39 +++--------------------- internal/incus/client.go | 8 ----- 3 files changed, 24 insertions(+), 89 deletions(-) diff --git a/cmd/destroy.go b/cmd/destroy.go index 412f249..e3ca0b2 100644 --- a/cmd/destroy.go +++ b/cmd/destroy.go @@ -4,80 +4,52 @@ import ( "fmt" "os" - "github.com/frostyard/igloo/internal/config" + "github.com/frostyard/igloo/internal/host" "github.com/frostyard/igloo/internal/incus" "github.com/frostyard/igloo/internal/ui" "github.com/spf13/cobra" ) func destroyCmd() *cobra.Command { - var force bool - var keepConfig bool - cmd := &cobra.Command{ Use: "destroy", - Short: "Destroy the igloo development environment", - Long: `Destroy removes the igloo container completely. -By default, it also removes the .igloo configuration directory.`, - Example: ` # Destroy the igloo environment - igloo destroy - - # Force destroy without confirmation - igloo destroy --force - - # Keep the .igloo directory - igloo destroy --keep-config`, + Short: "Destroy the igloo container for this project", + Long: `Removes the igloo container completely. The project files are untouched.`, + Example: ` # Destroy the container + igloo destroy`, RunE: func(cmd *cobra.Command, args []string) error { - return runDestroy(force, keepConfig) + return runDestroy() }, } - cmd.Flags().BoolVarP(&force, "force", "f", false, "Force destroy without stopping first") - cmd.Flags().BoolVar(&keepConfig, "keep-config", false, "Keep the .igloo configuration directory") - return cmd } -func runDestroy(force, keepConfig bool) error { +func runDestroy() error { styles := ui.NewStyles() + client := incus.NewClient() - // Load config - cfg, err := config.Load(config.ConfigPath()) + cwd, err := os.Getwd() if err != nil { - return fmt.Errorf("failed to load config: %w", err) + return fmt.Errorf("failed to get current directory: %w", err) } + name := host.ContainerName(cwd) - client := incus.NewClient() - - // Check if instance exists - exists, err := client.InstanceExists(cfg.Container.Name) + exists, err := client.InstanceExists(name) if err != nil { return fmt.Errorf("failed to check instance: %w", err) } - if exists { - fmt.Println(styles.Info(fmt.Sprintf("Destroying container %s...", cfg.Container.Name))) - if err := client.Delete(cfg.Container.Name, force); err != nil { - return fmt.Errorf("failed to destroy instance: %w", err) - } - fmt.Println(styles.Success(fmt.Sprintf("Container %s destroyed", cfg.Container.Name))) - } else { - fmt.Println(styles.Warning(fmt.Sprintf("Container %s does not exist", cfg.Container.Name))) - } - - // Remove stored config hash - if err := config.RemoveStoredHash(cfg.Container.Name); err != nil { - fmt.Println(styles.Warning(fmt.Sprintf("Could not remove stored hash: %v", err))) + if !exists { + fmt.Println(styles.Warning(fmt.Sprintf("Container %s does not exist", name))) + return nil } - // Remove .igloo directory unless --keep-config - if !keepConfig { - fmt.Println(styles.Info("Removing .igloo directory...")) - if err := os.RemoveAll(config.ConfigDir); err != nil { - return fmt.Errorf("failed to remove .igloo directory: %w", err) - } + fmt.Println(styles.Info(fmt.Sprintf("Destroying container %s...", name))) + if err := client.Delete(name, true); err != nil { + return fmt.Errorf("failed to destroy instance: %w", err) } - fmt.Println(styles.Success("Igloo environment destroyed")) + fmt.Println(styles.Success(fmt.Sprintf("Container %s destroyed", name))) return nil } diff --git a/cmd/root.go b/cmd/root.go index a18bbc5..1376726 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,41 +4,12 @@ import ( "github.com/spf13/cobra" ) -// RootCmd returns the root command for igloo +// RootCmd returns the root command for igloo. +// Running `igloo` with no subcommand enters the container. func RootCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "igloo", - Short: "Manage incus-based development environments", - Long: `Igloo creates and manages incus containers for local development. + enter := enterCmd() -It allows developers to use tools that aren't available on the host system -by running them in a container while sharing the project directory and display.`, - Example: ` # Initialize a new igloo environment in the current directory - igloo init + enter.AddCommand(destroyCmd()) - # Initialize with a specific distro and release - igloo init --distro ubuntu --release questing - - # Enter the igloo environment - igloo enter - - # Check environment status - igloo status - - # Stop the environment - igloo stop - - # Destroy the environment - igloo destroy`, - SilenceUsage: true, - } - - cmd.AddCommand(initCmd()) - cmd.AddCommand(enterCmd()) - cmd.AddCommand(stopCmd()) - cmd.AddCommand(removeCmd()) - cmd.AddCommand(destroyCmd()) - cmd.AddCommand(statusCmd()) - - return cmd + return enter } diff --git a/internal/incus/client.go b/internal/incus/client.go index 7af866d..bbea716 100644 --- a/internal/incus/client.go +++ b/internal/incus/client.go @@ -82,14 +82,6 @@ func (c *Client) Start(name string) error { return cmd.Run() } -// Stop stops an instance -func (c *Client) Stop(name string) error { - cmd := exec.Command("incus", "stop", name) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - // Delete deletes an instance func (c *Client) Delete(name string, force bool) error { args := []string{"delete", name} From 1d29e85defcc20f0a33731f61df421dde5660a48 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Tue, 10 Feb 2026 15:08:36 -0500 Subject: [PATCH 08/11] refactor: remove old commands, config package, and script package Delete cmd/init.go, cmd/provision.go, cmd/status.go, cmd/stop.go, cmd/remove.go, internal/config/, and internal/script/. All functionality is now handled by the simplified enter and destroy commands. Co-Authored-By: Claude Opus 4.6 --- cmd/init.go | 178 ----------------------- cmd/provision.go | 138 ------------------ cmd/remove.go | 69 --------- cmd/status.go | 131 ----------------- cmd/stop.go | 64 --------- internal/config/config.go | 180 ----------------------- internal/config/config_test.go | 220 ----------------------------- internal/config/distros.go | 50 ------- internal/config/distros_test.go | 126 ----------------- internal/config/hash.go | 156 -------------------- internal/config/hash_test.go | 117 --------------- internal/config/hostdetect.go | 61 -------- internal/config/hostdetect_test.go | 214 ---------------------------- internal/script/runner.go | 114 --------------- internal/script/runner_test.go | 207 --------------------------- 15 files changed, 2025 deletions(-) delete mode 100644 cmd/init.go delete mode 100644 cmd/provision.go delete mode 100644 cmd/remove.go delete mode 100644 cmd/status.go delete mode 100644 cmd/stop.go delete mode 100644 internal/config/config.go delete mode 100644 internal/config/config_test.go delete mode 100644 internal/config/distros.go delete mode 100644 internal/config/distros_test.go delete mode 100644 internal/config/hash.go delete mode 100644 internal/config/hash_test.go delete mode 100644 internal/config/hostdetect.go delete mode 100644 internal/config/hostdetect_test.go delete mode 100644 internal/script/runner.go delete mode 100644 internal/script/runner_test.go diff --git a/cmd/init.go b/cmd/init.go deleted file mode 100644 index d4ffc4e..0000000 --- a/cmd/init.go +++ /dev/null @@ -1,178 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/frostyard/igloo/internal/config" - "github.com/frostyard/igloo/internal/ui" - "github.com/spf13/cobra" -) - -func initCmd() *cobra.Command { - var distro string - var release string - var name string - var packages string - - cmd := &cobra.Command{ - Use: "init", - Short: "Initialize a new igloo development environment", - Long: `Initialize creates an .igloo/igloo.ini configuration file and sets up -an incus container for development. The container will have: - -- A user matching your host UID/GID -- Your home directory mounted at ~/host -- The project directory mounted at ~/workspace/ -- Display passthrough for GUI applications`, - Example: ` # Initialize with host OS defaults - igloo init - - # Initialize with Ubuntu Questing - igloo init --distro ubuntu --release questing - - # Initialize with custom name and packages - igloo init --name myproject-dev --packages "git,curl,vim"`, - RunE: func(cmd *cobra.Command, args []string) error { - return runInit(distro, release, name, packages) - }, - } - - cmd.Flags().StringVarP(&distro, "distro", "d", "", "Linux distribution (ubuntu, debian, fedora, archlinux)") - cmd.Flags().StringVarP(&release, "release", "r", "", "Distribution release (e.g., questing, trixie, 43, current)") - cmd.Flags().StringVarP(&name, "name", "n", "", "Container name (default: igloo-)") - cmd.Flags().StringVarP(&packages, "packages", "p", "", "Comma-separated list of packages to install") - - return cmd -} - -func runInit(distro, release, name, packages string) error { - styles := ui.NewStyles() - - // Check if .igloo directory already exists - if _, err := os.Stat(config.ConfigDir); err == nil { - return fmt.Errorf(".igloo directory already exists in this project") - } - - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current directory: %w", err) - } - projectName := filepath.Base(cwd) - - // Detect distro/release from host if not specified - if distro == "" || release == "" { - hostDistro, hostRelease := config.DetectHostOS() - if distro == "" { - distro = hostDistro - } - if release == "" { - release = hostRelease - } - fmt.Println(styles.Info(fmt.Sprintf("Detected host OS: %s/%s", distro, release))) - } - - // Validate distro/release - if err := config.ValidateDistro(distro, release); err != nil { - return err - } - - // Set default container name - if name == "" { - name = "igloo-" + projectName - } - - // Build image name - image := fmt.Sprintf("images:%s/%s/cloud", distro, release) - - // Create config - cfg := &config.IglooConfig{ - Container: config.ContainerConfig{ - Image: image, - Name: name, - }, - Packages: config.PackagesConfig{ - Install: packages, - }, - Mounts: config.MountsConfig{ - Home: true, - Project: true, - }, - Display: config.DisplayConfig{ - Enabled: true, - GPU: true, - }, - Symlinks: []string{ - ".gitconfig", - ".ssh", - ".bashrc", - ".profile", - ".bash_profile", - }, - } - - // Create .igloo directory and write config file - fmt.Println(styles.Info("Creating .igloo directory...")) - if err := os.MkdirAll(config.ConfigDir, 0755); err != nil { - return fmt.Errorf("failed to create .igloo directory: %w", err) - } - - fmt.Println(styles.Info("Writing .igloo/igloo.ini...")) - if err := config.Write(config.ConfigPath(), cfg); err != nil { - return fmt.Errorf("failed to write config: %w", err) - } - - // Create scripts directory with example script - scriptsDir := config.ScriptsPath() - if err := os.MkdirAll(scriptsDir, 0755); err != nil { - return fmt.Errorf("failed to create scripts directory: %w", err) - } - - exampleScript := `#!/bin/bash -# Example igloo init script -# -# Scripts in .igloo/scripts/ run in lexicographical order during 'igloo init' -# and when re-provisioning with 'igloo enter' (if container doesn't exist). -# -# Scripts run as root inside the container. The project directory is mounted -# at ~/workspace// so you can access project files. -# -# Common uses: -# - Install additional packages: apt-get install -y nodejs npm -# - Configure development tools: git config --global user.name "Your Name" -# - Set up databases: systemctl enable postgresql -# - Install language-specific tools: pip install poetry -# -# Naming convention: Use numbered prefixes for ordering (e.g., 01-packages.sh, 02-config.sh) -# -# To enable this script, rename it to remove the .example suffix: -# mv 00-example.sh.example 00-example.sh - -echo "Hello from igloo init script!" -echo "Container user: $USER" -echo "Working directory: $(pwd)" -` - examplePath := filepath.Join(scriptsDir, "00-example.sh.example") - if err := os.WriteFile(examplePath, []byte(exampleScript), 0644); err != nil { - return fmt.Errorf("failed to write example script: %w", err) - } - - // Provision the container - if err := provisionContainer(cfg); err != nil { - return err - } - - // Store the config hash for change detection - currentHash, err := config.HashConfigDir() - if err == nil { - if err := config.StoreHash(cfg.Container.Name, currentHash); err != nil { - fmt.Println(styles.Warning(fmt.Sprintf("Could not store config hash: %v", err))) - } - } - - fmt.Println(styles.Info("Run 'igloo enter' to start working")) - - return nil -} diff --git a/cmd/provision.go b/cmd/provision.go deleted file mode 100644 index 069d51d..0000000 --- a/cmd/provision.go +++ /dev/null @@ -1,138 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/frostyard/igloo/internal/config" - "github.com/frostyard/igloo/internal/display" - "github.com/frostyard/igloo/internal/incus" - "github.com/frostyard/igloo/internal/script" - "github.com/frostyard/igloo/internal/ui" -) - -// provisionContainer creates and configures an incus container from an existing igloo.ini -func provisionContainer(cfg *config.IglooConfig) error { - styles := ui.NewStyles() - client := incus.NewClient() - - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current directory: %w", err) - } - projectName := filepath.Base(cwd) - username := os.Getenv("USER") - name := cfg.Container.Name - image := cfg.Container.Image - - // Check if instance already exists - exists, err := client.InstanceExists(name) - if err != nil { - return fmt.Errorf("failed to check instance: %w", err) - } - if exists { - return nil // Already exists, nothing to do - } - - fmt.Println(styles.Info(fmt.Sprintf("Creating container %s from %s...", name, image))) - - // Generate cloud-init config - cloudInit, err := incus.GenerateCloudInit() - if err != nil { - return fmt.Errorf("failed to generate cloud-init: %w", err) - } - - // Create instance with cloud-init - if err := client.Create(name, image, cloudInit); err != nil { - return fmt.Errorf("failed to create instance: %w", err) - } - - // Add mount devices - if cfg.Mounts.Home { - homeDir := os.Getenv("HOME") - hostPath := fmt.Sprintf("/home/%s/host", username) - fmt.Println(styles.Info(fmt.Sprintf("Mounting home directory at %s...", hostPath))) - if err := client.AddDiskDevice(name, "home", homeDir, hostPath); err != nil { - return fmt.Errorf("failed to add home mount: %w", err) - } - } - - if cfg.Mounts.Project { - workspacePath := fmt.Sprintf("/home/%s/workspace/%s", username, projectName) - fmt.Println(styles.Info(fmt.Sprintf("Mounting project directory at %s...", workspacePath))) - if err := client.AddDiskDevice(name, "project", cwd, workspacePath); err != nil { - return fmt.Errorf("failed to add project mount: %w", err) - } - } - - // Start the instance first (before display passthrough, so /run/user exists) - fmt.Println(styles.Info("Starting container...")) - if err := client.Start(name); err != nil { - return fmt.Errorf("failed to start instance: %w", err) - } - - // Wait for cloud-init to complete (this creates /run/user/) - fmt.Println(styles.Info("Waiting for cloud-init to complete...")) - if err := client.WaitForCloudInit(name); err != nil { - return fmt.Errorf("cloud-init failed: %w", err) - } - - // Create symlinks from ~/host/ to ~/ - if len(cfg.Symlinks) > 0 { - fmt.Println(styles.Info("Creating symlinks...")) - homeDir := fmt.Sprintf("/home/%s", username) - hostDir := fmt.Sprintf("%s/host", homeDir) - for _, link := range cfg.Symlinks { - // Clean the path and remove leading ~/ or / if present - link = filepath.Clean(link) - if len(link) >= 2 && link[:2] == "~/" { - link = link[2:] - } else if len(link) >= 1 && link[0] == '/' { - link = link[1:] - } - - source := filepath.Join(hostDir, link) - target := filepath.Join(homeDir, link) - - // Create parent directory if needed, then create symlink - // Use -f to force overwrite, and || true to not fail if source doesn't exist - parentDir := filepath.Dir(target) - cmd := fmt.Sprintf("mkdir -p %s && [ -e %s ] && ln -sf %s %s || true", parentDir, source, source, target) - if err := client.ExecAsUser(name, username, "/bin/sh", "-c", cmd); err != nil { - fmt.Println(styles.Warning(fmt.Sprintf("Failed to create symlink for %s: %v", link, err))) - } - } - } - - // Add display passthrough (now /run/user/ exists) - if cfg.Display.Enabled { - fmt.Println(styles.Info("Configuring display passthrough...")) - displayType := display.Detect() - if err := display.ConfigurePassthrough(client, name, displayType, cfg.Display.GPU); err != nil { - fmt.Println(styles.Warning(fmt.Sprintf("Display passthrough configuration failed: %v", err))) - fmt.Println(styles.Warning("GUI applications may not work correctly")) - } - } - - // Run scripts from .igloo/scripts directory if present - runner := script.NewRunner(client, name, username, projectName, cwd) - scripts, err := runner.GetScripts() - if err != nil { - return fmt.Errorf("failed to check for scripts: %w", err) - } - if len(scripts) > 0 { - fmt.Println(styles.Info(fmt.Sprintf("Running %d init script(s) from .igloo/scripts/...", len(scripts)))) - for _, s := range scripts { - fmt.Println(styles.Info(fmt.Sprintf(" → %s", s))) - } - if err := runner.RunScripts(); err != nil { - return fmt.Errorf("init scripts failed: %w", err) - } - } - - fmt.Println(styles.Success(fmt.Sprintf("Igloo environment '%s' is ready!", name))) - - return nil -} diff --git a/cmd/remove.go b/cmd/remove.go deleted file mode 100644 index f5f473a..0000000 --- a/cmd/remove.go +++ /dev/null @@ -1,69 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/frostyard/igloo/internal/config" - "github.com/frostyard/igloo/internal/incus" - "github.com/frostyard/igloo/internal/ui" - "github.com/spf13/cobra" -) - -func removeCmd() *cobra.Command { - var force bool - - cmd := &cobra.Command{ - Use: "remove", - Short: "Remove the igloo container but keep the configuration", - Long: `Remove deletes the igloo container but preserves the .igloo configuration directory. -This allows you to recreate the environment later with the same settings using 'igloo init'.`, - Example: ` # Remove the igloo container - igloo remove - - # Force remove without stopping first - igloo remove --force`, - RunE: func(cmd *cobra.Command, args []string) error { - return runRemove(force) - }, - } - - cmd.Flags().BoolVarP(&force, "force", "f", false, "Force remove without stopping first") - - return cmd -} - -func runRemove(force bool) error { - styles := ui.NewStyles() - - // Load config - cfg, err := config.Load(config.ConfigPath()) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - client := incus.NewClient() - - // Check if instance exists - exists, err := client.InstanceExists(cfg.Container.Name) - if err != nil { - return fmt.Errorf("failed to check instance: %w", err) - } - - if !exists { - fmt.Println(styles.Warning(fmt.Sprintf("Container %s does not exist", cfg.Container.Name))) - return nil - } - - fmt.Println(styles.Info(fmt.Sprintf("Removing container %s...", cfg.Container.Name))) - if err := client.Delete(cfg.Container.Name, force); err != nil { - return fmt.Errorf("failed to remove instance: %w", err) - } - - // Remove stored config hash so next enter will re-provision - if err := config.RemoveStoredHash(cfg.Container.Name); err != nil { - fmt.Println(styles.Warning(fmt.Sprintf("Could not remove stored hash: %v", err))) - } - - fmt.Println(styles.Success(fmt.Sprintf("Container %s removed (.igloo preserved)", cfg.Container.Name))) - return nil -} diff --git a/cmd/status.go b/cmd/status.go deleted file mode 100644 index 2c77d15..0000000 --- a/cmd/status.go +++ /dev/null @@ -1,131 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "path/filepath" - "sort" - - "github.com/frostyard/igloo/internal/config" - "github.com/frostyard/igloo/internal/incus" - "github.com/frostyard/igloo/internal/ui" - "github.com/spf13/cobra" -) - -func statusCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "status", - Short: "Show the status of the igloo development environment", - Long: `Status displays information about the igloo container.`, - Example: ` # Show environment status - igloo status`, - RunE: func(cmd *cobra.Command, args []string) error { - return runStatus() - }, - } - - return cmd -} - -func runStatus() error { - styles := ui.NewStyles() - - // Load config - cfg, err := config.Load(config.ConfigPath()) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - client := incus.NewClient() - - // Check if instance exists - exists, err := client.InstanceExists(cfg.Container.Name) - if err != nil { - return fmt.Errorf("failed to check instance: %w", err) - } - - fmt.Println(styles.Header("Igloo Environment Status")) - fmt.Println() - - fmt.Printf(" %s %s\n", styles.Label("Name:"), cfg.Container.Name) - fmt.Printf(" %s %s\n", styles.Label("Image:"), cfg.Container.Image) - - if !exists { - fmt.Printf(" %s %s\n", styles.Label("Status:"), styles.Error("not created")) - return nil - } - - // Get instance status - running, err := client.IsRunning(cfg.Container.Name) - if err != nil { - return fmt.Errorf("failed to check instance status: %w", err) - } - - if running { - fmt.Printf(" %s %s\n", styles.Label("Status:"), styles.Success("running")) - } else { - fmt.Printf(" %s %s\n", styles.Label("Status:"), styles.Warning("stopped")) - } - - // Show mount info - fmt.Println() - fmt.Println(styles.Header("Mounts")) - if cfg.Mounts.Home { - fmt.Printf(" %s ~/host\n", styles.Label("Home:")) - } - if cfg.Mounts.Project { - fmt.Printf(" %s ~/workspace/\n", styles.Label("Project:")) - } - - // Show display info - if cfg.Display.Enabled { - fmt.Println() - fmt.Println(styles.Header("Display")) - fmt.Printf(" %s enabled\n", styles.Label("Passthrough:")) - if cfg.Display.GPU { - fmt.Printf(" %s enabled\n", styles.Label("GPU:")) - } - } - - // Show packages - if cfg.Packages.Install != "" { - fmt.Println() - fmt.Println(styles.Header("Packages")) - fmt.Printf(" %s\n", cfg.Packages.Install) - } - - // Show init scripts - scriptsPath := config.ScriptsPath() - if entries, err := os.ReadDir(scriptsPath); err == nil { - var scripts []string - for _, entry := range entries { - if !entry.IsDir() { - name := entry.Name() - // Skip hidden files and .example files - if len(name) > 0 && name[0] != '.' && filepath.Ext(name) != ".example" { - scripts = append(scripts, name) - } - } - } - if len(scripts) > 0 { - sort.Strings(scripts) - fmt.Println() - fmt.Println(styles.Header("Init Scripts")) - for _, s := range scripts { - fmt.Printf(" %s\n", s) - } - } - } - - // Show symlinks - if len(cfg.Symlinks) > 0 { - fmt.Println() - fmt.Println(styles.Header("Symlinks")) - fmt.Printf(" %s ~/host/ → ~/\n", styles.Label("Pattern:")) - for _, s := range cfg.Symlinks { - fmt.Printf(" %s\n", s) - } - } - - return nil -} diff --git a/cmd/stop.go b/cmd/stop.go deleted file mode 100644 index 36e6409..0000000 --- a/cmd/stop.go +++ /dev/null @@ -1,64 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/frostyard/igloo/internal/config" - "github.com/frostyard/igloo/internal/incus" - "github.com/frostyard/igloo/internal/ui" - "github.com/spf13/cobra" -) - -func stopCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "stop", - Short: "Stop the igloo development environment", - Long: `Stop shuts down the igloo container without destroying it.`, - Example: ` # Stop the igloo environment - igloo stop`, - RunE: func(cmd *cobra.Command, args []string) error { - return runStop() - }, - } - - return cmd -} - -func runStop() error { - styles := ui.NewStyles() - - // Load config - cfg, err := config.Load(config.ConfigPath()) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - client := incus.NewClient() - - // Check if instance exists - exists, err := client.InstanceExists(cfg.Container.Name) - if err != nil { - return fmt.Errorf("failed to check instance: %w", err) - } - if !exists { - return fmt.Errorf("instance %s does not exist", cfg.Container.Name) - } - - // Check if already stopped - running, err := client.IsRunning(cfg.Container.Name) - if err != nil { - return fmt.Errorf("failed to check instance status: %w", err) - } - if !running { - fmt.Println(styles.Info(fmt.Sprintf("Container %s is already stopped", cfg.Container.Name))) - return nil - } - - fmt.Println(styles.Info(fmt.Sprintf("Stopping %s...", cfg.Container.Name))) - if err := client.Stop(cfg.Container.Name); err != nil { - return fmt.Errorf("failed to stop instance: %w", err) - } - - fmt.Println(styles.Success(fmt.Sprintf("Container %s stopped", cfg.Container.Name))) - return nil -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index df1270c..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,180 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "gopkg.in/ini.v1" -) - -// Directory and file paths for igloo configuration -const ( - // ConfigDir is the directory where igloo stores its configuration - ConfigDir = ".igloo" - // ConfigFile is the name of the configuration file - ConfigFile = "igloo.ini" - // ScriptsDir is the subdirectory within ConfigDir for init scripts - ScriptsDir = "scripts" -) - -// ConfigPath returns the full path to the igloo.ini file -func ConfigPath() string { - return filepath.Join(ConfigDir, ConfigFile) -} - -// ScriptsPath returns the full path to the scripts directory -func ScriptsPath() string { - return filepath.Join(ConfigDir, ScriptsDir) -} - -// TODO: Add support for XDG user config at ~/.config/igloo/config.ini -// This would allow users to set default distro, packages, display settings, etc. -// Project-level .igloo/igloo.ini would override these defaults. - -// IglooConfig represents the configuration for an igloo environment -type IglooConfig struct { - Container ContainerConfig - Packages PackagesConfig - Mounts MountsConfig - Display DisplayConfig - Symlinks []string // List of paths to symlink from ~/host/ to ~/ -} - -// ContainerConfig holds container-specific settings -type ContainerConfig struct { - Image string `ini:"image"` - Name string `ini:"name"` -} - -// PackagesConfig holds package installation settings -type PackagesConfig struct { - Install string `ini:"install"` -} - -// MountsConfig holds mount settings -type MountsConfig struct { - Home bool `ini:"home"` - Project bool `ini:"project"` -} - -// DisplayConfig holds display passthrough settings -type DisplayConfig struct { - Enabled bool `ini:"enabled"` - GPU bool `ini:"gpu"` -} - -// Load reads and parses an igloo.ini file -func Load(path string) (*IglooConfig, error) { - cfg, err := ini.Load(path) - if err != nil { - return nil, fmt.Errorf("failed to load config file: %w", err) - } - - config := &IglooConfig{} - - // Map sections to struct - if err := cfg.Section("container").MapTo(&config.Container); err != nil { - return nil, fmt.Errorf("failed to parse container section: %w", err) - } - - if err := cfg.Section("packages").MapTo(&config.Packages); err != nil { - return nil, fmt.Errorf("failed to parse packages section: %w", err) - } - - if err := cfg.Section("mounts").MapTo(&config.Mounts); err != nil { - return nil, fmt.Errorf("failed to parse mounts section: %w", err) - } - - if err := cfg.Section("display").MapTo(&config.Display); err != nil { - return nil, fmt.Errorf("failed to parse display section: %w", err) - } - - // Parse symlinks section (comma-separated list) - symlinksKey := cfg.Section("symlinks").Key("paths") - if symlinksKey != nil && symlinksKey.String() != "" { - paths := strings.Split(symlinksKey.String(), ",") - for _, p := range paths { - p = strings.TrimSpace(p) - if p != "" { - config.Symlinks = append(config.Symlinks, p) - } - } - } - - return config, nil -} - -// Write creates an igloo.ini file with the given configuration -func Write(path string, config *IglooConfig) error { - cfg := ini.Empty() - - // Container section - containerSec, err := cfg.NewSection("container") - if err != nil { - return err - } - containerSec.Comment = "Container configuration" - if _, err := containerSec.NewKey("image", config.Container.Image); err != nil { - return err - } - if _, err := containerSec.NewKey("name", config.Container.Name); err != nil { - return err - } - - // Packages section - packagesSec, err := cfg.NewSection("packages") - if err != nil { - return err - } - packagesSec.Comment = "Packages to install in the container" - if _, err := packagesSec.NewKey("install", config.Packages.Install); err != nil { - return err - } - - // Mounts section - mountsSec, err := cfg.NewSection("mounts") - if err != nil { - return err - } - mountsSec.Comment = "Host directory mounts" - if _, err := mountsSec.NewKey("home", fmt.Sprintf("%t", config.Mounts.Home)); err != nil { - return err - } - if _, err := mountsSec.NewKey("project", fmt.Sprintf("%t", config.Mounts.Project)); err != nil { - return err - } - - // Display section - displaySec, err := cfg.NewSection("display") - if err != nil { - return err - } - displaySec.Comment = "Display passthrough settings" - if _, err := displaySec.NewKey("enabled", fmt.Sprintf("%t", config.Display.Enabled)); err != nil { - return err - } - if _, err := displaySec.NewKey("gpu", fmt.Sprintf("%t", config.Display.GPU)); err != nil { - return err - } - - // Symlinks section - if len(config.Symlinks) > 0 { - symlinksSec, err := cfg.NewSection("symlinks") - if err != nil { - return err - } - symlinksSec.Comment = "Symlinks from ~/host/ to ~/ (files/folders that exist on host)" - if _, err := symlinksSec.NewKey("paths", strings.Join(config.Symlinks, ", ")); err != nil { - return err - } - } - - return cfg.SaveTo(path) -} - -// Remove deletes the config file -func Remove(path string) error { - return os.Remove(path) -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index c07356f..0000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,220 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoad(t *testing.T) { - // Create a temporary config file - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "igloo.ini") - - content := `[container] -image = images:ubuntu/questing -name = test-igloo - -[packages] -install = vim, git, curl - -[mounts] -home = true -project = true - -[display] -enabled = true -gpu = false -` - - if err := os.WriteFile(configPath, []byte(content), 0644); err != nil { - t.Fatalf("failed to write test config: %v", err) - } - - cfg, err := Load(configPath) - if err != nil { - t.Fatalf("Load() failed: %v", err) - } - - // Verify container section - if cfg.Container.Image != "images:ubuntu/questing" { - t.Errorf("Container.Image = %q, want %q", cfg.Container.Image, "images:ubuntu/questing") - } - if cfg.Container.Name != "test-igloo" { - t.Errorf("Container.Name = %q, want %q", cfg.Container.Name, "test-igloo") - } - - // Verify packages section - if cfg.Packages.Install != "vim, git, curl" { - t.Errorf("Packages.Install = %q, want %q", cfg.Packages.Install, "vim, git, curl") - } - - // Verify mounts section - if !cfg.Mounts.Home { - t.Error("Mounts.Home = false, want true") - } - if !cfg.Mounts.Project { - t.Error("Mounts.Project = false, want true") - } - - // Verify display section - if !cfg.Display.Enabled { - t.Error("Display.Enabled = false, want true") - } - if cfg.Display.GPU { - t.Error("Display.GPU = true, want false") - } - - // Verify symlinks section (empty in this config) - if len(cfg.Symlinks) != 0 { - t.Errorf("Symlinks = %v, want empty", cfg.Symlinks) - } - -} - -func TestLoad_WithSymlinks(t *testing.T) { - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "igloo.ini") - - content := `[container] -image = images:debian/trixie -name = test-igloo - -[packages] -install = - -[mounts] -home = true -project = true - -[display] -enabled = true -gpu = false - -[symlinks] -paths = .gitconfig, .ssh, .config/nvim -` - - if err := os.WriteFile(configPath, []byte(content), 0644); err != nil { - t.Fatalf("failed to write test config: %v", err) - } - - cfg, err := Load(configPath) - if err != nil { - t.Fatalf("Load() failed: %v", err) - } - - // Verify symlinks - expected := []string{".gitconfig", ".ssh", ".config/nvim"} - if len(cfg.Symlinks) != len(expected) { - t.Errorf("Symlinks length = %d, want %d", len(cfg.Symlinks), len(expected)) - } - for i, s := range cfg.Symlinks { - if s != expected[i] { - t.Errorf("Symlinks[%d] = %q, want %q", i, s, expected[i]) - } - } -} - -func TestLoad_FileNotFound(t *testing.T) { - _, err := Load("/nonexistent/path/igloo.ini") - if err == nil { - t.Error("Load() should fail for nonexistent file") - } -} - -func TestLoad_InvalidINI(t *testing.T) { - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "invalid.ini") - - // Write invalid INI content (unclosed section) - if err := os.WriteFile(configPath, []byte("[container\n"), 0644); err != nil { - t.Fatalf("failed to write test config: %v", err) - } - - _, err := Load(configPath) - if err == nil { - t.Error("Load() should fail for invalid INI") - } -} - -func TestWrite(t *testing.T) { - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "igloo.ini") - - cfg := &IglooConfig{ - Container: ContainerConfig{ - Image: "images:debian/trixie", - Name: "my-igloo", - }, - Packages: PackagesConfig{ - Install: "neovim, tmux", - }, - Mounts: MountsConfig{ - Home: true, - Project: true, - }, - Display: DisplayConfig{ - Enabled: true, - GPU: true, - }, - } - - if err := Write(configPath, cfg); err != nil { - t.Fatalf("Write() failed: %v", err) - } - - // Read it back and verify - loaded, err := Load(configPath) - if err != nil { - t.Fatalf("Load() failed after Write(): %v", err) - } - - if loaded.Container.Image != cfg.Container.Image { - t.Errorf("Container.Image = %q, want %q", loaded.Container.Image, cfg.Container.Image) - } - if loaded.Container.Name != cfg.Container.Name { - t.Errorf("Container.Name = %q, want %q", loaded.Container.Name, cfg.Container.Name) - } - if loaded.Packages.Install != cfg.Packages.Install { - t.Errorf("Packages.Install = %q, want %q", loaded.Packages.Install, cfg.Packages.Install) - } - if loaded.Mounts.Home != cfg.Mounts.Home { - t.Errorf("Mounts.Home = %v, want %v", loaded.Mounts.Home, cfg.Mounts.Home) - } - if loaded.Mounts.Project != cfg.Mounts.Project { - t.Errorf("Mounts.Project = %v, want %v", loaded.Mounts.Project, cfg.Mounts.Project) - } - if loaded.Display.Enabled != cfg.Display.Enabled { - t.Errorf("Display.Enabled = %v, want %v", loaded.Display.Enabled, cfg.Display.Enabled) - } - if loaded.Display.GPU != cfg.Display.GPU { - t.Errorf("Display.GPU = %v, want %v", loaded.Display.GPU, cfg.Display.GPU) - } -} - -func TestRemove(t *testing.T) { - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "igloo.ini") - - // Create a file to remove - if err := os.WriteFile(configPath, []byte("[container]\n"), 0644); err != nil { - t.Fatalf("failed to write test config: %v", err) - } - - if err := Remove(configPath); err != nil { - t.Fatalf("Remove() failed: %v", err) - } - - // Verify file is gone - if _, err := os.Stat(configPath); !os.IsNotExist(err) { - t.Error("file should not exist after Remove()") - } -} - -func TestRemove_NonexistentFile(t *testing.T) { - err := Remove("/nonexistent/path/igloo.ini") - if err == nil { - t.Error("Remove() should fail for nonexistent file") - } -} diff --git a/internal/config/distros.go b/internal/config/distros.go deleted file mode 100644 index 9f7f75c..0000000 --- a/internal/config/distros.go +++ /dev/null @@ -1,50 +0,0 @@ -package config - -import ( - "fmt" - "slices" -) - -// SupportedDistros maps distribution names to their supported releases -var SupportedDistros = map[string][]string{ - "ubuntu": {"questing", "plucky", "noble", "jammy", "focal"}, - "debian": {"trixie", "bookworm", "bullseye"}, - "fedora": {"43", "42", "41", "40", "39"}, - "archlinux": {"current"}, -} - -// DefaultRelease returns the default (latest) release for a distro -var DefaultRelease = map[string]string{ - "ubuntu": "questing", - "debian": "trixie", - "fedora": "43", - "archlinux": "current", -} - -// ValidateDistro checks if the distro and release combination is supported -func ValidateDistro(distro, release string) error { - releases, ok := SupportedDistros[distro] - if !ok { - return fmt.Errorf("unsupported distribution: %s\nSupported: ubuntu, debian, fedora, archlinux", distro) - } - - if !slices.Contains(releases, release) { - return fmt.Errorf("unsupported release '%s' for %s\nSupported releases: %v", release, distro, releases) - } - - return nil -} - -// GetDefaultRelease returns the default release for a distro -func GetDefaultRelease(distro string) string { - if release, ok := DefaultRelease[distro]; ok { - return release - } - return "" -} - -// IsDistroSupported checks if a distro name is in our supported list -func IsDistroSupported(distro string) bool { - _, ok := SupportedDistros[distro] - return ok -} diff --git a/internal/config/distros_test.go b/internal/config/distros_test.go deleted file mode 100644 index 75fa682..0000000 --- a/internal/config/distros_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package config - -import ( - "testing" -) - -func TestValidateDistro(t *testing.T) { - tests := []struct { - name string - distro string - release string - wantErr bool - }{ - // Valid combinations - {"ubuntu questing", "ubuntu", "questing", false}, - {"ubuntu noble", "ubuntu", "noble", false}, - {"ubuntu jammy", "ubuntu", "jammy", false}, - {"ubuntu focal", "ubuntu", "focal", false}, - {"debian trixie", "debian", "trixie", false}, - {"debian bookworm", "debian", "bookworm", false}, - {"debian bullseye", "debian", "bullseye", false}, - {"fedora 43", "fedora", "43", false}, - {"fedora 41", "fedora", "41", false}, - {"fedora 40", "fedora", "40", false}, - {"archlinux current", "archlinux", "current", false}, - - // Invalid distros - {"unsupported distro", "gentoo", "latest", true}, - {"empty distro", "", "questing", true}, - {"typo distro", "ubunutu", "questing", true}, - - // Invalid releases - {"ubuntu invalid release", "ubuntu", "bionic", true}, - {"debian invalid release", "debian", "buster", true}, - {"fedora invalid release", "fedora", "38", true}, - {"archlinux invalid release", "archlinux", "rolling", true}, - {"empty release", "ubuntu", "", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateDistro(tt.distro, tt.release) - if (err != nil) != tt.wantErr { - t.Errorf("ValidateDistro(%q, %q) error = %v, wantErr %v", tt.distro, tt.release, err, tt.wantErr) - } - }) - } -} - -func TestGetDefaultRelease(t *testing.T) { - tests := []struct { - distro string - want string - }{ - {"ubuntu", "questing"}, - {"debian", "trixie"}, - {"fedora", "43"}, - {"archlinux", "current"}, - {"unknown", ""}, - } - - for _, tt := range tests { - t.Run(tt.distro, func(t *testing.T) { - got := GetDefaultRelease(tt.distro) - if got != tt.want { - t.Errorf("GetDefaultRelease(%q) = %q, want %q", tt.distro, got, tt.want) - } - }) - } -} - -func TestIsDistroSupported(t *testing.T) { - tests := []struct { - distro string - want bool - }{ - {"ubuntu", true}, - {"debian", true}, - {"fedora", true}, - {"archlinux", true}, - {"gentoo", false}, - {"centos", false}, - {"", false}, - {"Ubuntu", false}, // case sensitive - } - - for _, tt := range tests { - t.Run(tt.distro, func(t *testing.T) { - got := IsDistroSupported(tt.distro) - if got != tt.want { - t.Errorf("IsDistroSupported(%q) = %v, want %v", tt.distro, got, tt.want) - } - }) - } -} - -func TestSupportedDistrosCompleteness(t *testing.T) { - // Ensure DefaultRelease has entries for all supported distros - for distro := range SupportedDistros { - if _, ok := DefaultRelease[distro]; !ok { - t.Errorf("DefaultRelease missing entry for %q", distro) - } - } - - // Ensure DefaultRelease only has entries for supported distros - for distro := range DefaultRelease { - if _, ok := SupportedDistros[distro]; !ok { - t.Errorf("DefaultRelease has entry for unsupported distro %q", distro) - } - } - - // Ensure default release is always in the supported releases list - for distro, defaultRel := range DefaultRelease { - releases := SupportedDistros[distro] - found := false - for _, r := range releases { - if r == defaultRel { - found = true - break - } - } - if !found { - t.Errorf("DefaultRelease[%q] = %q is not in SupportedDistros[%q]", distro, defaultRel, distro) - } - } -} diff --git a/internal/config/hash.go b/internal/config/hash.go deleted file mode 100644 index 9a479ab..0000000 --- a/internal/config/hash.go +++ /dev/null @@ -1,156 +0,0 @@ -package config - -import ( - "crypto/sha256" - "encoding/hex" - "io" - "os" - "path/filepath" - "sort" -) - -// GetDataDir returns the XDG data directory for igloo -// Uses $XDG_DATA_HOME/igloo or ~/.local/share/igloo -func GetDataDir() string { - dataHome := os.Getenv("XDG_DATA_HOME") - if dataHome == "" { - home := os.Getenv("HOME") - dataHome = filepath.Join(home, ".local", "share") - } - return filepath.Join(dataHome, "igloo") -} - -// HashConfigDir computes a SHA256 hash of all files in the .igloo directory -func HashConfigDir() (string, error) { - return hashDir(ConfigDir) -} - -// hashDir computes a SHA256 hash of all files in a directory -func hashDir(dir string) (string, error) { - h := sha256.New() - - // Walk the config directory and hash all file contents - err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - // Hash the relative path for structure - relPath, err := filepath.Rel(dir, path) - if err != nil { - return err - } - - // Skip directories, just hash their names for structure - if info.IsDir() { - h.Write([]byte("dir:" + relPath + "\n")) - return nil - } - - // Hash the relative path and file contents - h.Write([]byte("file:" + relPath + "\n")) - - // Read and hash file contents - f, err := os.Open(path) - if err != nil { - return err - } - - if _, err := io.Copy(h, f); err != nil { - if closeErr := f.Close(); closeErr != nil { - return closeErr - } - return err - } - - if err := f.Close(); err != nil { - return err - } - - return nil - }) - - if err != nil { - return "", err - } - - return hex.EncodeToString(h.Sum(nil)), nil -} - -// GetStoredHash retrieves the stored hash for a container -func GetStoredHash(containerName string) (string, error) { - hashFile := filepath.Join(GetDataDir(), containerName+".hash") - data, err := os.ReadFile(hashFile) - if err != nil { - if os.IsNotExist(err) { - return "", nil // No stored hash yet - } - return "", err - } - return string(data), nil -} - -// StoreHash saves the hash for a container -func StoreHash(containerName, hash string) error { - dataDir := GetDataDir() - if err := os.MkdirAll(dataDir, 0755); err != nil { - return err - } - - hashFile := filepath.Join(dataDir, containerName+".hash") - return os.WriteFile(hashFile, []byte(hash), 0644) -} - -// RemoveStoredHash deletes the stored hash for a container -func RemoveStoredHash(containerName string) error { - hashFile := filepath.Join(GetDataDir(), containerName+".hash") - err := os.Remove(hashFile) - if os.IsNotExist(err) { - return nil // Already gone - } - return err -} - -// ConfigChanged checks if the .igloo directory has changed since last provision -// Returns (changed, currentHash, error) -func ConfigChanged(containerName string) (bool, string, error) { - currentHash, err := HashConfigDir() - if err != nil { - return false, "", err - } - - storedHash, err := GetStoredHash(containerName) - if err != nil { - return false, currentHash, err - } - - // If no stored hash, this is first run - not "changed" - if storedHash == "" { - return false, currentHash, nil - } - - return currentHash != storedHash, currentHash, nil -} - -// ListStoredHashes returns all container names that have stored hashes -func ListStoredHashes() ([]string, error) { - dataDir := GetDataDir() - entries, err := os.ReadDir(dataDir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - - var containers []string - for _, entry := range entries { - if !entry.IsDir() && filepath.Ext(entry.Name()) == ".hash" { - name := entry.Name() - containers = append(containers, name[:len(name)-5]) // Remove .hash suffix - } - } - - sort.Strings(containers) - return containers, nil -} diff --git a/internal/config/hash_test.go b/internal/config/hash_test.go deleted file mode 100644 index 0641662..0000000 --- a/internal/config/hash_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "testing" -) - -func TestHashDir(t *testing.T) { - // Create a temp directory with config structure - tmpDir := t.TempDir() - configDir := filepath.Join(tmpDir, ".igloo") - - // Create the config directory - if err := os.MkdirAll(configDir, 0755); err != nil { - t.Fatal(err) - } - - // Create a config file - configContent := "[container]\nname = test\n" - if err := os.WriteFile(filepath.Join(configDir, "igloo.ini"), []byte(configContent), 0644); err != nil { - t.Fatal(err) - } - - // Hash should succeed - hash1, err := hashDir(configDir) - if err != nil { - t.Fatalf("hashDir() error = %v", err) - } - if hash1 == "" { - t.Error("hashDir() returned empty hash") - } - - // Same content should produce same hash - hash2, err := hashDir(configDir) - if err != nil { - t.Fatalf("hashDir() error = %v", err) - } - if hash1 != hash2 { - t.Errorf("hashDir() not deterministic: %q != %q", hash1, hash2) - } - - // Changing content should change hash - if err := os.WriteFile(filepath.Join(configDir, "igloo.ini"), []byte("[container]\nname = changed\n"), 0644); err != nil { - t.Fatal(err) - } - hash3, err := hashDir(configDir) - if err != nil { - t.Fatalf("hashDir() error = %v", err) - } - if hash1 == hash3 { - t.Error("hashDir() should return different hash for different content") - } -} - -func TestStoreAndGetHash(t *testing.T) { - // Use a temp directory for data - tmpDir := t.TempDir() - t.Setenv("XDG_DATA_HOME", tmpDir) - - containerName := "test-container" - testHash := "abc123def456" - - // Initially no hash stored - hash, err := GetStoredHash(containerName) - if err != nil { - t.Fatalf("GetStoredHash() error = %v", err) - } - if hash != "" { - t.Errorf("GetStoredHash() = %q, want empty", hash) - } - - // Store a hash - if err := StoreHash(containerName, testHash); err != nil { - t.Fatalf("StoreHash() error = %v", err) - } - - // Retrieve it - hash, err = GetStoredHash(containerName) - if err != nil { - t.Fatalf("GetStoredHash() error = %v", err) - } - if hash != testHash { - t.Errorf("GetStoredHash() = %q, want %q", hash, testHash) - } - - // Remove it - if err := RemoveStoredHash(containerName); err != nil { - t.Fatalf("RemoveStoredHash() error = %v", err) - } - - // Should be gone - hash, err = GetStoredHash(containerName) - if err != nil { - t.Fatalf("GetStoredHash() error = %v", err) - } - if hash != "" { - t.Errorf("GetStoredHash() after remove = %q, want empty", hash) - } -} - -func TestConfigChanged(t *testing.T) { - // This test requires actual .igloo directory, so we'll skip the ConfigChanged test - // and focus on testing the underlying hashDir and store/get functions - t.Skip("ConfigChanged requires modifying package-level constants") -} - -func TestGetDataDir(t *testing.T) { - // Test with XDG_DATA_HOME set - t.Setenv("XDG_DATA_HOME", "/custom/data") - - dataDir := GetDataDir() - expected := "/custom/data/igloo" - if dataDir != expected { - t.Errorf("GetDataDir() = %q, want %q", dataDir, expected) - } -} diff --git a/internal/config/hostdetect.go b/internal/config/hostdetect.go deleted file mode 100644 index 03f080b..0000000 --- a/internal/config/hostdetect.go +++ /dev/null @@ -1,61 +0,0 @@ -package config - -import ( - "bufio" - "os" - "strings" -) - -// DetectHostOS reads /etc/os-release and returns the distro and release -// Falls back to ubuntu/questing if detection fails or OS is not supported -func DetectHostOS() (distro, release string) { - // Default fallback - defaultDistro := "ubuntu" - defaultRelease := "questing" - - file, err := os.Open("/etc/os-release") - if err != nil { - return defaultDistro, defaultRelease - } - defer func() { _ = file.Close() }() - - osInfo := make(map[string]string) - scanner := bufio.NewScanner(file) - - for scanner.Scan() { - line := scanner.Text() - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 { - key := parts[0] - value := strings.Trim(parts[1], "\"") - osInfo[key] = value - } - } - - // Get distribution ID - distro = strings.ToLower(osInfo["ID"]) - - // Check if it's a supported distro - if !IsDistroSupported(distro) { - return defaultDistro, defaultRelease - } - - // Get version/release - // Fedora uses VERSION_ID (numeric), others use VERSION_CODENAME - switch distro { - case "fedora": - release = osInfo["VERSION_ID"] - case "archlinux": - release = "current" - default: - release = strings.ToLower(osInfo["VERSION_CODENAME"]) - } - - // Validate the release is supported - if err := ValidateDistro(distro, release); err != nil { - // Distro is supported but release isn't - use default release for this distro - release = GetDefaultRelease(distro) - } - - return distro, release -} diff --git a/internal/config/hostdetect_test.go b/internal/config/hostdetect_test.go deleted file mode 100644 index 0397faf..0000000 --- a/internal/config/hostdetect_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "testing" -) - -// TestParseOSRelease tests OS detection with mock os-release files -func TestParseOSRelease(t *testing.T) { - tests := []struct { - name string - content string - wantDistro string - wantRelease string - }{ - { - name: "ubuntu questing", - content: `NAME="Ubuntu" -VERSION="25.10 (Questing Quokka)" -ID=ubuntu -VERSION_CODENAME=questing -PRETTY_NAME="Ubuntu 25.10" -`, - wantDistro: "ubuntu", - wantRelease: "questing", - }, - { - name: "debian trixie", - content: `PRETTY_NAME="Debian GNU/Linux 13 (trixie)" -NAME="Debian GNU/Linux" -VERSION_ID="13" -VERSION="13 (trixie)" -VERSION_CODENAME=trixie -ID=debian -`, - wantDistro: "debian", - wantRelease: "trixie", - }, - { - name: "fedora 43", - content: `NAME="Fedora Linux" -VERSION="43 (Workstation Edition)" -ID=fedora -VERSION_ID=43 -PRETTY_NAME="Fedora Linux 43 (Workstation Edition)" -`, - wantDistro: "fedora", - wantRelease: "43", - }, - { - name: "archlinux", - content: `NAME="Arch Linux" -PRETTY_NAME="Arch Linux" -ID=archlinux -BUILD_ID=rolling -`, - wantDistro: "archlinux", - wantRelease: "current", - }, - { - name: "unsupported distro falls back", - content: `NAME="Gentoo" -ID=gentoo -VERSION_CODENAME=latest -`, - wantDistro: "ubuntu", - wantRelease: "questing", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a temp file with os-release content - tmpDir := t.TempDir() - osReleasePath := filepath.Join(tmpDir, "os-release") - if err := os.WriteFile(osReleasePath, []byte(tt.content), 0644); err != nil { - t.Fatalf("failed to write os-release: %v", err) - } - - // Parse it using our helper - distro, release := parseOSReleaseFile(osReleasePath) - - if distro != tt.wantDistro { - t.Errorf("distro = %q, want %q", distro, tt.wantDistro) - } - if release != tt.wantRelease { - t.Errorf("release = %q, want %q", release, tt.wantRelease) - } - }) - } -} - -func TestParseOSRelease_FileNotFound(t *testing.T) { - distro, release := parseOSReleaseFile("/nonexistent/os-release") - if distro != "ubuntu" || release != "questing" { - t.Errorf("expected fallback (ubuntu, questing), got (%q, %q)", distro, release) - } -} - -// parseOSReleaseFile is a testable version of DetectHostOS that accepts a custom path -func parseOSReleaseFile(path string) (distro, release string) { - // Default fallback - defaultDistro := "ubuntu" - defaultRelease := "questing" - - content, err := os.ReadFile(path) - if err != nil { - return defaultDistro, defaultRelease - } - - osInfo := make(map[string]string) - - for _, line := range splitLines(string(content)) { - if len(line) == 0 { - continue - } - parts := splitN(line, "=", 2) - if len(parts) == 2 { - key := parts[0] - value := trim(parts[1], "\"") - osInfo[key] = value - } - } - - // Get distribution ID - distro = toLower(osInfo["ID"]) - - // Check if it's a supported distro - if !IsDistroSupported(distro) { - return defaultDistro, defaultRelease - } - - // Get version/release - switch distro { - case "fedora": - release = osInfo["VERSION_ID"] - case "archlinux": - release = "current" - default: - release = toLower(osInfo["VERSION_CODENAME"]) - } - - // Validate the release is supported - if err := ValidateDistro(distro, release); err != nil { - release = GetDefaultRelease(distro) - } - - return distro, release -} - -// Helper functions to avoid importing strings in test -func splitLines(s string) []string { - var lines []string - start := 0 - for i := 0; i < len(s); i++ { - if s[i] == '\n' { - lines = append(lines, s[start:i]) - start = i + 1 - } - } - if start < len(s) { - lines = append(lines, s[start:]) - } - return lines -} - -func splitN(s, sep string, n int) []string { - if n <= 0 { - return nil - } - idx := -1 - for i := 0; i <= len(s)-len(sep); i++ { - if s[i:i+len(sep)] == sep { - idx = i - break - } - } - if idx < 0 { - return []string{s} - } - return []string{s[:idx], s[idx+len(sep):]} -} - -func trim(s, cutset string) string { - for len(s) > 0 && containsChar(cutset, s[0]) { - s = s[1:] - } - for len(s) > 0 && containsChar(cutset, s[len(s)-1]) { - s = s[:len(s)-1] - } - return s -} - -func containsChar(s string, c byte) bool { - for i := 0; i < len(s); i++ { - if s[i] == c { - return true - } - } - return false -} - -func toLower(s string) string { - b := make([]byte, len(s)) - for i := 0; i < len(s); i++ { - c := s[i] - if c >= 'A' && c <= 'Z' { - c += 'a' - 'A' - } - b[i] = c - } - return string(b) -} diff --git a/internal/script/runner.go b/internal/script/runner.go deleted file mode 100644 index bba7a8c..0000000 --- a/internal/script/runner.go +++ /dev/null @@ -1,114 +0,0 @@ -package script - -import ( - "fmt" - "os" - "path/filepath" - "sort" - - "github.com/frostyard/igloo/internal/config" - "github.com/frostyard/igloo/internal/incus" -) - -// Runner handles script execution in incus instances -type Runner struct { - client *incus.Client - instance string - username string - projectName string - projectDir string -} - -// NewRunner creates a new script runner -func NewRunner(client *incus.Client, instance, username, projectName, projectDir string) *Runner { - return &Runner{ - client: client, - instance: instance, - username: username, - projectName: projectName, - projectDir: projectDir, - } -} - -// RunScripts executes all scripts in the .igloo/scripts directory in lexicographical order -func (r *Runner) RunScripts() error { - scriptsPath := filepath.Join(r.projectDir, config.ScriptsPath()) - - // Check if scripts directory exists on the host - entries, err := os.ReadDir(scriptsPath) - if err != nil { - if os.IsNotExist(err) { - // No scripts directory - nothing to do - return nil - } - return fmt.Errorf("failed to read scripts directory: %w", err) - } - - // Filter and sort script files - var scripts []string - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - // Skip hidden files and non-executable looking files - if len(name) > 0 && name[0] != '.' { - scripts = append(scripts, name) - } - } - - if len(scripts) == 0 { - return nil - } - - // Sort lexicographically - sort.Strings(scripts) - - // The project directory is mounted at /home/$USER/workspace/$projectName - workspacePath := fmt.Sprintf("/home/%s/workspace/%s", r.username, r.projectName) - containerScriptsDir := filepath.Join(workspacePath, config.ScriptsPath()) - - // Execute each script in order - for _, scriptName := range scripts { - fullScriptPath := filepath.Join(containerScriptsDir, scriptName) - - // Make the script executable - if err := r.client.ExecAsRoot(r.instance, "chmod", "+x", fullScriptPath); err != nil { - return fmt.Errorf("failed to make script %s executable: %w", scriptName, err) - } - - // Execute the script as root - if err := r.client.ExecAsRoot(r.instance, "/bin/sh", "-c", fullScriptPath); err != nil { - return fmt.Errorf("script %s failed: %w", scriptName, err) - } - } - - return nil -} - -// GetScripts returns the list of scripts that would be run, in order -func (r *Runner) GetScripts() ([]string, error) { - scriptsPath := filepath.Join(r.projectDir, config.ScriptsPath()) - - entries, err := os.ReadDir(scriptsPath) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - - var scripts []string - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - if len(name) > 0 && name[0] != '.' { - scripts = append(scripts, name) - } - } - - sort.Strings(scripts) - return scripts, nil -} diff --git a/internal/script/runner_test.go b/internal/script/runner_test.go deleted file mode 100644 index b485b2e..0000000 --- a/internal/script/runner_test.go +++ /dev/null @@ -1,207 +0,0 @@ -package script - -import ( - "os" - "path/filepath" - "testing" - - "github.com/frostyard/igloo/internal/config" -) - -func TestNewRunner(t *testing.T) { - runner := NewRunner(nil, "test-instance", "testuser", "myproject", "/tmp/test") - - if runner.instance != "test-instance" { - t.Errorf("instance = %q, want %q", runner.instance, "test-instance") - } - if runner.username != "testuser" { - t.Errorf("username = %q, want %q", runner.username, "testuser") - } - if runner.projectName != "myproject" { - t.Errorf("projectName = %q, want %q", runner.projectName, "myproject") - } - if runner.projectDir != "/tmp/test" { - t.Errorf("projectDir = %q, want %q", runner.projectDir, "/tmp/test") - } -} - -func TestGetScripts_NoDirectory(t *testing.T) { - // Create a temp directory without .igloo/scripts - tmpDir := t.TempDir() - runner := NewRunner(nil, "test", "user", "proj", tmpDir) - - scripts, err := runner.GetScripts() - if err != nil { - t.Errorf("GetScripts() error = %v, want nil", err) - } - if len(scripts) != 0 { - t.Errorf("GetScripts() = %v, want empty slice", scripts) - } -} - -func TestGetScripts_EmptyDirectory(t *testing.T) { - // Create a temp directory with empty .igloo/scripts - tmpDir := t.TempDir() - scriptsDir := filepath.Join(tmpDir, config.ScriptsPath()) - if err := os.MkdirAll(scriptsDir, 0755); err != nil { - t.Fatal(err) - } - - runner := NewRunner(nil, "test", "user", "proj", tmpDir) - - scripts, err := runner.GetScripts() - if err != nil { - t.Errorf("GetScripts() error = %v, want nil", err) - } - if len(scripts) != 0 { - t.Errorf("GetScripts() = %v, want empty slice", scripts) - } -} - -func TestGetScripts_WithScripts(t *testing.T) { - // Create a temp directory with some scripts - tmpDir := t.TempDir() - scriptsDir := filepath.Join(tmpDir, config.ScriptsPath()) - if err := os.MkdirAll(scriptsDir, 0755); err != nil { - t.Fatal(err) - } - - // Create some script files (out of order to test sorting) - scriptFiles := []string{"03-third.sh", "01-first.sh", "02-second.sh"} - for _, name := range scriptFiles { - f, err := os.Create(filepath.Join(scriptsDir, name)) - if err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - } - - runner := NewRunner(nil, "test", "user", "proj", tmpDir) - - scripts, err := runner.GetScripts() - if err != nil { - t.Errorf("GetScripts() error = %v, want nil", err) - } - - // Should be sorted lexicographically - expected := []string{"01-first.sh", "02-second.sh", "03-third.sh"} - if len(scripts) != len(expected) { - t.Errorf("GetScripts() returned %d scripts, want %d", len(scripts), len(expected)) - } - for i, s := range scripts { - if s != expected[i] { - t.Errorf("scripts[%d] = %q, want %q", i, s, expected[i]) - } - } -} - -func TestGetScripts_SkipsDirectories(t *testing.T) { - // Create a temp directory with scripts and a subdirectory - tmpDir := t.TempDir() - scriptsDir := filepath.Join(tmpDir, config.ScriptsPath()) - if err := os.MkdirAll(scriptsDir, 0755); err != nil { - t.Fatal(err) - } - - // Create a script file - f, err := os.Create(filepath.Join(scriptsDir, "01-init.sh")) - if err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - - // Create a subdirectory (should be skipped) - if err := os.Mkdir(filepath.Join(scriptsDir, "subdir"), 0755); err != nil { - t.Fatal(err) - } - - runner := NewRunner(nil, "test", "user", "proj", tmpDir) - - scripts, err := runner.GetScripts() - if err != nil { - t.Errorf("GetScripts() error = %v, want nil", err) - } - if len(scripts) != 1 { - t.Errorf("GetScripts() = %v, want 1 script", scripts) - } - if scripts[0] != "01-init.sh" { - t.Errorf("scripts[0] = %q, want %q", scripts[0], "01-init.sh") - } -} - -func TestScriptPathResolution(t *testing.T) { - tests := []struct { - name string - scriptPath string - username string - projectName string - wantPath string - }{ - { - name: "relative path", - scriptPath: "setup.sh", - username: "bjk", - projectName: "igloo", - wantPath: "/home/bjk/workspace/igloo/setup.sh", - }, - { - name: "relative path with subdirectory", - scriptPath: "scripts/init.sh", - username: "bjk", - projectName: "igloo", - wantPath: "/home/bjk/workspace/igloo/scripts/init.sh", - }, - { - name: "absolute path", - scriptPath: "/opt/scripts/setup.sh", - username: "bjk", - projectName: "igloo", - wantPath: "/opt/scripts/setup.sh", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - workspacePath := "/home/" + tt.username + "/workspace/" + tt.projectName - - var fullScriptPath string - if filepath.IsAbs(tt.scriptPath) { - fullScriptPath = tt.scriptPath - } else { - fullScriptPath = filepath.Join(workspacePath, tt.scriptPath) - } - - if fullScriptPath != tt.wantPath { - t.Errorf("script path = %q, want %q", fullScriptPath, tt.wantPath) - } - }) - } -} - -func TestRunnerFields(t *testing.T) { - runner := &Runner{ - client: nil, - instance: "my-igloo", - username: "developer", - projectName: "myapp", - projectDir: "/home/dev/projects/myapp", - } - - // Verify all fields are set correctly - if runner.instance != "my-igloo" { - t.Errorf("instance = %q, want %q", runner.instance, "my-igloo") - } - if runner.username != "developer" { - t.Errorf("username = %q, want %q", runner.username, "developer") - } - if runner.projectName != "myapp" { - t.Errorf("projectName = %q, want %q", runner.projectName, "myapp") - } - if runner.projectDir != "/home/dev/projects/myapp" { - t.Errorf("projectDir = %q, want %q", runner.projectDir, "/home/dev/projects/myapp") - } -} From 9a7c5f8cec0409360f0a4cb97d2f26a2cc0fee18 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Tue, 10 Feb 2026 15:09:22 -0500 Subject: [PATCH 09/11] chore: remove gopkg.in/ini.v1 dependency, go mod tidy Co-Authored-By: Claude Opus 4.6 --- go.mod | 2 +- go.sum | 11 ----------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/go.mod b/go.mod index ccf3648..696f77b 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.10.2 - gopkg.in/ini.v1 v1.67.1 ) require ( @@ -35,6 +34,7 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.37.0 // indirect diff --git a/go.sum b/go.sum index aa9fdf2..11582b3 100644 --- a/go.sum +++ b/go.sum @@ -33,7 +33,6 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -65,13 +64,6 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -87,8 +79,5 @@ golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= -gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From de2926334ea5e0a5db2edb1f8aad89acfa38a0c8 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Tue, 10 Feb 2026 15:09:50 -0500 Subject: [PATCH 10/11] docs: update README for simplified two-command interface Co-Authored-By: Claude Opus 4.6 --- README.md | 207 +++++++++++++----------------------------------------- 1 file changed, 47 insertions(+), 160 deletions(-) diff --git a/README.md b/README.md index fe274e6..7981c15 100644 --- a/README.md +++ b/README.md @@ -1,198 +1,85 @@ -# 🏔️ Igloo +# Igloo -**Build cozy development environments in seconds** ❄️ +**Isolated development containers for GTK apps** -Igloo is a CLI tool that creates isolated Linux development containers using [Incus](https://linuxcontainers.org/incus/). Think of it as your personal igloo in the frozen tundra of system configuration chaos—warm, safe, and exactly how you like it. +Igloo creates Linux development containers using [Incus](https://linuxcontainers.org/incus/) with automatic display passthrough. Two commands, zero configuration. -## ✨ Features - -- 🐧 **Multi-distro support** — Ubuntu, Debian, Fedora, or Arch Linux -- 🏠 **Home away from home** — Your home directory and project files are automatically mounted -- 🖥️ **GUI apps just work** — Wayland and X11 passthrough with optional GPU acceleration -- 👤 **Seamless user mapping** — Same UID/GID as your host, no permission headaches -- 📜 **Custom init scripts** — Automate your environment setup -- ⚡ **Fast iteration** — Destroy and rebuild in seconds - -## 🚀 Quick Start +## Quick Start ```bash -# Initialize a new igloo in your project directory -cd ~/projects/my-awesome-app -igloo init - -# Enter your cozy development environment -igloo enter - -# When you're done for the day -igloo stop - -# Start fresh? No problem! -igloo destroy -igloo init +cd ~/projects/my-gtk-app +igloo # creates and enters the container ``` -## 📦 Installation +That's it. Igloo auto-detects your host OS, creates a matching container, mounts your project directory, copies your dotfiles, and sets up display passthrough with GPU support. -### From Source +When you're done with the container: ```bash -git clone https://github.com/frostyard/igloo.git -cd igloo -make build -sudo cp igloo /usr/local/bin/ -``` - -### Prerequisites - -- [Incus](https://linuxcontainers.org/incus/docs/main/installing/) installed and configured -- Your user added to the `incus` group - -## 🎛️ Commands - -| Command | Description | -| --------------- | ---------------------------------- | -| `igloo init` | Create a new igloo environment | -| `igloo enter` | Enter the igloo (starts if needed) | -| `igloo stop` | Stop the running igloo | -| `igloo status` | Show environment status | -| `igloo remove` | Remove container, keep config | -| `igloo destroy` | Remove everything | - -## ⚙️ Configuration - -Running `igloo init` creates a `.igloo/` directory with your configuration: - -``` -.igloo/ -├── igloo.ini # Main configuration -└── scripts/ # Init scripts (run during provisioning) - └── 00-example.sh.example -``` - -### igloo.ini - -```ini -[container] -image = images:debian/trixie/cloud -name = igloo-myproject - -[packages] -install = git, vim, curl - -[mounts] -home = true -project = true - -[display] -enabled = true -gpu = true - -[symlinks] -paths = .gitconfig, .ssh, .config/nvim +igloo destroy ``` -### Init Scripts 📜 +## Setup Script -Drop shell scripts in `.igloo/scripts/` to customize your environment: +Optionally create a `.igloo.sh` in your project root to install dependencies on first creation: ```bash -# .igloo/scripts/01-install-tools.sh #!/bin/bash -apt-get install -y nodejs npm -npm install -g yarn +apt-get update +apt-get install -y build-essential libgtk-4-dev meson ninja-build ``` -Scripts run in lexicographical order, so use numbered prefixes like `01-`, `02-`, etc. +To reprovision after changing `.igloo.sh`, run `igloo destroy` then `igloo`. -### Symlinks 🔗 +## Commands -The `[symlinks]` section lets you link files or folders from your host home directory (`~/host/`) to the container's home (`~/`). This is perfect for sharing dotfiles! +| Command | Description | +| --- | --- | +| `igloo` | Enter the container (creates it if needed) | +| `igloo destroy` | Remove the container completely | +| `igloo --no-gui` | Enter without display/GPU passthrough | -**Default symlinks** (created automatically with `igloo init`): +## What Happens on First Run -```ini -[symlinks] -paths = .gitconfig, .ssh, .bashrc, .profile, .bash_profile -``` +1. Detects your host distro from `/etc/os-release` (follows `ID_LIKE` for derivatives) +2. Creates an Incus container with matching image +3. Maps your user (same UID/GID, sudo access) +4. Mounts the project directory at the same absolute path +5. Copies dotfiles (`.gitconfig`, `.ssh/`, `.bashrc`, `.profile`, `.bash_profile`) +6. Sets up X11/Wayland display passthrough with GPU +7. Runs `.igloo.sh` if it exists +8. Drops you into a shell -Each path listed will create a symlink: `~/` → `~/host/`. If a file doesn't exist on your host, it's silently skipped—no errors! +On subsequent runs, it just enters the existing container (starting it if stopped) and refreshes display passthrough. -Add more paths as needed: +## Installation -```ini -[symlinks] -paths = .gitconfig, .ssh, .bashrc, .profile, .bash_profile, .config/nvim, .vimrc -``` - -## 🎨 Flags & Options - -### igloo init - -```bash -igloo init --distro ubuntu --release noble # Use Ubuntu Noble -igloo init --distro fedora --release 43 # Use Fedora 43 -igloo init --name my-dev-box # Custom container name -igloo init --packages "go,nodejs,python3" # Pre-install packages -``` - -### igloo destroy - -```bash -igloo destroy # Remove container and .igloo directory -igloo destroy --keep-config # Keep .igloo directory for later -igloo destroy --force # Force remove without stopping -``` - -## 🗂️ Directory Layout (Inside the Container) - -``` -/home/youruser/ -├── host/ # Your host home directory -└── workspace/ - └── myproject/ # Your project directory (where you ran igloo init) -``` - -## 💡 Tips & Tricks - -### Run GUI Apps - -```bash -igloo enter -code . # VS Code just works! -firefox # Browse the web -``` - -### Use Your Host's Git Config +### Prerequisites -Your home directory is mounted, so `~/.gitconfig` is already available! +- [Incus](https://linuxcontainers.org/incus/docs/main/installing/) installed and configured +- Your user added to the `incus` group -### Quick Rebuild +### From Source ```bash -igloo destroy && igloo init # Fresh start in ~30 seconds +git clone https://github.com/frostyard/igloo.git +cd igloo +make build +sudo cp igloo /usr/local/bin/ ``` -### Multiple Projects, Multiple Igloos - -Each project directory can have its own igloo. They're completely isolated! +## Tips -## 🤝 Contributing - -Contributions are welcome! Feel free to open issues and pull requests. +- Each project directory gets its own container (`igloo-`) +- GUI apps just work inside the container (GTK, Qt, Firefox, VS Code, etc.) +- The `--no-gui` flag is useful when SSHed into the machine or running headless builds +- Multiple projects can each have their own igloo, completely isolated ## Credits -Igloo stands on the shoulders of giants: - -- **[Distrobox](https://github.com/89luca89/distrobox)** — The original inspiration for seamless container-based development environments -- **[Blincus](https://blincus.dev)** — blincus is igloo version 0. +- **[Distrobox](https://github.com/89luca89/distrobox)** -- The original inspiration +- **[Blincus](https://blincus.dev)** -- Blincus is igloo version 0 ## License -MIT License — build all the igloos you want! 🏔️ - ---- - -

- Stay frosty, friends! ❄️🐧 -

+MIT From 684a7bca36ccd462b2ba30fb86e71156a048f9e3 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Tue, 10 Feb 2026 15:10:10 -0500 Subject: [PATCH 11/11] chore: replace interface{} with any Co-Authored-By: Claude Opus 4.6 --- internal/incus/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/incus/client.go b/internal/incus/client.go index bbea716..f561d33 100644 --- a/internal/incus/client.go +++ b/internal/incus/client.go @@ -29,7 +29,7 @@ func (c *Client) InstanceExists(name string) (bool, error) { return false, err } - var instances []map[string]interface{} + var instances []map[string]any if err := json.Unmarshal(output, &instances); err != nil { return false, fmt.Errorf("failed to parse incus output: %w", err) }