diff --git a/.env-example b/.env-example index 9b92afd4..e33d2697 100644 --- a/.env-example +++ b/.env-example @@ -1,3 +1,5 @@ +# Git identity — prefer [git] section in ~/.config/devcell/devcell.toml instead. +# These env vars still work and take highest priority. GIT_AUTHOR_NAME="Your Name" GIT_AUTHOR_EMAIL="your@email.com" GIT_COMMITTER_NAME="Your Name" diff --git a/README.md b/README.md index 389366d0..b691fefa 100644 --- a/README.md +++ b/README.md @@ -132,11 +132,44 @@ image_tag = "latest-go" - [Slidev](https://sli.dev/) — Markdown presentations - [yt-dlp](https://github.com/yt-dlp/yt-dlp) +### Shell +- **zsh** with autosuggestions and syntax highlighting +- [Starship](https://starship.rs/) — cross-shell prompt (Rust, fast) + ### Electronics *(electronics / ultimate profiles)* - [KiCad](https://www.kicad.org/) — PCB design - ngspice — circuit simulation - OpenSCAD — parametric CAD +## Components + +The image is built from composable Nix home-manager modules (`nixhome/modules/`), assembled into profiles (`nixhome/profiles/`). + +| Module | Description | +|--------|-------------| +| `shell.nix` | zsh + Starship prompt, autosuggestions, syntax highlighting | +| `base.nix` | Universal utilities (git, ripgrep, tmux, jq, fonts) | +| `build.nix` | Native build toolchain (clang, cmake, lld) | +| `go.nix` | Go runtime (asdf) + tooling (golangci-lint, gopls) | +| `node.nix` | Node.js runtime (asdf) + yarn | +| `python.nix` | Python3 + uv package manager | +| `infra.nix` | Terraform, OpenTofu, packer | +| `web.nix` | Chromium + Hugo + Playwright | +| `desktop/` | X11/VNC desktop (fluxbox, x11vnc, xrdp, xterm) | +| `electronics.nix` | KiCad, ngspice, wokwi-cli | +| `graphics.nix` | Inkscape + inkscape-mcp | +| `nixos.nix` | Nix dev tools (deadnix, statix, nix-tree) | + +Modules are composed into profiles that map 1:1 to image tags: + +``` +base → base.nix +go → base + build + go + apple + infra +node → base + node + web +fullstack → go + node + python + web +ultimate → fullstack + desktop + electronics + graphics + nixos +``` + ## VNC When `gui = true` in your config, the container runs Xvfb + VNC. Connect with any VNC client or use: diff --git a/cmd/behavior_test.go b/cmd/behavior_test.go index 197a792e..96861619 100644 --- a/cmd/behavior_test.go +++ b/cmd/behavior_test.go @@ -116,21 +116,20 @@ func TestScenarioA_RDPPortPublished(t *testing.T) { } } -// GUI=true mounts xrdp cert dir from global config -func TestScenarioA_XrdpCertVolume(t *testing.T) { - guiCfg := cfg.CellConfig{Cell: cfg.CellSection{GUI: true}} +// Config dir is always mounted at /etc/devcell/config +func TestScenarioA_ConfigDirVolume(t *testing.T) { argv := buildBehaviourArgv("/tmp/myproject", []string{"TMUX_PANE", "%3"}, - "claude", nil, nil, guiCfg) + "claude", nil, nil, cfg.CellConfig{}) found := false for _, a := range argv { - if strings.Contains(a, "/xrdp:/etc/devcell/xrdp") { + if strings.Contains(a, ":/etc/devcell/config") { found = true break } } if !found { - t.Errorf("expected xrdp cert volume mount: %v", argv) + t.Errorf("expected config dir volume mount: %v", argv) } } diff --git a/cmd/shell.go b/cmd/shell.go index c22d6c15..4c5596a8 100644 --- a/cmd/shell.go +++ b/cmd/shell.go @@ -5,7 +5,7 @@ import "github.com/spf13/cobra" var shellCmd = &cobra.Command{ Use: "shell [-- command [args...]]", Short: "Open an interactive shell in a devcell container", - Long: `Opens an interactive bash shell inside a devcell container. + Long: `Opens an interactive zsh shell inside a devcell container. The current working directory is mounted as /workspace. Optionally pass a command after -- to run it non-interactively instead of starting a shell. @@ -25,9 +25,9 @@ Examples: if len(rest) > 0 { return runAgent(rest[0], nil, append(cellFlags, rest[1:]...), nil) } - return runAgent("bash", nil, cellFlags, nil) + return runAgent("zsh", nil, cellFlags, nil) } } - return runAgent("bash", nil, args, nil) + return runAgent("zsh", nil, args, nil) }, } diff --git a/cmd/utility_test.go b/cmd/utility_test.go index b5dc0b60..2e57df64 100644 --- a/cmd/utility_test.go +++ b/cmd/utility_test.go @@ -10,10 +10,10 @@ import ( // --- shell --- func TestShell_NoBinaryArgs(t *testing.T) { - argv := buildTestArgv("bash", nil, nil) + argv := buildTestArgv("zsh", nil, nil) tail := trailingAfterImage(argv) - if len(tail) == 0 || tail[0] != "bash" { - t.Errorf("expected bash at end, got: %v", tail) + if len(tail) == 0 || tail[0] != "zsh" { + t.Errorf("expected zsh at end, got: %v", tail) } if len(tail) != 1 { t.Errorf("expected no extra args for plain shell, got: %v", tail) @@ -21,27 +21,27 @@ func TestShell_NoBinaryArgs(t *testing.T) { } func TestShell_WithPassthroughArgs(t *testing.T) { - argv := buildTestArgv("bash", nil, []string{"ls", "-la"}) + argv := buildTestArgv("zsh", nil, []string{"ls", "-la"}) tail := trailingAfterImage(argv) joined := strings.Join(tail, " ") - if joined != "bash ls -la" { - t.Errorf("expected 'bash ls -la', got: %q", joined) + if joined != "zsh ls -la" { + t.Errorf("expected 'zsh ls -la', got: %q", joined) } } func TestShell_WithPythonScript(t *testing.T) { - argv := buildTestArgv("bash", nil, []string{"python3", "script.py"}) + argv := buildTestArgv("zsh", nil, []string{"python3", "script.py"}) tail := trailingAfterImage(argv) joined := strings.Join(tail, " ") - if joined != "bash python3 script.py" { - t.Errorf("expected 'bash python3 script.py', got: %q", joined) + if joined != "zsh python3 script.py" { + t.Errorf("expected 'zsh python3 script.py', got: %q", joined) } } func TestShell_NoDefaultFlags(t *testing.T) { - argv := buildTestArgv("bash", nil, nil) + argv := buildTestArgv("zsh", nil, nil) tail := trailingAfterImage(argv) - // bash must be the only item (no injected flags) + // zsh must be the only item (no injected flags) if len(tail) != 1 { t.Errorf("shell should have no default flags, got tail: %v", tail) } diff --git a/images/entrypoint.sh b/images/entrypoint.sh index aeedec93..d902221b 100755 --- a/images/entrypoint.sh +++ b/images/entrypoint.sh @@ -47,7 +47,7 @@ if [ -S /var/run/docker.sock ]; then fi mkdir -p "$HOME/.local/bin" "$HOME/tmp" -chown "$HOST_USER" "$HOME/.local/bin" "$HOME/tmp" +chown "$HOST_USER" "$HOME/.local" "$HOME/.local/bin" "$HOME/tmp" # ── Give session user access to devcell's nix environment ───────────────────── # nix.sh resolves $HOME/.nix-profile to find nix tools. Create a symlink so @@ -164,7 +164,18 @@ setup_asdf_home if [ -d "$DEVCELL_HOME/.config/nix" ] && [ ! -d "$HOME/.config/nix" ]; then mkdir -p "$HOME/.config" cp -r "$DEVCELL_HOME/.config/nix" "$HOME/.config/" - chown -R "$HOST_USER" "$HOME/.config/nix" + chown -R "$HOST_USER" "$HOME/.config" +fi + +# ── Copy starship config — project homedir wins over nix default ───────────── +if [ -f "$REPO_HOMEDIR/.config/starship.toml" ]; then + mkdir -p "$HOME/.config" + cp "$REPO_HOMEDIR/.config/starship.toml" "$HOME/.config/" + chown "$HOST_USER" "$HOME/.config/starship.toml" +elif [ -f "$DEVCELL_HOME/.config/starship.toml" ]; then + mkdir -p "$HOME/.config" + cp "$DEVCELL_HOME/.config/starship.toml" "$HOME/.config/" + chown "$HOST_USER" "$HOME/.config/starship.toml" fi # ── Copy from repo's homedir/ (project-specific overrides) ─────────────────── @@ -207,6 +218,7 @@ merge_claude_nix() { mkdir -p "$HOME/.claude/hooks" cp -r "$nix_hooks_dir/"* "$HOME/.claude/hooks/" 2>/dev/null || true find "$HOME/.claude/hooks" -type f -name "*.sh" -exec chmod +x {} \; 2>/dev/null || true + chown -R "$HOST_USER" "$HOME/.claude" log "✓ Claude hooks installed from nix" fi if [ -f "$nix_settings" ]; then diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index 31d0bbdd..737853c1 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -41,10 +41,41 @@ type ClaudeSection struct { UseOllama bool `toml:"use_ollama"` } +// GitSection holds [git] config for git identity inside the container. +type GitSection struct { + AuthorName string `toml:"author_name"` + AuthorEmail string `toml:"author_email"` + CommitterName string `toml:"committer_name"` + CommitterEmail string `toml:"committer_email"` +} + +// HasIdentity reports whether any git identity field is set. +func (g GitSection) HasIdentity() bool { + return g.AuthorName != "" || g.AuthorEmail != "" || + g.CommitterName != "" || g.CommitterEmail != "" +} + +// ResolvedCommitterName returns CommitterName if set, else falls back to AuthorName. +func (g GitSection) ResolvedCommitterName() string { + if g.CommitterName != "" { + return g.CommitterName + } + return g.AuthorName +} + +// ResolvedCommitterEmail returns CommitterEmail if set, else falls back to AuthorEmail. +func (g GitSection) ResolvedCommitterEmail() string { + if g.CommitterEmail != "" { + return g.CommitterEmail + } + return g.AuthorEmail +} + // CellConfig is the merged configuration from all TOML layers. type CellConfig struct { Cell CellSection Claude ClaudeSection + Git GitSection `toml:"git"` Env map[string]string Asdf map[string]string // [asdf] — keys map to ASDF_ env vars Volumes []VolumeMount @@ -109,6 +140,21 @@ func Merge(global, project CellConfig) CellConfig { out.Claude.UseOllama = true } + // Git: project wins when non-zero + out.Git = global.Git + if project.Git.AuthorName != "" { + out.Git.AuthorName = project.Git.AuthorName + } + if project.Git.AuthorEmail != "" { + out.Git.AuthorEmail = project.Git.AuthorEmail + } + if project.Git.CommitterName != "" { + out.Git.CommitterName = project.Git.CommitterName + } + if project.Git.CommitterEmail != "" { + out.Git.CommitterEmail = project.Git.CommitterEmail + } + // Slices accumulate: global first, then project out.Volumes = append(global.Volumes, project.Volumes...) diff --git a/internal/cfg/cfg_test.go b/internal/cfg/cfg_test.go index 6677fa43..9296b3f1 100644 --- a/internal/cfg/cfg_test.go +++ b/internal/cfg/cfg_test.go @@ -378,3 +378,97 @@ func TestMerge_ClaudeGlobalKeptWhenProjectUnset(t *testing.T) { t.Error("expected global use_ollama=true to be preserved when project unset") } } + +// --- Git section --- + +func TestLoadFile_GitSection(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[git] +author_name = "Alice" +author_email = "alice@example.com" +committer_name = "Bob" +committer_email = "bob@example.com" +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.Git.AuthorName != "Alice" { + t.Errorf("author_name: want Alice, got %q", c.Git.AuthorName) + } + if c.Git.AuthorEmail != "alice@example.com" { + t.Errorf("author_email: want alice@example.com, got %q", c.Git.AuthorEmail) + } + if c.Git.CommitterName != "Bob" { + t.Errorf("committer_name: want Bob, got %q", c.Git.CommitterName) + } + if c.Git.CommitterEmail != "bob@example.com" { + t.Errorf("committer_email: want bob@example.com, got %q", c.Git.CommitterEmail) + } +} + +func TestLoadFile_GitDefaultsEmpty(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", `[cell]`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if c.Git.HasIdentity() { + t.Error("expected no git identity when [git] not set") + } +} + +func TestMerge_GitProjectWins(t *testing.T) { + global := cfg.CellConfig{Git: cfg.GitSection{AuthorName: "Global", AuthorEmail: "global@test.com"}} + project := cfg.CellConfig{Git: cfg.GitSection{AuthorName: "Project"}} + merged := cfg.Merge(global, project) + if merged.Git.AuthorName != "Project" { + t.Errorf("want Project, got %q", merged.Git.AuthorName) + } + if merged.Git.AuthorEmail != "global@test.com" { + t.Errorf("email should be preserved from global, got %q", merged.Git.AuthorEmail) + } +} + +func TestMerge_GitGlobalKeptWhenProjectUnset(t *testing.T) { + global := cfg.CellConfig{Git: cfg.GitSection{AuthorName: "Global", AuthorEmail: "global@test.com"}} + project := cfg.CellConfig{} + merged := cfg.Merge(global, project) + if merged.Git.AuthorName != "Global" { + t.Errorf("want Global, got %q", merged.Git.AuthorName) + } +} + +func TestGitSection_HasIdentity(t *testing.T) { + if (cfg.GitSection{}).HasIdentity() { + t.Error("empty GitSection should not have identity") + } + if !(cfg.GitSection{AuthorEmail: "a@b.com"}).HasIdentity() { + t.Error("GitSection with author_email should have identity") + } +} + +func TestGitSection_CommitterDefaultsToAuthor(t *testing.T) { + g := cfg.GitSection{AuthorName: "Alice", AuthorEmail: "alice@test.com"} + if g.ResolvedCommitterName() != "Alice" { + t.Errorf("want Alice, got %q", g.ResolvedCommitterName()) + } + if g.ResolvedCommitterEmail() != "alice@test.com" { + t.Errorf("want alice@test.com, got %q", g.ResolvedCommitterEmail()) + } +} + +func TestGitSection_ExplicitCommitterOverridesAuthor(t *testing.T) { + g := cfg.GitSection{ + AuthorName: "Alice", AuthorEmail: "alice@test.com", + CommitterName: "Bot", CommitterEmail: "bot@ci.com", + } + if g.ResolvedCommitterName() != "Bot" { + t.Errorf("want Bot, got %q", g.ResolvedCommitterName()) + } + if g.ResolvedCommitterEmail() != "bot@ci.com" { + t.Errorf("want bot@ci.com, got %q", g.ResolvedCommitterEmail()) + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index c1946e1b..cd155be9 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -67,6 +67,14 @@ type RunSpec struct { Debug bool // pass DEVCELL_DEBUG=true into the container Image string // image ID or tag to run; defaults to UserImageTag ExtraEnv map[string]string // additional env vars injected by the command handler + Getenv func(string) string // env lookup; defaults to os.Getenv when nil +} + +func (s RunSpec) getenv(key string) string { + if s.Getenv != nil { + return s.Getenv(key) + } + return os.Getenv(key) } // BuildArgv constructs the full docker run argv for the given spec. @@ -104,10 +112,40 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str e("HISTFILE", "/home/"+c.HostUser+"/zsh_history_"+c.AppName) e("TMPDIR", "/home/"+c.HostUser+"/tmp") e("CODEX_OSS_BASE_URL", envOrDefault("CODEX_OSS_BASE_URL", "http://host.docker.internal:1234/v1")) - e("GIT_AUTHOR_NAME", envOrDefault("GIT_AUTHOR_NAME", "DevCell")) - e("GIT_AUTHOR_EMAIL", envOrDefault("GIT_AUTHOR_EMAIL", "devcell@devcell.io")) - e("GIT_COMMITTER_NAME", envOrDefault("GIT_COMMITTER_NAME", "DevCell")) - e("GIT_COMMITTER_EMAIL", envOrDefault("GIT_COMMITTER_EMAIL", "devcell@devcell.io")) + + // Volume mount helper (defined early for use in git identity fallback) + v := func(mount string) { argv = append(argv, "-v", mount) } + + // Git identity: host env > [git] toml > mount ~/.config/git/config:ro > hardcoded defaults + gitCfg := spec.CellCfg.Git + hostGitEnv := spec.getenv("GIT_AUTHOR_NAME") != "" || + spec.getenv("GIT_AUTHOR_EMAIL") != "" || + spec.getenv("GIT_COMMITTER_NAME") != "" || + spec.getenv("GIT_COMMITTER_EMAIL") != "" + + if hostGitEnv { + e("GIT_AUTHOR_NAME", envOrDefaultFn(spec.getenv, "GIT_AUTHOR_NAME", "DevCell")) + e("GIT_AUTHOR_EMAIL", envOrDefaultFn(spec.getenv, "GIT_AUTHOR_EMAIL", "devcell@devcell.io")) + e("GIT_COMMITTER_NAME", envOrDefaultFn(spec.getenv, "GIT_COMMITTER_NAME", "DevCell")) + e("GIT_COMMITTER_EMAIL", envOrDefaultFn(spec.getenv, "GIT_COMMITTER_EMAIL", "devcell@devcell.io")) + } else if gitCfg.HasIdentity() { + e("GIT_AUTHOR_NAME", gitCfg.AuthorName) + e("GIT_AUTHOR_EMAIL", gitCfg.AuthorEmail) + e("GIT_COMMITTER_NAME", gitCfg.ResolvedCommitterName()) + e("GIT_COMMITTER_EMAIL", gitCfg.ResolvedCommitterEmail()) + } else { + gitConfigDir := filepath.Join(c.HostHome, ".config", "git") + gitConfigFile := filepath.Join(gitConfigDir, "config") + if err := fs.Stat(gitConfigFile); err == nil { + v(gitConfigDir + ":/etc/devcell/git:ro") + e("GIT_CONFIG_GLOBAL", "/etc/devcell/git/config") + } else { + e("GIT_AUTHOR_NAME", "DevCell") + e("GIT_AUTHOR_EMAIL", "devcell@devcell.io") + e("GIT_COMMITTER_NAME", "DevCell") + e("GIT_COMMITTER_EMAIL", "devcell@devcell.io") + } + } // Optional .env file envFile := filepath.Join(c.BaseDir, ".env") @@ -143,7 +181,6 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str } // Standard volumes - v := func(mount string) { argv = append(argv, "-v", mount) } v(c.BaseDir + ":" + c.BaseDir) v(c.BaseDir + ":/" + c.AppName) v(c.CellHome + ":/home/" + c.HostUser) @@ -151,15 +188,15 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str v(c.HostHome + "/.claude/commands:/home/" + c.HostUser + "/.claude/commands:ro") v(c.HostHome + "/.claude/agents:/home/" + c.HostUser + "/.claude/agents:ro") v(c.HostHome + "/.claude/skills:/home/" + c.HostUser + "/.claude/skills") + v(c.ConfigDir + ":/etc/devcell/config") // cfg [[volumes]] entries for _, vol := range spec.CellCfg.Volumes { argv = append(argv, "-v", vol.Mount) } - // GUI volumes and port mapping + // GUI port mapping if spec.CellCfg.Cell.GUI { - v(c.ConfigDir + "/xrdp:/etc/devcell/xrdp") argv = append(argv, "-p", c.VNCPort+":5900") argv = append(argv, "-p", c.RDPPort+":3389") } @@ -306,3 +343,10 @@ func envOrDefault(key, def string) string { } return def } + +func envOrDefaultFn(getenv func(string) string, key, def string) string { + if v := getenv(key); v != "" { + return v + } + return def +} diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index ff94fd07..5a8cf35a 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -333,6 +333,83 @@ func TestArgv_GUIDisabledByDefault(t *testing.T) { } } +// --- Git identity --- + +func TestArgv_GitEnvVarsFromHostEnv(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.Getenv = func(k string) string { + m := map[string]string{ + "GIT_AUTHOR_NAME": "EnvAlice", + "GIT_AUTHOR_EMAIL": "env@alice.com", + } + return m[k] + } + s.CellCfg.Git = cfg.GitSection{ + AuthorName: "TomlBob", AuthorEmail: "toml@bob.com", + } + }) + if !hasArg(argv, "GIT_AUTHOR_NAME=EnvAlice") { + t.Errorf("expected GIT_AUTHOR_NAME=EnvAlice: %v", argv) + } + if !hasArg(argv, "GIT_AUTHOR_EMAIL=env@alice.com") { + t.Errorf("expected GIT_AUTHOR_EMAIL=env@alice.com: %v", argv) + } +} + +func TestArgv_GitEnvVarsFromToml(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.Getenv = func(string) string { return "" } + s.CellCfg.Git = cfg.GitSection{ + AuthorName: "Alice", AuthorEmail: "alice@test.com", + } + }) + if !hasArg(argv, "GIT_AUTHOR_NAME=Alice") { + t.Errorf("expected GIT_AUTHOR_NAME=Alice: %v", argv) + } + if !hasArg(argv, "GIT_COMMITTER_NAME=Alice") { + t.Errorf("expected GIT_COMMITTER_NAME=Alice (defaulted from author): %v", argv) + } + if !hasArg(argv, "GIT_COMMITTER_EMAIL=alice@test.com") { + t.Errorf("expected GIT_COMMITTER_EMAIL=alice@test.com (defaulted from author): %v", argv) + } +} + +func TestArgv_GitConfigMounted(t *testing.T) { + // fs.Stat checks the file; mount is the parent directory (avoids Docker mkdir-file bug) + gitConfigFile := "/home/bob/.config/git/config" + spec := runner.RunSpec{ + Config: baseConfig(), + CellCfg: cfg.CellConfig{}, + Binary: "bash", + Getenv: func(string) string { return "" }, + } + argv := runner.BuildArgv(spec, existFS(gitConfigFile), noopLookPath) + expectedMount := "/home/bob/.config/git:/etc/devcell/git:ro" + if !hasConsecutive(argv, "-v", expectedMount) { + t.Errorf("expected git config dir mount %q: %v", expectedMount, argv) + } + if !hasArg(argv, "GIT_CONFIG_GLOBAL=/etc/devcell/git/config") { + t.Errorf("expected GIT_CONFIG_GLOBAL env var: %v", argv) + } + for _, a := range argv { + if strings.HasPrefix(a, "GIT_AUTHOR_NAME=") { + t.Errorf("should not inject GIT_AUTHOR_NAME when mounting config: %v", argv) + } + } +} + +func TestArgv_GitFallbackDefaults(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.Getenv = func(string) string { return "" } + }) + if !hasArg(argv, "GIT_AUTHOR_NAME=DevCell") { + t.Errorf("expected hardcoded fallback GIT_AUTHOR_NAME=DevCell: %v", argv) + } + if !hasArg(argv, "GIT_COMMITTER_EMAIL=devcell@devcell.io") { + t.Errorf("expected hardcoded fallback GIT_COMMITTER_EMAIL: %v", argv) + } +} + func min(a, b int) int { if a < b { return a diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index 4df881b2..f8cbdd42 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -24,6 +24,9 @@ var flakeNixContent []byte //go:embed templates/devcell.toml.tmpl var devcellTomlContent []byte +//go:embed templates/starship.toml.tmpl +var starshipTomlContent []byte + //go:embed templates/Vagrantfile.tmpl var vagrantfileContent []byte @@ -120,6 +123,18 @@ func Scaffold(dir string, modelsSnippet string) error { } } + // Scaffold homedir/.config/starship.toml for per-project prompt customization. + starshipDir := filepath.Join(dir, "homedir", ".config") + starshipDest := filepath.Join(starshipDir, "starship.toml") + if _, err := os.Stat(starshipDest); err != nil { + if err := os.MkdirAll(starshipDir, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", starshipDir, err) + } + if err := os.WriteFile(starshipDest, starshipTomlContent, 0644); err != nil { + return fmt.Errorf("write homedir starship.toml: %w", err) + } + } + // Generate package files from devcell.toml [packages] config. c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) if err != nil { diff --git a/internal/scaffold/templates/devcell.toml.tmpl b/internal/scaffold/templates/devcell.toml.tmpl index 6d8f6fe6..2297836e 100644 --- a/internal/scaffold/templates/devcell.toml.tmpl +++ b/internal/scaffold/templates/devcell.toml.tmpl @@ -2,7 +2,9 @@ # Global DevCell configuration. Project-level overrides go in /.devcell.toml [cell] -# Override the default image tag (default: latest-ultimate) +# Override the default image tag. +# Available tags: latest-base, latest-go, latest-node, latest-python, +# latest-electronics, latest-fullstack, latest-ultimate (default) # image_tag = "latest-go" # Enable GUI (Xvfb + VNC + browser). Injects DEVCELL_GUI_ENABLED=true. gui = true @@ -10,20 +12,33 @@ gui = true [claude] # Route Claude Code through a local ollama instance (Anthropic API compat). # Equivalent to: cell claude --ollama -# use_ollama = true +# use_ollama = false + +# [git] +# Git identity forwarded into the container. +# If omitted and no GIT_AUTHOR_* env vars are set, ~/.config/git/config is mounted read-only instead. +# author_name = "Your Name" +# author_email = "you@example.com" +# committer_name = "Your Name" # defaults to author_name if omitted +# committer_email = "you@example.com" # defaults to author_email if omitted [env] # Extra environment variables forwarded into the container. -# MY_TOKEN = "abc123" +# GITHUB_TOKEN = "ghp_xxx" +# ANTHROPIC_API_KEY = "sk-ant-xxx" [asdf] # ASDF runtime settings forwarded as ASDF_ env vars. # See: https://asdf-vm.com/manage/configuration.html -golang_mod_version_enabled = "true" +# golang_mod_version_enabled = "true" +# default_tool_versions_filename = ".tool-versions" # [[volumes]] # Extra volume mounts appended to docker run. # mount = "~/work/secrets:/run/secrets:ro" +# +# [[volumes]] +# mount = "~/.ssh:/home/user/.ssh:ro" {{MODELS_SECTION}} @@ -40,3 +55,5 @@ golang_mod_version_enabled = "true" [packages.python] # Python packages installed in the container. Edit and run 'cell build'. "pre-commit" = "*" +# "httpie" = "*" +# "pipx" = "*" diff --git a/internal/scaffold/templates/starship.toml.tmpl b/internal/scaffold/templates/starship.toml.tmpl new file mode 100644 index 00000000..a4f86070 --- /dev/null +++ b/internal/scaffold/templates/starship.toml.tmpl @@ -0,0 +1,30 @@ +# Starship prompt configuration — https://starship.rs/config/ +# This file is scaffolded by `cell init` into homedir/.config/starship.toml +# and copied into the container at ~/.config/starship.toml by the entrypoint. +# Edit to customize your prompt per-project; run `cell build` is NOT needed. + +format = "$directory$git_branch$git_status$character" +right_format = "$cmd_duration" +add_newline = false + +[character] +success_symbol = "[•](bold green)" +error_symbol = "[•](bold red)" + +[directory] +style = "bold cyan" +truncation_length = 3 +truncate_to_repo = true + +[git_branch] +format = "[$branch]($style) " +style = "bold purple" + +[git_status] +format = "([$all_status$ahead_behind]($style) )" +style = "bold yellow" + +[cmd_duration] +min_time = 2_000 +format = "[$duration]($style)" +style = "bold yellow" diff --git a/nixhome/modules/base.nix b/nixhome/modules/base.nix index 9f9f2645..982d468e 100644 --- a/nixhome/modules/base.nix +++ b/nixhome/modules/base.nix @@ -1,6 +1,7 @@ # base.nix — utilities present in every profile {pkgs, ...}: { imports = [ + ./shell.nix ./entrypoint.nix ./managed-mcp.nix ./managed-claude.nix diff --git a/nixhome/modules/desktop/default.nix b/nixhome/modules/desktop/default.nix index c8c78589..7c3dd5bf 100644 --- a/nixhome/modules/desktop/default.nix +++ b/nixhome/modules/desktop/default.nix @@ -22,7 +22,7 @@ {pkgs, lib, ...}: let # Import theme — palette (c), fonts (f), and generated fluxbox cfg. - theme = import ./theme.nix { inherit lib; }; + theme = import ./themes/main/theme.nix { inherit lib pkgs; }; inherit (theme) c f cfg init xresources wallpaper pixmaps; in { @@ -74,6 +74,7 @@ in fontconfig # font configuration (libfontconfig) freetype # font rendering (libfreetype6) libGL # OpenGL (libegl1-mesa / libgl1-mesa) + mesa # Mesa 3D — provides llvmpipe software rasterizer for GLX on Xvfb glew # OpenGL extension library (libglew2.2) libGLU # OpenGL utility library (libglu1-mesa) libtiff # TIFF image library (libtiff5) @@ -84,7 +85,16 @@ in # Fonts — required for Chromium and other GUI apps noto-fonts dejavu_fonts - jetbrains-mono # neobrutalist UI font — used by fluxbox theme and xterm + nerd-fonts.jetbrains-mono # neobrutalist UI font — fluxbox theme and xterm + nerd-fonts.fira-code # popular ligature font + nerd-fonts.hack # clean monospace + nerd-fonts.meslo-lg # macOS Terminal default derivative + nerd-fonts.caskaydia-cove # Cascadia Code Nerd Font + nerd-fonts.sauce-code-pro # Source Code Pro Nerd Font + nerd-fonts.ubuntu-mono # Ubuntu monospace + nerd-fonts.roboto-mono # Google monospace + nerd-fonts.iosevka # narrow monospace + nerd-fonts.victor-mono # cursive italic monospace inter # geometric sans — fallback UI font # Playwright MCP wrapper — sets per-app user-data-dir and forwards secrets @@ -173,17 +183,101 @@ in # so the compat link /nix/var/nix/profiles/per-user/$USER/profile is used correctly # regardless of which username the container runs as. menu = '' - [begin] ( [*.] devcell ) - [submenu] ( Applications ) + [begin] ([*.] devcell) + [submenu] (Applications) [exec] (Chromium) {sh -c 'chromium &'} [end] - [exec] (Terminal) {${pkgs.xterm}/bin/xterm} + [exec] (Kitty) {${pkgs.kitty}/bin/kitty} + [exec] (XTerm) {${pkgs.xterm}/bin/xterm} [separator] [exit] (Exit Fluxbox) [end] ''; }; + # ── Kitty terminal — GPU-accelerated with software fallback ─────────── + programs.kitty = { + enable = true; + font = { + name = "JetBrainsMono Nerd Font"; + size = 11; + }; + settings = { + # ── Colors — neobrutalist palette from theme.nix ── + background = c.surface; + foreground = "#e0f0ff"; + cursor = c.accent; + cursor_text_color = c.surface; + selection_background = "#334455"; + selection_foreground = "#ffffff"; + url_color = c.accent; + url_style = "curly"; + + # ── Window borders (kitty splits, not WM) ── + active_border_color = c.accent; + inactive_border_color = c.inactive; + bell_border_color = c.close; + window_border_width = "1px"; + + # ── Window chrome ── + window_padding_width = 8; + hide_window_decorations = false; + + # ── Rendering — software fallback for containers without GPU ── + linux_display_server = "x11"; + + # ── Bell ── + enable_audio_bell = false; + visual_bell_duration = "0.15"; + visual_bell_color = c.raised; + + # ── Scrollback ── + scrollback_lines = 10000; + + # ── Opacity ── + dim_opacity = "0.7"; + inactive_text_alpha = "0.8"; + + # ── Tab bar — powerline style matching toolbar ── + tab_bar_edge = "bottom"; + tab_bar_style = "powerline"; + tab_powerline_style = "slanted"; + tab_bar_background = c.border; + active_tab_background = c.accent; + active_tab_foreground = c.border; + active_tab_font_style = "bold"; + inactive_tab_background = c.raised; + inactive_tab_foreground = c.inactive; + inactive_tab_font_style = "normal"; + + # ── Marks (ctrl+shift+1/2/3 to highlight patterns) ── + mark1_background = c.accent; + mark1_foreground = c.border; + mark2_background = c.highlight; + mark2_foreground = c.border; + mark3_background = c.close; + mark3_foreground = c.textBright; + + # ── Terminal colors (same as Xresources) ── + color0 = c.surface; + color1 = "#ff5555"; + color2 = c.highlight; + color3 = "#f1fa8c"; + color4 = "#2e86c1"; + color5 = "#bd93f9"; + color6 = c.accent; + color7 = "#bfbfbf"; + color8 = "#555577"; + color9 = "#ff6e6e"; + color10 = "#c8f346"; + color11 = "#ffffa5"; + color12 = "#5dade2"; + color13 = "#d6bcfa"; + color14 = "#48d1b5"; + color15 = c.textBright; + }; + }; + # ── Theme file deployment ───────────────────────────────────────────────── # All visual assets: wallpaper, Xresources, fluxbox style + overlay, button pixmaps. home.file = { @@ -194,6 +288,7 @@ in ".fluxbox/apps".text = '' [app] (name=.*) [Tab] {no} + [Deco] {1087} [end] ''; # ── Entrypoint fragment: GUI service startup ──────────────────────────── @@ -207,14 +302,23 @@ in [ "$DEVCELL_GUI_ENABLED" = "true" ] || return 0 + # Ensure DBUS machine-id exists (Kitty/GTK apps need it) + [ -f /etc/machine-id ] || dbus-uuidgen > /etc/machine-id 2>/dev/null || true + DISPLAY_NUM=99 RESOLUTION=1920x1080x24 mkdir -p /tmp/.X11-unix chmod 1777 /tmp/.X11-unix - log "Starting Xvfb on display :''${DISPLAY_NUM}..." - gosu "$USER" Xvfb :''${DISPLAY_NUM} -screen 0 ''${RESOLUTION} 2>/dev/null & + # Mesa llvmpipe software rendering — enables GLX for GPU terminals (Kitty etc.) + export LIBGL_ALWAYS_SOFTWARE=1 + export GALLIUM_DRIVER=llvmpipe + export LIBGL_DRIVERS_PATH=${pkgs.mesa}/lib/dri + export LD_LIBRARY_PATH=${pkgs.mesa}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} + + log "Starting Xvfb on display :''${DISPLAY_NUM} (+GLX, Mesa llvmpipe)..." + gosu "$USER" Xvfb :''${DISPLAY_NUM} -screen 0 ''${RESOLUTION} +extension GLX +render +iglx 2>/dev/null & export DISPLAY=:''${DISPLAY_NUM} # Wait for X server to accept connections (socket file appears before server is ready) for i in $(seq 1 40); do @@ -223,9 +327,12 @@ in done # Load X resources (xterm dark theme, cursor color, fonts) - # Run as root — X resource database is per-display, not per-user. + # Deferred via background process: xrdb ChangeProperty requests sent from + # the entrypoint's PID 1 context are silently dropped by Xvfb. Running + # xrdb from a detached process after exec gosu replaces PID 1 works. if [ -f "$DEVCELL_HOME/.Xresources" ]; then - xrdb -display :''${DISPLAY_NUM} -merge "$DEVCELL_HOME/.Xresources" 2>/dev/null || true + (sleep 1; xrdb -display :''${DISPLAY_NUM} -merge "$DEVCELL_HOME/.Xresources" 2>/dev/null) & + disown fi if [ -f "$DEVCELL_HOME/.fluxbox/wallpaper.png" ]; then @@ -274,8 +381,8 @@ in chmod u+w "$XRDP_CFG/"* 2>/dev/null || true # Generate self-signed SSL cert in global config dir - # (survives container restarts via ~/.config/devcell/xrdp/ bind mount) - XRDP_CERT_DIR="/etc/devcell/xrdp" + # (survives container restarts via ~/.config/devcell/ bind mount at /etc/devcell/config/) + XRDP_CERT_DIR="/etc/devcell/config/xrdp" mkdir -p "$XRDP_CERT_DIR" if [ ! -f "$XRDP_CERT_DIR/key.pem" ]; then openssl req -x509 -newkey rsa:2048 -nodes \ diff --git a/nixhome/modules/desktop/pixmaps/close.xpm b/nixhome/modules/desktop/pixmaps/close.xpm deleted file mode 100644 index ea0a5050..00000000 --- a/nixhome/modules/desktop/pixmaps/close.xpm +++ /dev/null @@ -1,37 +0,0 @@ -/* XPM */ -static char *close_xpm[] = { -"30 30 3 1", -" c None", -". c #E85D26", -"x c #9B3E19", -" ", -" ", -" .......... ", -" .............. ", -" ................ ", -" .................... ", -" .................... ", -" ...................... ", -" .....x............x..... ", -" ......x..........x...... ", -" ........x........x........ ", -" .........x......x......... ", -" ..........x....x.......... ", -" ...........x..x........... ", -" ............xx............ ", -" ............xx............ ", -" ...........x..x........... ", -" ..........x....x.......... ", -" .........x......x......... ", -" ........x........x........ ", -" ......x..........x...... ", -" .....x............x..... ", -" ...................... ", -" .................... ", -" .................... ", -" ................ ", -" .............. ", -" .......... ", -" ", -" " -}; diff --git a/nixhome/modules/desktop/pixmaps/close_unfocus.xpm b/nixhome/modules/desktop/pixmaps/close_unfocus.xpm deleted file mode 100644 index d392101c..00000000 --- a/nixhome/modules/desktop/pixmaps/close_unfocus.xpm +++ /dev/null @@ -1,36 +0,0 @@ -/* XPM */ -static char *close_unfocus_xpm[] = { -"30 30 2 1", -" c None", -". c #445566", -" ", -" ", -" .......... ", -" .............. ", -" ................ ", -" .................... ", -" .................... ", -" ...................... ", -" ........................ ", -" ........................ ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" ........................ ", -" ........................ ", -" ...................... ", -" .................... ", -" .................... ", -" ................ ", -" .............. ", -" .......... ", -" ", -" " -}; diff --git a/nixhome/modules/desktop/pixmaps/max.xpm b/nixhome/modules/desktop/pixmaps/max.xpm deleted file mode 100644 index 27e441ea..00000000 --- a/nixhome/modules/desktop/pixmaps/max.xpm +++ /dev/null @@ -1,37 +0,0 @@ -/* XPM */ -static char *max_xpm[] = { -"30 30 3 1", -" c None", -". c #1abc9c", -"x c #128068", -" ", -" ", -" .......... ", -" .............. ", -" ................ ", -" .................... ", -" .................... ", -" ...................... ", -" ........................ ", -" ......xxx............... ", -" .......xx................. ", -" .......x.................. ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .................x........ ", -" ................xx........ ", -" ...............xxx........ ", -" ........................ ", -" ........................ ", -" ...................... ", -" .................... ", -" .................... ", -" ................ ", -" .............. ", -" .......... ", -" ", -" " -}; diff --git a/nixhome/modules/desktop/pixmaps/max_unfocus.xpm b/nixhome/modules/desktop/pixmaps/max_unfocus.xpm deleted file mode 100644 index c1734bd4..00000000 --- a/nixhome/modules/desktop/pixmaps/max_unfocus.xpm +++ /dev/null @@ -1,36 +0,0 @@ -/* XPM */ -static char *max_unfocus_xpm[] = { -"30 30 2 1", -" c None", -". c #445566", -" ", -" ", -" .......... ", -" .............. ", -" ................ ", -" .................... ", -" .................... ", -" ...................... ", -" ........................ ", -" ........................ ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" ........................ ", -" ........................ ", -" ...................... ", -" .................... ", -" .................... ", -" ................ ", -" .............. ", -" .......... ", -" ", -" " -}; diff --git a/nixhome/modules/desktop/pixmaps/min.xpm b/nixhome/modules/desktop/pixmaps/min.xpm deleted file mode 100644 index c2894aac..00000000 --- a/nixhome/modules/desktop/pixmaps/min.xpm +++ /dev/null @@ -1,37 +0,0 @@ -/* XPM */ -static char *min_xpm[] = { -"30 30 3 1", -" c None", -". c #b8e336", -"x c #7A9724", -" ", -" ", -" .......... ", -" .............. ", -" ................ ", -" .................... ", -" .................... ", -" ...................... ", -" ........................ ", -" ........................ ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" ........xxxxxxxxxx........ ", -" ........xxxxxxxxxx........ ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" ........................ ", -" ........................ ", -" ...................... ", -" .................... ", -" .................... ", -" ................ ", -" .............. ", -" .......... ", -" ", -" " -}; diff --git a/nixhome/modules/desktop/pixmaps/min_unfocus.xpm b/nixhome/modules/desktop/pixmaps/min_unfocus.xpm deleted file mode 100644 index 1263bbb9..00000000 --- a/nixhome/modules/desktop/pixmaps/min_unfocus.xpm +++ /dev/null @@ -1,36 +0,0 @@ -/* XPM */ -static char *min_unfocus_xpm[] = { -"30 30 2 1", -" c None", -". c #445566", -" ", -" ", -" .......... ", -" .............. ", -" ................ ", -" .................... ", -" .................... ", -" ...................... ", -" ........................ ", -" ........................ ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" .......................... ", -" ........................ ", -" ........................ ", -" ...................... ", -" .................... ", -" .................... ", -" ................ ", -" .............. ", -" .......... ", -" ", -" " -}; diff --git a/nixhome/modules/desktop/themes/main/svg/close.svg b/nixhome/modules/desktop/themes/main/svg/close.svg new file mode 100644 index 00000000..a43c9020 --- /dev/null +++ b/nixhome/modules/desktop/themes/main/svg/close.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/nixhome/modules/desktop/themes/main/svg/close_unfocus.svg b/nixhome/modules/desktop/themes/main/svg/close_unfocus.svg new file mode 100644 index 00000000..f570ff64 --- /dev/null +++ b/nixhome/modules/desktop/themes/main/svg/close_unfocus.svg @@ -0,0 +1,3 @@ + + + diff --git a/nixhome/modules/desktop/themes/main/svg/max.svg b/nixhome/modules/desktop/themes/main/svg/max.svg new file mode 100644 index 00000000..49db89da --- /dev/null +++ b/nixhome/modules/desktop/themes/main/svg/max.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/nixhome/modules/desktop/themes/main/svg/max_unfocus.svg b/nixhome/modules/desktop/themes/main/svg/max_unfocus.svg new file mode 100644 index 00000000..f570ff64 --- /dev/null +++ b/nixhome/modules/desktop/themes/main/svg/max_unfocus.svg @@ -0,0 +1,3 @@ + + + diff --git a/nixhome/modules/desktop/themes/main/svg/min.svg b/nixhome/modules/desktop/themes/main/svg/min.svg new file mode 100644 index 00000000..9a6e4c17 --- /dev/null +++ b/nixhome/modules/desktop/themes/main/svg/min.svg @@ -0,0 +1,4 @@ + + + + diff --git a/nixhome/modules/desktop/themes/main/svg/min_unfocus.svg b/nixhome/modules/desktop/themes/main/svg/min_unfocus.svg new file mode 100644 index 00000000..f570ff64 --- /dev/null +++ b/nixhome/modules/desktop/themes/main/svg/min_unfocus.svg @@ -0,0 +1,3 @@ + + + diff --git a/nixhome/modules/desktop/themes/main/svg/wallpaper.svg b/nixhome/modules/desktop/themes/main/svg/wallpaper.svg new file mode 100644 index 00000000..bc451a41 --- /dev/null +++ b/nixhome/modules/desktop/themes/main/svg/wallpaper.svg @@ -0,0 +1,428 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/nixhome/modules/desktop/theme.nix b/nixhome/modules/desktop/themes/main/theme.nix similarity index 80% rename from nixhome/modules/desktop/theme.nix rename to nixhome/modules/desktop/themes/main/theme.nix index 1ecf6322..b0d04dd0 100644 --- a/nixhome/modules/desktop/theme.nix +++ b/nixhome/modules/desktop/themes/main/theme.nix @@ -2,7 +2,7 @@ # Single source of truth for palette, fonts, and fluxbox theme generation. # Imported by default.nix; palette (c) and fonts (f) are reusable across # fluxbox theme, Xresources, and any future GUI config. -{ lib }: +{ lib, pkgs }: let # ── Design tokens ──────────────────────────────────────────────────────── c = { @@ -14,10 +14,19 @@ let raised = "#1a1a2e"; # focused iconbar, tab bg — slightly lighter than surface text = "#f0f0f0"; # active window title text textBright = "#ffffff"; # clock, menu body text + # macOS traffic-light button colors + close = "#E85D26"; closeDark = "#5C2510"; + min = "#1858a8"; minDark = "#0A2444"; + max = "#c8ff00"; maxDark = "#4A5C00"; + }; + + # ── Sizing tokens ────────────────────────────────────────────────────── + s = { + toolbarHeight = 35; }; f = rec { - family = "JetBrains Mono"; + family = "JetBrainsMono Nerd Font"; sm = "${family}-9"; smB = "${family}-9:bold"; md = "${family}-10"; @@ -49,7 +58,7 @@ let "toolbar.clock.font" = f.smB; "toolbar.clock.textColor" = c.textBright; "toolbar.color" = c.border; - "toolbar.height" = 28; + "toolbar.height" = s.toolbarHeight; "toolbar.iconbar.empty" = "flat"; "toolbar.iconbar.empty.color" = c.border; "toolbar.iconbar.focused" = "flat"; @@ -80,10 +89,10 @@ let "window.close.pressed.pixmap" = "pixmaps/close.xpm"; "window.close.unfocus.pixmap" = "pixmaps/close_unfocus.xpm"; "window.grip.focus" = "flat"; - "window.grip.focus.color" = c.highlight; + "window.grip.focus.color" = c.accent; "window.handle.focus" = "flat"; - "window.handle.focus.color" = c.accent; - "window.handleWidth" = 6; + "window.handle.focus.color" = c.highlight; + "window.handleWidth" = 10; "window.iconify.pixmap" = "pixmaps/min.xpm"; "window.iconify.pressed.pixmap" = "pixmaps/min.xpm"; "window.iconify.unfocus.pixmap" = "pixmaps/min_unfocus.xpm"; @@ -132,6 +141,7 @@ let "menu.frame.color" = c.surface; "menu.frame.disableColor" = c.inactive; "menu.frame.font" = f.lg; + "menu.frame.justify" = "left"; "menu.frame.textColor" = c.textBright; "menu.hilite" = "flat"; "menu.hilite.color" = c.highlight; @@ -176,7 +186,7 @@ let session.screen0.tab.placement: TopLeft session.screen0.tab.width: 64 session.screen0.iconbar.usePixmap: false - session.screen0.iconbar.iconTextPadding: 8 + session.screen0.iconbar.iconTextPadding: 10 session.screen0.titlebar.left: Stick session.screen0.titlebar.right: Minimize Maximize Close ''; @@ -188,7 +198,7 @@ let XTerm*cursorColor: ${c.accent} XTerm*faceName: ${f.family} XTerm*faceSize: 11 - XTerm*internalBorder: 4 + XTerm*internalBorder: 8 XTerm*scrollBar: False XTerm*saveLines: 4096 XTerm*color0: ${c.surface} @@ -209,18 +219,37 @@ let XTerm*color15: ${c.textBright} ''; - # ── Window button pixmaps (macOS-inspired neobrutalist circles) ─────────── - # close=orange, maximize=teal, minimize=yellow-green; unfocused=grey + # ── Window button pixmaps (macOS traffic-light circles, AA edges) ──────── + # SVG sources in ./svg/, converted to XPM at nix build time. + # Pipeline: SVG → PNG (rsvg-convert) → flatten on titlebar color → XPM. + xpmDir = pkgs.runCommand "fluxbox-pixmaps" { + nativeBuildInputs = [ pkgs.librsvg pkgs.imagemagick ]; + } '' + mkdir -p $out + for svg in ${./svg}/close.svg ${./svg}/close_unfocus.svg \ + ${./svg}/max.svg ${./svg}/max_unfocus.svg \ + ${./svg}/min.svg ${./svg}/min_unfocus.svg; do + name=$(basename "$svg" .svg) + rsvg-convert -w 30 -h 30 "$svg" -o "$name.png" + magick "$name.png" -background '${c.border}' -flatten \ + -fuzz 1% -transparent '${c.border}' "$out/$name.xpm" + done + ''; + pixmaps = { - ".fluxbox/styles/devcell-ocean/pixmaps/close.xpm".source = ./pixmaps/close.xpm; - ".fluxbox/styles/devcell-ocean/pixmaps/close_unfocus.xpm".source = ./pixmaps/close_unfocus.xpm; - ".fluxbox/styles/devcell-ocean/pixmaps/max.xpm".source = ./pixmaps/max.xpm; - ".fluxbox/styles/devcell-ocean/pixmaps/max_unfocus.xpm".source = ./pixmaps/max_unfocus.xpm; - ".fluxbox/styles/devcell-ocean/pixmaps/min.xpm".source = ./pixmaps/min.xpm; - ".fluxbox/styles/devcell-ocean/pixmaps/min_unfocus.xpm".source = ./pixmaps/min_unfocus.xpm; + ".fluxbox/styles/devcell-ocean/pixmaps/close.xpm".source = "${xpmDir}/close.xpm"; + ".fluxbox/styles/devcell-ocean/pixmaps/close_unfocus.xpm".source = "${xpmDir}/close_unfocus.xpm"; + ".fluxbox/styles/devcell-ocean/pixmaps/max.xpm".source = "${xpmDir}/max.xpm"; + ".fluxbox/styles/devcell-ocean/pixmaps/max_unfocus.xpm".source = "${xpmDir}/max_unfocus.xpm"; + ".fluxbox/styles/devcell-ocean/pixmaps/min.xpm".source = "${xpmDir}/min.xpm"; + ".fluxbox/styles/devcell-ocean/pixmaps/min_unfocus.xpm".source = "${xpmDir}/min_unfocus.xpm"; }; - # Default wallpaper — ocean waves with devcell logo. - wallpaper = ./wallpaper.png; + # ── Wallpaper — SVG → PNG at build time ────────────────────────────────── + wallpaper = pkgs.runCommand "devcell-wallpaper" { + nativeBuildInputs = [ pkgs.librsvg ]; + } '' + rsvg-convert -w 3840 -h 2160 ${./svg/wallpaper.svg} -o $out + ''; in { inherit c f cfg init xresources pixmaps wallpaper; } diff --git a/nixhome/modules/desktop/wallpaper.png b/nixhome/modules/desktop/wallpaper.png deleted file mode 100644 index dec5e3ed..00000000 Binary files a/nixhome/modules/desktop/wallpaper.png and /dev/null differ diff --git a/nixhome/modules/shell.nix b/nixhome/modules/shell.nix new file mode 100644 index 00000000..215aba5c --- /dev/null +++ b/nixhome/modules/shell.nix @@ -0,0 +1,62 @@ +# shell.nix — zsh + starship prompt configuration +# Imported by base.nix so all profiles get a consistent shell experience. +# Works in both `cell shell` (zsh) and xterm (desktop GUI). +{pkgs, ...}: { + programs.zsh = { + enable = true; + autosuggestion.enable = true; + syntaxHighlighting.enable = true; + history = { + size = 50000; + save = 50000; + ignoreDups = true; + ignoreAllDups = true; + ignoreSpace = true; + share = true; + }; + initContent = '' + # Keybindings + bindkey '^[[A' history-search-backward + bindkey '^[[B' history-search-forward + bindkey '^R' history-incremental-search-backward + ''; + }; + + programs.starship = { + enable = true; + enableZshIntegration = true; + enableBashIntegration = true; + settings = { + format = "$directory$git_branch$git_status$character"; + right_format = "$cmd_duration"; + add_newline = false; + + character = { + success_symbol = "[•](bold green)"; + error_symbol = "[•](bold red)"; + }; + + directory = { + style = "bold cyan"; + truncation_length = 3; + truncate_to_repo = true; + }; + + git_branch = { + format = "[$branch]($style) "; + style = "bold purple"; + }; + + git_status = { + format = "([$all_status$ahead_behind]($style) )"; + style = "bold yellow"; + }; + + cmd_duration = { + min_time = 2000; + format = "[$duration]($style)"; + style = "bold yellow"; + }; + }; + }; +} diff --git a/test/desktop_test.go b/test/desktop_test.go new file mode 100644 index 00000000..bcf85b3f --- /dev/null +++ b/test/desktop_test.go @@ -0,0 +1,407 @@ +package container_test + +// desktop_test.go — Tests for fluxbox desktop theme, taskbar, and menu styling. +// +// Verifies that nixhome/modules/desktop/theme.nix values are deployed +// correctly inside the container at /opt/devcell/.fluxbox/. +// +// Run against a GUI-capable image: +// DEVCELL_TEST_IMAGE=ghcr.io/dimmkirr/devcell:latest-ultimate go test -v -run TestDesktop ./... + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + devcellHome = "/opt/devcell" + themePath = devcellHome + "/.fluxbox/styles/devcell-ocean/theme.cfg" + overlayPath = devcellHome + "/.fluxbox/overlay" + initPath = devcellHome + "/.fluxbox/init" +) + +// assertContains checks that content contains the expected substring. +func assertContains(t *testing.T, label, content, expected string) { + t.Helper() + if !strings.Contains(content, expected) { + t.Errorf("%s: expected %q not found in:\n%s", label, expected, content) + } +} + +// ── Theme file existence ──────────────────────────────────────────────────── + +// TestDesktopThemeFileExists — theme.cfg must be deployed at the expected path. +func TestDesktopThemeFileExists(t *testing.T) { + c := startEnvContainer(t) + skipIfNoGUI(t, c) + + for _, path := range []string{themePath, overlayPath, initPath} { + out, code := exec(t, c, []string{"test", "-f", path}) + if code != 0 { + t.Errorf("FAIL: %s does not exist (exit %d): %s", path, code, out) + } else { + t.Logf("PASS: %s exists", path) + } + } +} + +// TestDesktopWallpaperExists — wallpaper.png must be deployed. +func TestDesktopWallpaperExists(t *testing.T) { + c := startEnvContainer(t) + skipIfNoGUI(t, c) + + path := devcellHome + "/.fluxbox/wallpaper.png" + _, code := exec(t, c, []string{"test", "-f", path}) + if code != 0 { + t.Fatalf("FAIL: wallpaper not found at %s", path) + } + // Verify it's a valid PNG (magic bytes) + out, code := exec(t, c, []string{"sh", "-c", "head -c 4 " + path + " | od -A n -t x1 | tr -d ' '"}) + if code != 0 { + t.Fatalf("FAIL: could not read wallpaper header: %s", out) + } + if !strings.Contains(out, "89504e47") { + t.Errorf("FAIL: wallpaper is not a valid PNG (magic: %s)", out) + } else { + t.Logf("PASS: wallpaper.png exists and is a valid PNG") + } +} + +// ── Toolbar styling ───────────────────────────────────────────────────────── + +// TestDesktopToolbarHeight — toolbar must be 35px. +func TestDesktopToolbarHeight(t *testing.T) { + c := startEnvContainer(t) + skipIfNoGUI(t, c) + + theme, _ := exec(t, c, []string{"cat", themePath}) + assertContains(t, "toolbar.height", theme, "toolbar.height: 35") +} + +// TestDesktopToolbarColors — toolbar uses the correct palette colors. +func TestDesktopToolbarColors(t *testing.T) { + c := startEnvContainer(t) + skipIfNoGUI(t, c) + + theme, _ := exec(t, c, []string{"cat", themePath}) + + // Toolbar background = border color (#000000) + assertContains(t, "toolbar.color", theme, "toolbar.color: #000000") + // Clock text = textBright (#ffffff) + assertContains(t, "toolbar.clock.textColor", theme, "toolbar.clock.textColor: #ffffff") + // Workspace badge = highlight (#b8e336) + assertContains(t, "toolbar.workspace.color", theme, "toolbar.workspace.color: #b8e336") + // Focused iconbar = raised (#1a1a2e) + assertContains(t, "toolbar.iconbar.focused.color", theme, "toolbar.iconbar.focused.color: #1a1a2e") + // Unfocused text = inactive (#667788) + assertContains(t, "toolbar.iconbar.unfocused.textColor", theme, "toolbar.iconbar.unfocused.textColor: #667788") +} + +// TestDesktopToolbarFonts — toolbar uses JetBrainsMono Nerd Font at correct sizes. +func TestDesktopToolbarFonts(t *testing.T) { + c := startEnvContainer(t) + skipIfNoGUI(t, c) + + theme, _ := exec(t, c, []string{"cat", themePath}) + + // Clock font = JetBrainsMono Nerd Font-9:bold + assertContains(t, "toolbar.clock.font", theme, "toolbar.clock.font: JetBrainsMono Nerd Font-9:bold") + // Workspace font = JetBrainsMono Nerd Font-9:bold + assertContains(t, "toolbar.workspace.font", theme, "toolbar.workspace.font: JetBrainsMono Nerd Font-9:bold") + // Focused iconbar font = JetBrainsMono Nerd Font-9:bold (same size as unfocused for baseline alignment) + assertContains(t, "toolbar.iconbar.focused.font", theme, "toolbar.iconbar.focused.font: JetBrainsMono Nerd Font-9:bold") +} + +// ── Menu styling ──────────────────────────────────────────────────────────── + +// TestDesktopMenuTitle — menu title uses highlight color, large bold font, left-justified. +func TestDesktopMenuTitle(t *testing.T) { + c := startEnvContainer(t) + skipIfNoGUI(t, c) + + theme, _ := exec(t, c, []string{"cat", themePath}) + + // Title background = highlight (#b8e336) + assertContains(t, "menu.title.color", theme, "menu.title.color: #b8e336") + // Title text = border (#000000) + assertContains(t, "menu.title.textColor", theme, "menu.title.textColor: #000000") + // Title font = JetBrainsMono Nerd Font-13:bold + assertContains(t, "menu.title.font", theme, "menu.title.font: JetBrainsMono Nerd Font-13:bold") + // Title justify = left + assertContains(t, "menu.title.justify", theme, "menu.title.justify: left") + // Title height = 32 + assertContains(t, "menu.titleHeight", theme, "menu.titleHeight: 32") +} + +// TestDesktopMenuBody — menu body uses surface color, white text, correct item height. +func TestDesktopMenuBody(t *testing.T) { + c := startEnvContainer(t) + skipIfNoGUI(t, c) + + theme, _ := exec(t, c, []string{"cat", themePath}) + + // Body background = surface (#0a0a18) + assertContains(t, "menu.frame.color", theme, "menu.frame.color: #0a0a18") + // Body text = textBright (#ffffff) + assertContains(t, "menu.frame.textColor", theme, "menu.frame.textColor: #ffffff") + // Body font = JetBrainsMono Nerd Font-11 + assertContains(t, "menu.frame.font", theme, "menu.frame.font: JetBrainsMono Nerd Font-11") + // Item height = 28 + assertContains(t, "menu.itemHeight", theme, "menu.itemHeight: 28") + // Highlight = highlight (#b8e336) + assertContains(t, "menu.hilite.color", theme, "menu.hilite.color: #b8e336") + // Border = 3px black (neobrutalist) + assertContains(t, "menu.borderWidth", theme, "menu.borderWidth: 3") + assertContains(t, "menu.borderColor", theme, "menu.borderColor: #000000") +} + +// ── Window styling ────────────────────────────────────────────────────────── + +// TestDesktopWindowBorder — windows must have thick 3px black border (neobrutalist signature). +func TestDesktopWindowBorder(t *testing.T) { + c := startEnvContainer(t) + skipIfNoGUI(t, c) + + theme, _ := exec(t, c, []string{"cat", themePath}) + + assertContains(t, "window.borderWidth", theme, "window.borderWidth: 3") + assertContains(t, "window.borderColor", theme, "window.borderColor: #000000") +} + +// TestDesktopWindowTitle — focused window title bar must be 30px, flat, black bg. +func TestDesktopWindowTitle(t *testing.T) { + c := startEnvContainer(t) + skipIfNoGUI(t, c) + + theme, _ := exec(t, c, []string{"cat", themePath}) + + assertContains(t, "window.title.height", theme, "window.title.height: 30") + assertContains(t, "window.title.focus", theme, "window.title.focus: flat") + assertContains(t, "window.title.focus.color", theme, "window.title.focus.color: #000000") + // Title text font = JetBrainsMono Nerd Font-10:bold + assertContains(t, "window.label.focus.font", theme, "window.label.focus.font: JetBrainsMono Nerd Font-10:bold") + // Title text color = text (#f0f0f0) + assertContains(t, "window.label.focus.textColor", theme, "window.label.focus.textColor: #f0f0f0") +} + +// TestDesktopWindowHandle — focused handle = highlight green, 10px width. +func TestDesktopWindowHandle(t *testing.T) { + c := startEnvContainer(t) + skipIfNoGUI(t, c) + + theme, _ := exec(t, c, []string{"cat", themePath}) + + assertContains(t, "window.handleWidth", theme, "window.handleWidth: 10") + assertContains(t, "window.handle.focus.color", theme, "window.handle.focus.color: #b8e336") +} + +// ── Pixmaps ───────────────────────────────────────────────────────────────── + +// TestDesktopPixmapsExist — all 6 window button pixmaps must be deployed. +func TestDesktopPixmapsExist(t *testing.T) { + c := startEnvContainer(t) + skipIfNoGUI(t, c) + + pixmapDir := devcellHome + "/.fluxbox/styles/devcell-ocean/pixmaps" + pixmaps := []string{ + "close.xpm", "close_unfocus.xpm", + "max.xpm", "max_unfocus.xpm", + "min.xpm", "min_unfocus.xpm", + } + for _, p := range pixmaps { + path := pixmapDir + "/" + p + _, code := exec(t, c, []string{"test", "-f", path}) + if code != 0 { + t.Errorf("FAIL: pixmap %s not found", path) + } else { + t.Logf("PASS: %s exists", p) + } + } +} + +// TestDesktopPixmapReferences — theme.cfg must reference the pixmap files. +func TestDesktopPixmapReferences(t *testing.T) { + c := startEnvContainer(t) + skipIfNoGUI(t, c) + + theme, _ := exec(t, c, []string{"cat", themePath}) + + assertContains(t, "window.close.pixmap", theme, "window.close.pixmap: pixmaps/close.xpm") + assertContains(t, "window.maximize.pixmap", theme, "window.maximize.pixmap: pixmaps/max.xpm") + assertContains(t, "window.iconify.pixmap", theme, "window.iconify.pixmap: pixmaps/min.xpm") +} + +// ── Init / session config ─────────────────────────────────────────────────── + +// TestDesktopInitConfig — fluxbox init must reference the correct style and menu paths. +func TestDesktopInitConfig(t *testing.T) { + c := startEnvContainer(t) + skipIfNoGUI(t, c) + + init, _ := exec(t, c, []string{"cat", initPath}) + + assertContains(t, "styleFile", init, "session.styleFile:\t"+devcellHome+"/.fluxbox/styles/devcell-ocean/theme.cfg") + assertContains(t, "menuFile", init, "session.menuFile:\t"+devcellHome+"/.fluxbox/menu") + assertContains(t, "overlay", init, "session.styleOverlay:\t"+devcellHome+"/.fluxbox/overlay") + assertContains(t, "toolbar.placement", init, "session.screen0.toolbar.placement: BottomCenter") + assertContains(t, "toolbar.widthPercent", init, "session.screen0.toolbar.widthPercent: 100") + assertContains(t, "toolbar.visible", init, "session.screen0.toolbar.visible: true") +} + +// TestDesktopOverlayMatchesTheme — overlay must contain the same content as theme.cfg. +func TestDesktopOverlayMatchesTheme(t *testing.T) { + c := startEnvContainer(t) + skipIfNoGUI(t, c) + + theme, _ := exec(t, c, []string{"cat", themePath}) + overlay, _ := exec(t, c, []string{"cat", overlayPath}) + + if theme != overlay { + t.Errorf("FAIL: overlay content differs from theme.cfg\ntheme.cfg length: %d\noverlay length: %d", len(theme), len(overlay)) + } else { + t.Logf("PASS: overlay matches theme.cfg (%d bytes)", len(theme)) + } +} + +// ── Xresources ────────────────────────────────────────────────────────────── + +// TestDesktopXresources — Xresources must set XTerm colors from the palette. +func TestDesktopXresources(t *testing.T) { + c := startEnvContainer(t) + skipIfNoGUI(t, c) + + xres, _ := exec(t, c, []string{"cat", devcellHome + "/.Xresources"}) + + // Background = surface (#0a0a18) + assertContains(t, "XTerm*background", xres, "XTerm*background: #0a0a18") + // Cursor = accent (#1abc9c) + assertContains(t, "XTerm*cursorColor", xres, "XTerm*cursorColor: #1abc9c") + // Font = JetBrainsMono Nerd Font + assertContains(t, "XTerm*faceName", xres, "XTerm*faceName: JetBrainsMono Nerd Font") + // Font size = 11 + assertContains(t, "XTerm*faceSize", xres, "XTerm*faceSize: 11") +} + +// ── Runtime GUI tests (require full GUI stack) ────────────────────────────── + +// startDesktopGUIContainer starts a container with DEVCELL_GUI_ENABLED=true +// and waits for the full GUI stack (Xvfb + fluxbox + x11vnc) to be running. +func startDesktopGUIContainer(t *testing.T) testcontainers.Container { + t.Helper() + ctx := context.Background() + req := testcontainers.ContainerRequest{ + Image: image(), + Env: map[string]string{ + "HOST_USER": hostUser, + "APP_NAME": "test", + "DEVCELL_GUI_ENABLED": "true", + }, + User: "0", + Cmd: []string{"tail", "-f", "/dev/null"}, + WaitingFor: wait.ForExec([]string{"sh", "-c", + "grep -qi 170C /proc/net/tcp6 /proc/net/tcp 2>/dev/null && grep -qi ' 0A ' /proc/net/tcp6 /proc/net/tcp 2>/dev/null"}). + WithStartupTimeout(60 * time.Second), + } + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("start desktop GUI container: %v", err) + } + t.Cleanup(func() { _ = c.Terminate(ctx) }) + return c +} + +// TestDesktopXresourcesLoaded — with GUI enabled, xrdb must have loaded the +// Xresources into the X server's resource database at startup. +// xrdb loading is deferred (background process after entrypoint exec gosu) +// so we retry for up to 5 seconds. +func TestDesktopXresourcesLoaded(t *testing.T) { + probeGUI(t) + c := startDesktopGUIContainer(t) + + // xrdb is loaded by a deferred background process (~1s after entrypoint); + // retry for up to 5 seconds to allow it to complete. + var out string + var code int + for i := 0; i < 10; i++ { + out, code = exec(t, c, []string{"sh", "-c", "DISPLAY=:99 xrdb -query"}) + if code == 0 && out != "" { + break + } + time.Sleep(500 * time.Millisecond) + } + if code != 0 { + t.Fatalf("xrdb -query failed (exit %d): %s", code, out) + } + if out == "" { + t.Fatal("FAIL: X resource database is empty — Xresources not loaded at startup") + } + + // Verify key resources from theme.nix palette + assertContains(t, "XTerm*background", out, "XTerm*background:\t#0a0a18") + assertContains(t, "XTerm*cursorColor", out, "XTerm*cursorColor:\t#1abc9c") + assertContains(t, "XTerm*faceName", out, "XTerm*faceName:\tJetBrainsMono Nerd Font") + assertContains(t, "XTerm*faceSize", out, "XTerm*faceSize:\t11") + t.Logf("PASS: Xresources loaded into X server (%d lines)", strings.Count(out, "\n")+1) +} + +// TestDesktopFluxboxThemeActive — with GUI enabled, fluxbox must be running +// with the devcell-ocean theme (not default grey). +func TestDesktopFluxboxThemeActive(t *testing.T) { + probeGUI(t) + c := startDesktopGUIContainer(t) + + // Verify fluxbox is running with our patched init + out, code := exec(t, c, []string{"sh", "-c", "cat /tmp/fluxbox-init"}) + if code != 0 { + t.Fatalf("could not read /tmp/fluxbox-init (exit %d): %s", code, out) + } + // The init must point to our custom theme + assertContains(t, "styleFile", out, + "session.styleFile:\t"+devcellHome+"/.fluxbox/styles/devcell-ocean/theme.cfg") + // Workspace name must be patched with APP_NAME + assertContains(t, "workspaceNames", out, "test") + t.Logf("PASS: fluxbox running with devcell-ocean theme, workspace='test'") +} + +// TestDesktopScreenshotNotDefaultGrey — capture a screenshot and verify +// the taskbar is not the default fluxbox grey (#bebebd) but our black (#000000). +// Uses ImageMagick's `import` to capture the X root window. +func TestDesktopScreenshotNotDefaultGrey(t *testing.T) { + probeGUI(t) + c := startDesktopGUIContainer(t) + + // Capture a screenshot + _, code := exec(t, c, []string{"sh", "-c", "DISPLAY=:99 import -window root /tmp/desktop.png"}) + if code != 0 { + t.Skip("import (ImageMagick) not available — skipping screenshot test") + } + + // Sample the taskbar area (bottom 40px strip, center pixel) + // Use ImageMagick to get the color of a pixel at (960, 1060) — middle of the toolbar + // on a 1920x1080 screen with 40px toolbar at bottom. + out, code := exec(t, c, []string{"sh", "-c", + "DISPLAY=:99 convert /tmp/desktop.png -crop 1x1+960+1060 -format '%[hex:u.p{0,0}]' info:"}) + if code != 0 { + t.Skipf("convert not available (exit %d): %s", code, out) + } + + // The toolbar pixel should be dark (our theme: #000000) not default grey (#bebebd) + out = strings.TrimSpace(strings.ToUpper(out)) + t.Logf("Toolbar pixel color at (960,1060): #%s", out) + + // Default fluxbox grey is around BEBEBD; our theme is 000000. + // Allow for some variation but it should definitely not be light grey. + if strings.HasPrefix(out, "BE") || strings.HasPrefix(out, "C0") || strings.HasPrefix(out, "D0") { + t.Errorf("FAIL: toolbar appears to use default grey color (#%s) — theme not applied", out) + } else { + t.Logf("PASS: toolbar is not default grey (#%s)", out) + } +} diff --git a/test/git_test.go b/test/git_test.go new file mode 100644 index 00000000..260a51ed --- /dev/null +++ b/test/git_test.go @@ -0,0 +1,120 @@ +package container_test + +// git_test.go — e2e tests for git identity configuration inside the container. +// +// Verifies that GIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL env vars produce correct +// commit metadata, and that ~/.config/git/config is read when env vars are absent. +// +// Run: +// go test -v -run TestGit -timeout 120s ./... + +import ( + "strings" + "testing" + + "github.com/testcontainers/testcontainers-go" +) + +// TestGitCommitUsesEnvIdentity starts a container with explicit git env vars, +// creates a commit, and verifies git log shows the configured identity. +func TestGitCommitUsesEnvIdentity(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + + c := startContainer(t, map[string]string{ + "HOST_USER": hostUser, + "APP_NAME": "test", + "GIT_AUTHOR_NAME": "Test Author", + "GIT_AUTHOR_EMAIL": "test@devcell.io", + "GIT_COMMITTER_NAME": "Test Committer", + "GIT_COMMITTER_EMAIL": "committer@devcell.io", + }) + + // Init repo, make a commit + out, code := asUser(t, c, ` + cd /tmp && + mkdir gittest && cd gittest && + git init && + touch file.txt && + git add file.txt && + git commit -m "test commit" && + git log -1 --format='%an|%ae|%cn|%ce' + `) + if code != 0 { + t.Fatalf("FAIL: git commit failed (exit %d):\n%s", code, out) + } + + // Parse last line of output (git log format) + lines := strings.Split(strings.TrimSpace(out), "\n") + logLine := lines[len(lines)-1] + parts := strings.Split(logLine, "|") + if len(parts) != 4 { + t.Fatalf("FAIL: unexpected git log format: %q", logLine) + } + + if parts[0] != "Test Author" { + t.Errorf("author name: want %q, got %q", "Test Author", parts[0]) + } + if parts[1] != "test@devcell.io" { + t.Errorf("author email: want %q, got %q", "test@devcell.io", parts[1]) + } + if parts[2] != "Test Committer" { + t.Errorf("committer name: want %q, got %q", "Test Committer", parts[2]) + } + if parts[3] != "committer@devcell.io" { + t.Errorf("committer email: want %q, got %q", "committer@devcell.io", parts[3]) + } + t.Logf("PASS: git identity = %s", logLine) +} + +// TestGitCommitUsesGitConfig verifies that when no GIT_* env vars are set, +// git reads identity from ~/.config/git/config inside the container. +func TestGitCommitUsesGitConfig(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + + c := startContainer(t, map[string]string{ + "HOST_USER": hostUser, + "APP_NAME": "test", + }) + + // Write a git config file, init repo, commit, check identity + out, code := asUser(t, c, ` + mkdir -p ~/.config/git && + cat > ~/.config/git/config <<'GITCFG' +[user] + name = Config User + email = config@devcell.io +GITCFG + cd /tmp && + mkdir gitcfgtest && cd gitcfgtest && + git init && + touch file.txt && + git add file.txt && + git commit -m "config commit" && + git log -1 --format='%an|%ae' + `) + if code != 0 { + t.Fatalf("FAIL: git commit with config failed (exit %d):\n%s", code, out) + } + + lines := strings.Split(strings.TrimSpace(out), "\n") + logLine := lines[len(lines)-1] + parts := strings.Split(logLine, "|") + if len(parts) != 2 { + t.Fatalf("FAIL: unexpected git log format: %q", logLine) + } + + if parts[0] != "Config User" { + t.Errorf("author name: want %q, got %q", "Config User", parts[0]) + } + if parts[1] != "config@devcell.io" { + t.Errorf("author email: want %q, got %q", "config@devcell.io", parts[1]) + } + t.Logf("PASS: git identity from config = %s", logLine) +} + +// suppress unused import warning +var _ testcontainers.Container diff --git a/test/rdp_test.go b/test/rdp_test.go index 60d3b629..c4dc4528 100644 --- a/test/rdp_test.go +++ b/test/rdp_test.go @@ -249,7 +249,7 @@ func TestRdpLogsToFile(t *testing.T) { probeGUI(t) c := startRdpContainer(t) out, code := exec(t, c, []string{"sh", "-c", - "test -f /var/log/xrdp.log && test -f /var/log/xrdp-sesman.log && echo OK"}) + "test -f /var/log/xrdp.log && echo OK"}) if code != 0 || !strings.Contains(out, "OK") { t.Fatalf("FAIL: xrdp log files not found in /var/log/") } @@ -261,22 +261,16 @@ func TestRdpLogsToFile(t *testing.T) { } if !strings.Contains(out, "EnableSyslog=false") { t.Errorf("FAIL: xrdp.ini EnableSyslog should be false:\n%s", out) - } - // sesman.ini must also have syslog disabled - out, _ = exec(t, c, []string{"sh", "-c", - "grep -E '^EnableSyslog=' /tmp/xrdp/sesman.ini 2>/dev/null"}) - if !strings.Contains(out, "EnableSyslog=false") { - t.Errorf("FAIL: sesman.ini EnableSyslog should be false:\n%s", out) } else { t.Logf("PASS: xrdp logs to /var/log/, syslog disabled\n%s", out) } } -// TestRdpCertPersisted — SSL cert should be in /etc/devcell/xrdp/ (global config). +// TestRdpCertPersisted — SSL cert should be in /etc/devcell/config/xrdp/ (global config). func TestRdpCertPersisted(t *testing.T) { probeGUI(t) c := startRdpContainer(t) - certDir := "/etc/devcell/xrdp" + certDir := "/etc/devcell/config/xrdp" out, code := exec(t, c, []string{"sh", "-c", "test -f " + certDir + "/key.pem && test -f " + certDir + "/cert.pem && echo OK"}) if code != 0 || !strings.Contains(out, "OK") { @@ -299,19 +293,20 @@ func TestRdpNoXorgSection(t *testing.T) { } } -// TestRdpUserPasswordSet — user must have a password for sesman PAM auth. -func TestRdpUserPasswordSet(t *testing.T) { +// TestRdpUserExists — session user must exist for xrdp VNC proxy. +// Note: no PAM password needed — xrdp uses [vnc-any] with hardcoded VNC creds. +func TestRdpUserExists(t *testing.T) { probeGUI(t) c := startRdpContainer(t) out, code := exec(t, c, []string{"sh", "-c", - "getent shadow " + hostUser + " | cut -d: -f2 | head -c3"}) + "id " + hostUser + " && getent passwd " + hostUser + " | cut -d: -f7"}) if code != 0 { - t.Fatalf("FAIL: could not read shadow entry (exit %d)", code) + t.Fatalf("FAIL: user %s does not exist (exit %d)", hostUser, code) } - if strings.HasPrefix(out, "$") { - t.Logf("PASS: user %s has a password set (hash prefix: %s...)", hostUser, out) + if strings.Contains(out, "/bin/zsh") || strings.Contains(out, "/bin/bash") { + t.Logf("PASS: user %s exists with valid shell:\n%s", hostUser, out) } else { - t.Errorf("FAIL: user %s password appears locked or empty: %q", hostUser, out) + t.Errorf("FAIL: user %s has unexpected shell:\n%s", hostUser, out) } } diff --git a/test/shell_test.go b/test/shell_test.go new file mode 100644 index 00000000..309952ae --- /dev/null +++ b/test/shell_test.go @@ -0,0 +1,127 @@ +package container_test + +// shell_test.go — e2e tests for zsh + starship prompt configuration. +// +// Verifies that starship renders the configured prompt symbols correctly, +// both via `starship prompt` (direct) and via `unbuffer zsh` (full shell). +// +// Run: +// go test -v -run TestShell -timeout 120s ./... + +import ( + "strings" + "testing" +) + +// TestShellStarshipBinary verifies starship is installed and on PATH. +func TestShellStarshipBinary(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startEnvContainer(t) + out, code := asUser(t, c, "starship --version") + if code != 0 { + t.Fatalf("starship not found on PATH (exit %d): %s", code, out) + } + t.Logf("PASS: %s", strings.TrimSpace(out)) +} + +// TestShellStarshipConfigExists verifies the home-manager-generated config +// is present and contains the expected unicode character symbol. +func TestShellStarshipConfigExists(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startEnvContainer(t) + + out, code := asUser(t, c, "cat ~/.config/starship.toml") + if code != 0 { + t.Fatalf("starship.toml not found (exit %d): %s", code, out) + } + + // Verify the unicode symbol from shell.nix made it through nix string escaping + if !strings.Contains(out, "•") { + t.Errorf("FAIL: starship.toml missing • character symbol:\n%s", out) + } else { + t.Logf("PASS: starship.toml contains • symbol") + } + + if !strings.Contains(out, "add_newline = false") { + t.Errorf("FAIL: starship.toml missing add_newline setting:\n%s", out) + } +} + +// TestShellStarshipPromptRenders runs `starship prompt` directly and verifies +// the rendered output contains the configured unicode symbols. +// No PTY needed — starship prompt writes to stdout. +func TestShellStarshipPromptRenders(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startEnvContainer(t) + + // starship prompt renders the prompt string to stdout. + // Run from /tmp so directory module shows "tmp". + out, code := asUser(t, c, "cd /tmp && starship prompt") + if code != 0 { + t.Fatalf("starship prompt failed (exit %d): %s", code, out) + } + + if !strings.Contains(out, "•") { + t.Errorf("FAIL: starship prompt output missing • symbol: %q", out) + } else { + t.Logf("PASS: starship prompt rendered •: %q", out) + } +} + +// TestShellZshStarshipIntegration verifies the complete integration chain: +// zsh sources .zshrc → starship init is evaluated → starship prompt renders •. +// Uses `zsh -c 'source ~/.zshrc; starship prompt'` to test without PTY complexity. +func TestShellZshStarshipIntegration(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startEnvContainer(t) + + // Source .zshrc (which contains starship init) then render the prompt. + // This tests the full chain without needing a PTY. + out, code := asUser(t, c, `zsh -c 'source ~/.zshrc 2>/dev/null; starship prompt'`) + if code != 0 { + t.Fatalf("zsh + starship prompt failed (exit %d): %s", code, out) + } + + if !strings.Contains(out, "•") { + t.Errorf("FAIL: zsh→.zshrc→starship prompt missing • symbol: %q", out) + } else { + t.Logf("PASS: zsh→.zshrc→starship prompt rendered •: %q", out) + } +} + +// TestShellZshAutosuggestions verifies the zsh-autosuggestions plugin is loaded. +func TestShellZshAutosuggestions(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startEnvContainer(t) + + // Check if the autosuggestions plugin source file exists in the zshrc + out, code := asUser(t, c, "grep -l autosuggestions ~/.zshrc") + if code != 0 { + t.Fatalf("FAIL: zsh-autosuggestions not referenced in .zshrc (exit %d): %s", code, out) + } + t.Logf("PASS: zsh-autosuggestions found in .zshrc") +} + +// TestShellZshSyntaxHighlighting verifies the syntax-highlighting plugin is loaded. +func TestShellZshSyntaxHighlighting(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startEnvContainer(t) + + out, code := asUser(t, c, "grep -l syntax-highlighting ~/.zshrc") + if code != 0 { + t.Fatalf("FAIL: zsh-syntax-highlighting not referenced in .zshrc (exit %d): %s", code, out) + } + t.Logf("PASS: zsh-syntax-highlighting found in .zshrc") +}