diff --git a/.igloo/igloo.ini b/.igloo/igloo.ini index e4680fe..ac68abf 100644 --- a/.igloo/igloo.ini +++ b/.igloo/igloo.ini @@ -16,3 +16,6 @@ project = true [display] enabled = true gpu = true + +[symlinks] +paths = .gitconfig, .ssh, .config/nvim, .bashrc, .profile, .bash_profile \ No newline at end of file diff --git a/README.md b/README.md index ece4d8f..fe274e6 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,9 @@ project = true [display] enabled = true gpu = true + +[symlinks] +paths = .gitconfig, .ssh, .config/nvim ``` ### Init Scripts 📜 @@ -101,6 +104,26 @@ npm install -g yarn Scripts run in lexicographical order, so use numbered prefixes like `01-`, `02-`, etc. +### Symlinks 🔗 + +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! + +**Default symlinks** (created automatically with `igloo init`): + +```ini +[symlinks] +paths = .gitconfig, .ssh, .bashrc, .profile, .bash_profile +``` + +Each path listed will create a symlink: `~/` → `~/host/`. If a file doesn't exist on your host, it's silently skipped—no errors! + +Add more paths as needed: + +```ini +[symlinks] +paths = .gitconfig, .ssh, .bashrc, .profile, .bash_profile, .config/nvim, .vimrc +``` + ## 🎨 Flags & Options ### igloo init diff --git a/cmd/init.go b/cmd/init.go index 27e42f0..82a31da 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -104,6 +104,13 @@ func runInit(distro, release, name, packages string) error { Enabled: true, GPU: true, }, + Symlinks: []string{ + ".gitconfig", + ".ssh", + ".bashrc", + ".profile", + ".bash_profile", + }, } // Create .igloo directory and write config file diff --git a/cmd/provision.go b/cmd/provision.go index 0852290..0b2f5dc 100644 --- a/cmd/provision.go +++ b/cmd/provision.go @@ -79,6 +79,33 @@ func provisionContainer(cfg *config.IglooConfig) error { 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...")) diff --git a/cmd/status.go b/cmd/status.go index f20629d..2c77d15 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -117,5 +117,15 @@ func runStatus() error { } } + // 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/internal/config/config.go b/internal/config/config.go index ce4b644..df1270c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "gopkg.in/ini.v1" ) @@ -38,6 +39,7 @@ type IglooConfig struct { Packages PackagesConfig Mounts MountsConfig Display DisplayConfig + Symlinks []string // List of paths to symlink from ~/host/ to ~/ } // ContainerConfig holds container-specific settings @@ -89,6 +91,18 @@ func Load(path string) (*IglooConfig, error) { 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 } @@ -145,6 +159,18 @@ func Write(path string, config *IglooConfig) error { 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) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 12a9959..c07356f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -65,6 +65,55 @@ gpu = false 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) { diff --git a/internal/incus/client.go b/internal/incus/client.go index 8b75ecd..45076b6 100644 --- a/internal/incus/client.go +++ b/internal/incus/client.go @@ -180,6 +180,26 @@ func (c *Client) ExecAsRoot(name string, command ...string) error { return cmd.Run() } +// ExecAsUser runs a command in an instance as a specific user +func (c *Client) ExecAsUser(name, username string, command ...string) error { + uid := os.Getuid() + gid := os.Getgid() + + args := []string{ + "exec", name, + "--user", fmt.Sprintf("%d", uid), + "--group", fmt.Sprintf("%d", gid), + "--env", "HOME=/home/" + username, + "--env", "USER=" + username, + "--", + } + args = append(args, command...) + cmd := exec.Command("incus", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + // ExecInteractive runs an interactive shell in an instance func (c *Client) ExecInteractive(name, username, workDir string) error { uid := os.Getuid() @@ -193,7 +213,7 @@ func (c *Client) ExecInteractive(name, username, workDir string) error { "--env", "HOME=/home/" + username, "--env", "USER=" + username, "--env", "XAUTHORITY=/home/" + username + "/.Xauthority", - "--", "/bin/bash", "-l", + "--", "/bin/bash", "--login", "-i", } cmd := exec.Command("incus", args...)