Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env-example
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 5 additions & 6 deletions cmd/behavior_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
6 changes: 3 additions & 3 deletions cmd/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
},
}
22 changes: 11 additions & 11 deletions cmd/utility_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,38 +10,38 @@ 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)
}
}

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)
}
Expand Down
16 changes: 14 additions & 2 deletions images/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) ───────────────────
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions internal/cfg/cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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_<UPPER_KEY> env vars
Volumes []VolumeMount
Expand Down Expand Up @@ -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...)

Expand Down
94 changes: 94 additions & 0 deletions internal/cfg/cfg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
Loading
Loading