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
Binary file added .DS_Store
Binary file not shown.
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ jobs:
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
- run: go vet ./...
- run: CGO_ENABLED=0 go vet ./...
working-directory: backend
- run: go test ./...
- run: CGO_ENABLED=0 go test ./...
working-directory: backend
- run: go build -o ../bin/calf ./cmd/calf
- run: CGO_ENABLED=0 go build -o /dev/null ./cmd/calf
working-directory: backend

ui:
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
bin/
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.3.0] - 2026-07-01

### Added

- Lima VM runtime on macOS with containerd, nerdctl, and Docker socket forwarding
- Linux native runtime path via nerdctl
- `calf start`, `calf stop`, and `calf status` CLI commands
- Container and image REST API endpoints
- WebSocket container log streaming at `/v1/containers/{id}/logs`
- Flutter UI screens for containers, images, and live logs
- `examples/hello-world/` reference project
- `scripts/verify-docker-cli.sh` P0 Docker CLI verification script
- Docker Desktop migration guide in `DEVELOPMENT.md`

### Changed

- `/v1/status` now includes runtime mode, state, and Docker socket path
- Go tests moved to `backend/test/`

## [0.2.0] - 2026-07-01

### Added
Expand Down
58 changes: 40 additions & 18 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
@@ -1,47 +1,69 @@
## Quick start

**1. Start the API** (port `8080`):

```bash
cd backend
go run ./cmd/calf
make dev-backend # terminal 1: daemon + runtime on :8765
make dev-ui # terminal 2: macOS app
```

**2. Start the UI** (in another terminal):
For containers via the Docker CLI, set:

```bash
cd ui
flutter pub get
flutter run
export DOCKER_HOST=unix://$HOME/.config/calf/docker.sock
```

Pick a device when prompted (`chrome`, `macos`, etc.). The UI calls the API on startup and shows daemon status.

## Configuration

On first run the daemon creates `~/.config/calf/config.yaml` with defaults:

```yaml
listen_addr: ":8080"
listen_addr: ":8765"
log_level: info
vm_name: calf
docker_socket: ""
```

## Build

Build the daemon and macOS UI from the repository root:
Build the macOS UI from the repository root:

```bash
make build
```

Artifacts:
Artifact: `ui/build/macos/Build/Products/Release/ui.app`

## Migrating from Docker Desktop

1. Export images you need:

```bash
docker save my-image:latest -o my-image.tar
```

2. Stop Docker Desktop.

3. Install [Lima](https://github.com/lima-vm/lima) on macOS if needed.

4. Start Calf:

```bash
make dev-backend
```

5. Point your tools at Calf:

```bash
export DOCKER_HOST=unix://$HOME/.config/calf/docker.sock
```

6. Import images:

- `bin/calf` — daemon binary
- `ui/build/macos/Build/Products/Release/ui.app` — macOS app bundle
```bash
docker load -i my-image.tar
```

Build individually:
7. Verify:

```bash
make backend
make ui
docker run hello-world
```
26 changes: 21 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
.PHONY: backend ui build clean
.PHONY: help ui build clean dev dev-backend dev-ui

backend:
cd backend && go build -o ../bin/calf ./cmd/calf
help:
@echo "Calf — common commands"
@echo ""
@echo " make dev-backend API daemon on :8765 (terminal 1)"
@echo " make dev-ui Flutter app on macOS (terminal 2)"
@echo ""
@echo " make ui build macOS app"
@echo " make build build macOS app"
@echo " make clean remove build artifacts"
@echo ""
@echo "Full guide: DEVELOPMENT.md"

ui:
cd ui && flutter build macos

build: backend ui
build: ui

clean:
rm -rf bin
cd ui && flutter clean

dev-backend:
cd backend && CGO_ENABLED=0 go run ./cmd/calf

dev-ui:
cd ui && flutter run -d macos

dev: help
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

A fast, lightweight alternative to Docker Desktop for running and managing containers on your machine, without the overhead of a full desktop stack.

## Quick start

```bash
make help # list commands
make dev-backend # terminal 1: API on :8765
make dev-ui # terminal 2: macOS app
```

See [DEVELOPMENT.md](DEVELOPMENT.md) for configuration and migration from Docker Desktop.

## License

MIT — see [LICENSE](LICENSE).
5 changes: 2 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,12 @@ Be a **valid** Docker Desktop replacement for local development: same CLI (`dock
| `docker volume *` | P1 |
| `docker compose` | P0 (phase 2) |

- [ ] `calf` wrapper command: `calf start`, `calf stop`, `calf status`
- [ ] Document migration from Docker Desktop (export images, switch context)

### 1.3 Minimal UI

- [ ] Container list (running / stopped) with start/stop/remove actions
- [ ] Image list with pull and remove
- [x] Container list (running / stopped) with start/stop/remove actions
- [x] Image list with pull and remove
- [ ] Real-time log viewer (WebSocket)

**Exit criteria:** a sample project with `Dockerfile` + `docker run` works without Docker Desktop installed.
Expand Down
157 changes: 152 additions & 5 deletions backend/cmd/calf/main.go
Original file line number Diff line number Diff line change
@@ -1,25 +1,172 @@
package main

import (
"context"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"

"github.com/enegalan/calf/backend/internal/api"
"github.com/enegalan/calf/backend/internal/config"
"github.com/enegalan/calf/backend/internal/runtime"
)

func main() {
os.Exit(run())
}

func run() int {
cfg, err := config.Load()
if err != nil {
slog.Error("failed to load config", "error", err)
os.Exit(1)
return 1
}

logger := config.NewLogger(cfg.LogLevel)
server := api.New(cfg, logger)
rt := runtime.New(cfg.VMName, cfg.DockerSocket, cfg.CPUs, cfg.MemoryGB, cfg.MemorySwapGB, cfg.DiskGB, runtime.ParseListenPort(cfg.ListenAddr))
server := api.New(cfg, logger, rt)

if err := ensurePort(cfg.ListenAddr); err != nil {
logger.Warn("cleaned up previous instance", "error", err)
}

writePidFile()
defer removePidFile()

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

rtCtx, rtCancel := context.WithCancel(ctx)
defer rtCancel()

go func() {
logger.Info("starting runtime")
if err := rt.Start(rtCtx); err != nil {
logger.Warn("runtime start failed (non-fatal)", "error", err)
} else {
logger.Info("runtime started", "socket", rt.DockerSocket())
}
}()

errCh := make(chan error, 1)
go func() {
errCh <- server.Run()
}()

select {
case err := <-errCh:
if err != nil {
logger.Error("server stopped", "error", err)
rtCancel()
stopCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if stopErr := rt.Stop(stopCtx); stopErr != nil {
logger.Error("runtime stop failed", "error", stopErr)
}
return 1
}
case <-ctx.Done():
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Error("shutdown failed", "error", err)
}
}

rtCancel()
stopCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := rt.Stop(stopCtx); err != nil {
logger.Error("runtime stop failed", "error", err)
}

return 0
}

func writePidFile() {
path := pidFilePath()
os.MkdirAll(filepath.Dir(path), 0o755)
os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())), 0o644)
}

func removePidFile() {
os.Remove(pidFilePath())
}

func ensurePort(addr string) error {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return err
}

ln, err := net.Listen("tcp", net.JoinHostPort(host, port))
if err == nil {
ln.Close()
return nil
}

pid := findPidOnPort(port)
if pid == 0 {
return fmt.Errorf("port %s is in use; run: pkill -f calf", addr)
}

calfPID, pidErr := readPidFile()
if pidErr != nil || calfPID != pid {
return fmt.Errorf("port %s is in use by pid %d", addr, pid)
}

proc, _ := os.FindProcess(pid)
if proc != nil {
proc.Signal(syscall.SIGTERM)
}

for i := 0; i < 10; i++ {
time.Sleep(300 * time.Millisecond)
ln, err := net.Listen("tcp", net.JoinHostPort(host, port))
if err == nil {
ln.Close()
return nil
}
}

if err := server.Run(); err != nil {
logger.Error("server stopped", "error", err)
os.Exit(1)
return fmt.Errorf("port %s is still in use after cleanup; run: pkill -f calf", addr)
}

func findPidOnPort(port string) int {
out, err := exec.Command("lsof", "-ti", fmt.Sprintf(":%s", port), "-s", "TCP:LISTEN").Output()
if err != nil {
return 0
}
for _, raw := range strings.Fields(string(out)) {
pid, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
continue
}
if pid != os.Getpid() && pid != os.Getppid() {
return pid
}
}
return 0
}

func pidFilePath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "calf", "calf.pid")
}

func readPidFile() (int, error) {
data, err := os.ReadFile(pidFilePath())
if err != nil {
return 0, err
}
return strconv.Atoi(string(data))
}
6 changes: 5 additions & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@ module github.com/enegalan/calf/backend

go 1.22.1

require gopkg.in/yaml.v3 v3.0.1 // indirect
require (
github.com/creack/pty v1.1.24 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
4 changes: 4 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Loading
Loading