# Core DevOps CLI Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Implement `core dev` commands for portable development environment using core-devops LinuxKit images. **Architecture:** `pkg/devops` package handles image management, config, and orchestration. Reuses `pkg/container.LinuxKitManager` for VM lifecycle. Image sources (GitHub, Registry, CDN) implement common interface. CLI in `cmd/core/cmd/dev.go`. **Tech Stack:** Go, pkg/container, golang.org/x/crypto/ssh, os/exec for gh CLI, YAML config --- ### Task 1: Create DevOps Package Structure **Files:** - Create: `pkg/devops/devops.go` - Create: `pkg/devops/go.mod` **Step 1: Create go.mod** ```go module github.com/host-uk/core/pkg/devops go 1.25 require ( github.com/host-uk/core/pkg/container v0.0.0 golang.org/x/crypto v0.32.0 gopkg.in/yaml.v3 v3.0.1 ) replace github.com/host-uk/core/pkg/container => ../container ``` **Step 2: Create devops.go with core types** ```go // Package devops provides a portable development environment using LinuxKit images. package devops import ( "context" "fmt" "os" "path/filepath" "runtime" "github.com/host-uk/core/pkg/container" ) // DevOps manages the portable development environment. type DevOps struct { config *Config images *ImageManager container *container.LinuxKitManager } // New creates a new DevOps instance. func New() (*DevOps, error) { cfg, err := LoadConfig() if err != nil { return nil, fmt.Errorf("devops.New: failed to load config: %w", err) } images, err := NewImageManager(cfg) if err != nil { return nil, fmt.Errorf("devops.New: failed to create image manager: %w", err) } mgr, err := container.NewLinuxKitManager() if err != nil { return nil, fmt.Errorf("devops.New: failed to create container manager: %w", err) } return &DevOps{ config: cfg, images: images, container: mgr, }, nil } // ImageName returns the platform-specific image name. func ImageName() string { return fmt.Sprintf("core-devops-%s-%s.qcow2", runtime.GOOS, runtime.GOARCH) } // ImagesDir returns the path to the images directory. func ImagesDir() (string, error) { if dir := os.Getenv("CORE_IMAGES_DIR"); dir != "" { return dir, nil } home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".core", "images"), nil } // ImagePath returns the full path to the platform-specific image. func ImagePath() (string, error) { dir, err := ImagesDir() if err != nil { return "", err } return filepath.Join(dir, ImageName()), nil } // IsInstalled checks if the dev image is installed. func (d *DevOps) IsInstalled() bool { path, err := ImagePath() if err != nil { return false } _, err = os.Stat(path) return err == nil } ``` **Step 3: Add to go.work** Run: `cd /Users/snider/Code/Core && echo " ./pkg/devops" >> go.work && go work sync` **Step 4: Verify it compiles** Run: `cd /Users/snider/Code/Core && go build ./pkg/devops/...` Expected: Error (missing Config, ImageManager) - that's OK for now **Step 5: Commit** ```bash git add pkg/devops/ git add go.work go.work.sum git commit -m "feat(devops): add package structure Initial pkg/devops setup with DevOps type and path helpers. Co-Authored-By: Claude Opus 4.5 " ``` --- ### Task 2: Implement Config Loading **Files:** - Create: `pkg/devops/config.go` - Create: `pkg/devops/config_test.go` **Step 1: Write the failing test** ```go package devops import ( "os" "path/filepath" "testing" ) func TestLoadConfig_Good_Default(t *testing.T) { // Use temp home dir tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) cfg, err := LoadConfig() if err != nil { t.Fatalf("unexpected error: %v", err) } if cfg.Images.Source != "auto" { t.Errorf("expected source 'auto', got %q", cfg.Images.Source) } } func TestLoadConfig_Good_FromFile(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) configDir := filepath.Join(tmpDir, ".core") os.MkdirAll(configDir, 0755) configContent := `version: 1 images: source: github github: repo: myorg/images ` os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(configContent), 0644) cfg, err := LoadConfig() if err != nil { t.Fatalf("unexpected error: %v", err) } if cfg.Images.Source != "github" { t.Errorf("expected source 'github', got %q", cfg.Images.Source) } if cfg.Images.GitHub.Repo != "myorg/images" { t.Errorf("expected repo 'myorg/images', got %q", cfg.Images.GitHub.Repo) } } ``` **Step 2: Run test to verify it fails** Run: `cd /Users/snider/Code/Core && go test ./pkg/devops/... -run TestLoadConfig -v` Expected: FAIL (LoadConfig not defined) **Step 3: Write implementation** ```go package devops import ( "os" "path/filepath" "gopkg.in/yaml.v3" ) // Config holds global devops configuration from ~/.core/config.yaml. type Config struct { Version int `yaml:"version"` Images ImagesConfig `yaml:"images"` } // ImagesConfig holds image source configuration. type ImagesConfig struct { Source string `yaml:"source"` // auto, github, registry, cdn GitHub GitHubConfig `yaml:"github,omitempty"` Registry RegistryConfig `yaml:"registry,omitempty"` CDN CDNConfig `yaml:"cdn,omitempty"` } // GitHubConfig holds GitHub Releases configuration. type GitHubConfig struct { Repo string `yaml:"repo"` // owner/repo format } // RegistryConfig holds container registry configuration. type RegistryConfig struct { Image string `yaml:"image"` // e.g., ghcr.io/host-uk/core-devops } // CDNConfig holds CDN/S3 configuration. type CDNConfig struct { URL string `yaml:"url"` // base URL for downloads } // DefaultConfig returns sensible defaults. func DefaultConfig() *Config { return &Config{ Version: 1, Images: ImagesConfig{ Source: "auto", GitHub: GitHubConfig{ Repo: "host-uk/core-images", }, Registry: RegistryConfig{ Image: "ghcr.io/host-uk/core-devops", }, }, } } // ConfigPath returns the path to the config file. func ConfigPath() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".core", "config.yaml"), nil } // LoadConfig loads configuration from ~/.core/config.yaml. // Returns default config if file doesn't exist. func LoadConfig() (*Config, error) { configPath, err := ConfigPath() if err != nil { return DefaultConfig(), nil } data, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { return DefaultConfig(), nil } return nil, err } cfg := DefaultConfig() if err := yaml.Unmarshal(data, cfg); err != nil { return nil, err } return cfg, nil } ``` **Step 4: Run tests** Run: `cd /Users/snider/Code/Core && go test ./pkg/devops/... -run TestLoadConfig -v` Expected: PASS **Step 5: Commit** ```bash git add pkg/devops/config.go pkg/devops/config_test.go git commit -m "feat(devops): add config loading Loads ~/.core/config.yaml with image source preferences. Defaults to auto-detection with host-uk/core-images. Co-Authored-By: Claude Opus 4.5 " ``` --- ### Task 3: Implement ImageSource Interface **Files:** - Create: `pkg/devops/sources/source.go` **Step 1: Create source interface** ```go // Package sources provides image download sources for core-devops. package sources import ( "context" ) // ImageSource defines the interface for downloading dev images. type ImageSource interface { // Name returns the source identifier. Name() string // Available checks if this source can be used. Available() bool // LatestVersion returns the latest available version. LatestVersion(ctx context.Context) (string, error) // Download downloads the image to the destination path. // Reports progress via the callback if provided. Download(ctx context.Context, dest string, progress func(downloaded, total int64)) error } // SourceConfig holds configuration for a source. type SourceConfig struct { // GitHub configuration GitHubRepo string // Registry configuration RegistryImage string // CDN configuration CDNURL string // Image name (e.g., core-devops-darwin-arm64.qcow2) ImageName string } ``` **Step 2: Verify it compiles** Run: `cd /Users/snider/Code/Core && go build ./pkg/devops/...` Expected: No errors **Step 3: Commit** ```bash git add pkg/devops/sources/source.go git commit -m "feat(devops): add ImageSource interface Defines common interface for GitHub, Registry, and CDN sources. Co-Authored-By: Claude Opus 4.5 " ``` --- ### Task 4: Implement GitHub Source **Files:** - Create: `pkg/devops/sources/github.go` - Create: `pkg/devops/sources/github_test.go` **Step 1: Write the failing test** ```go package sources import ( "testing" ) func TestGitHubSource_Good_Available(t *testing.T) { src := NewGitHubSource(SourceConfig{ GitHubRepo: "host-uk/core-images", ImageName: "core-devops-darwin-arm64.qcow2", }) if src.Name() != "github" { t.Errorf("expected name 'github', got %q", src.Name()) } // Available depends on gh CLI being installed _ = src.Available() } ``` **Step 2: Run test to verify it fails** Run: `cd /Users/snider/Code/Core && go test ./pkg/devops/sources/... -run TestGitHubSource -v` Expected: FAIL **Step 3: Write implementation** ```go package sources import ( "context" "encoding/json" "fmt" "os" "os/exec" "strings" ) // GitHubSource downloads images from GitHub Releases. type GitHubSource struct { config SourceConfig } // NewGitHubSource creates a new GitHub source. func NewGitHubSource(cfg SourceConfig) *GitHubSource { return &GitHubSource{config: cfg} } // Name returns "github". func (s *GitHubSource) Name() string { return "github" } // Available checks if gh CLI is installed and authenticated. func (s *GitHubSource) Available() bool { _, err := exec.LookPath("gh") if err != nil { return false } // Check if authenticated cmd := exec.Command("gh", "auth", "status") return cmd.Run() == nil } // LatestVersion returns the latest release tag. func (s *GitHubSource) LatestVersion(ctx context.Context) (string, error) { cmd := exec.CommandContext(ctx, "gh", "release", "view", "-R", s.config.GitHubRepo, "--json", "tagName", "-q", ".tagName", ) out, err := cmd.Output() if err != nil { return "", fmt.Errorf("github.LatestVersion: %w", err) } return strings.TrimSpace(string(out)), nil } // Download downloads the image from the latest release. func (s *GitHubSource) Download(ctx context.Context, dest string, progress func(downloaded, total int64)) error { // Get release assets to find our image cmd := exec.CommandContext(ctx, "gh", "release", "download", "-R", s.config.GitHubRepo, "-p", s.config.ImageName, "-D", dest, "--clobber", ) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("github.Download: %w", err) } return nil } // releaseAsset represents a GitHub release asset. type releaseAsset struct { Name string `json:"name"` Size int64 `json:"size"` URL string `json:"url"` } ``` **Step 4: Run tests** Run: `cd /Users/snider/Code/Core && go test ./pkg/devops/sources/... -run TestGitHubSource -v` Expected: PASS **Step 5: Commit** ```bash git add pkg/devops/sources/github.go pkg/devops/sources/github_test.go git commit -m "feat(devops): add GitHub Releases source Downloads core-devops images from GitHub Releases using gh CLI. Co-Authored-By: Claude Opus 4.5 " ``` --- ### Task 5: Implement CDN Source **Files:** - Create: `pkg/devops/sources/cdn.go` - Create: `pkg/devops/sources/cdn_test.go` **Step 1: Write the failing test** ```go package sources import ( "testing" ) func TestCDNSource_Good_Available(t *testing.T) { src := NewCDNSource(SourceConfig{ CDNURL: "https://images.example.com", ImageName: "core-devops-darwin-arm64.qcow2", }) if src.Name() != "cdn" { t.Errorf("expected name 'cdn', got %q", src.Name()) } // CDN is available if URL is configured if !src.Available() { t.Error("expected Available() to be true when URL is set") } } func TestCDNSource_Bad_NoURL(t *testing.T) { src := NewCDNSource(SourceConfig{ ImageName: "core-devops-darwin-arm64.qcow2", }) if src.Available() { t.Error("expected Available() to be false when URL is empty") } } ``` **Step 2: Run test to verify it fails** Run: `cd /Users/snider/Code/Core && go test ./pkg/devops/sources/... -run TestCDNSource -v` Expected: FAIL **Step 3: Write implementation** ```go package sources import ( "context" "fmt" "io" "net/http" "os" "path/filepath" ) // CDNSource downloads images from a CDN or S3 bucket. type CDNSource struct { config SourceConfig } // NewCDNSource creates a new CDN source. func NewCDNSource(cfg SourceConfig) *CDNSource { return &CDNSource{config: cfg} } // Name returns "cdn". func (s *CDNSource) Name() string { return "cdn" } // Available checks if CDN URL is configured. func (s *CDNSource) Available() bool { return s.config.CDNURL != "" } // LatestVersion fetches version from manifest or returns "latest". func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) { // Try to fetch manifest.json for version info url := fmt.Sprintf("%s/manifest.json", s.config.CDNURL) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return "latest", nil } resp, err := http.DefaultClient.Do(req) if err != nil || resp.StatusCode != 200 { return "latest", nil } defer resp.Body.Close() // For now, just return latest - could parse manifest for version return "latest", nil } // Download downloads the image from CDN. func (s *CDNSource) Download(ctx context.Context, dest string, progress func(downloaded, total int64)) error { url := fmt.Sprintf("%s/%s", s.config.CDNURL, s.config.ImageName) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return fmt.Errorf("cdn.Download: %w", err) } resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("cdn.Download: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("cdn.Download: HTTP %d", resp.StatusCode) } // Ensure dest directory exists if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { return fmt.Errorf("cdn.Download: %w", err) } // Create destination file destPath := filepath.Join(dest, s.config.ImageName) f, err := os.Create(destPath) if err != nil { return fmt.Errorf("cdn.Download: %w", err) } defer f.Close() // Copy with progress total := resp.ContentLength var downloaded int64 buf := make([]byte, 32*1024) for { n, err := resp.Body.Read(buf) if n > 0 { if _, werr := f.Write(buf[:n]); werr != nil { return fmt.Errorf("cdn.Download: %w", werr) } downloaded += int64(n) if progress != nil { progress(downloaded, total) } } if err == io.EOF { break } if err != nil { return fmt.Errorf("cdn.Download: %w", err) } } return nil } ``` **Step 4: Run tests** Run: `cd /Users/snider/Code/Core && go test ./pkg/devops/sources/... -run TestCDNSource -v` Expected: PASS **Step 5: Commit** ```bash git add pkg/devops/sources/cdn.go pkg/devops/sources/cdn_test.go git commit -m "feat(devops): add CDN/S3 source Downloads core-devops images from custom CDN with progress reporting. Co-Authored-By: Claude Opus 4.5 " ``` --- ### Task 6: Implement ImageManager **Files:** - Create: `pkg/devops/images.go` - Create: `pkg/devops/images_test.go` **Step 1: Write the failing test** ```go package devops import ( "os" "path/filepath" "testing" ) func TestImageManager_Good_IsInstalled(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) cfg := DefaultConfig() mgr, err := NewImageManager(cfg) if err != nil { t.Fatalf("unexpected error: %v", err) } // Not installed yet if mgr.IsInstalled() { t.Error("expected IsInstalled() to be false") } // Create fake image imagePath := filepath.Join(tmpDir, ImageName()) os.WriteFile(imagePath, []byte("fake"), 0644) // Now installed if !mgr.IsInstalled() { t.Error("expected IsInstalled() to be true") } } ``` **Step 2: Run test to verify it fails** Run: `cd /Users/snider/Code/Core && go test ./pkg/devops/... -run TestImageManager -v` Expected: FAIL **Step 3: Write implementation** ```go package devops import ( "context" "encoding/json" "fmt" "os" "path/filepath" "time" "github.com/host-uk/core/pkg/devops/sources" ) // ImageManager handles image downloads and updates. type ImageManager struct { config *Config manifest *Manifest sources []sources.ImageSource } // Manifest tracks installed images. type Manifest struct { Images map[string]ImageInfo `json:"images"` path string } // ImageInfo holds metadata about an installed image. type ImageInfo struct { Version string `json:"version"` SHA256 string `json:"sha256,omitempty"` Downloaded time.Time `json:"downloaded"` Source string `json:"source"` } // NewImageManager creates a new image manager. func NewImageManager(cfg *Config) (*ImageManager, error) { imagesDir, err := ImagesDir() if err != nil { return nil, err } // Ensure images directory exists if err := os.MkdirAll(imagesDir, 0755); err != nil { return nil, err } // Load or create manifest manifestPath := filepath.Join(imagesDir, "manifest.json") manifest, err := loadManifest(manifestPath) if err != nil { return nil, err } // Build source list based on config imageName := ImageName() sourceCfg := sources.SourceConfig{ GitHubRepo: cfg.Images.GitHub.Repo, RegistryImage: cfg.Images.Registry.Image, CDNURL: cfg.Images.CDN.URL, ImageName: imageName, } var srcs []sources.ImageSource switch cfg.Images.Source { case "github": srcs = []sources.ImageSource{sources.NewGitHubSource(sourceCfg)} case "cdn": srcs = []sources.ImageSource{sources.NewCDNSource(sourceCfg)} default: // "auto" srcs = []sources.ImageSource{ sources.NewGitHubSource(sourceCfg), sources.NewCDNSource(sourceCfg), } } return &ImageManager{ config: cfg, manifest: manifest, sources: srcs, }, nil } // IsInstalled checks if the dev image is installed. func (m *ImageManager) IsInstalled() bool { path, err := ImagePath() if err != nil { return false } _, err = os.Stat(path) return err == nil } // Install downloads and installs the dev image. func (m *ImageManager) Install(ctx context.Context, progress func(downloaded, total int64)) error { imagesDir, err := ImagesDir() if err != nil { return err } // Find first available source var src sources.ImageSource for _, s := range m.sources { if s.Available() { src = s break } } if src == nil { return fmt.Errorf("no image source available") } // Get version version, err := src.LatestVersion(ctx) if err != nil { return fmt.Errorf("failed to get latest version: %w", err) } fmt.Printf("Downloading %s from %s...\n", ImageName(), src.Name()) // Download if err := src.Download(ctx, imagesDir, progress); err != nil { return err } // Update manifest m.manifest.Images[ImageName()] = ImageInfo{ Version: version, Downloaded: time.Now(), Source: src.Name(), } return m.manifest.Save() } // CheckUpdate checks if an update is available. func (m *ImageManager) CheckUpdate(ctx context.Context) (current, latest string, hasUpdate bool, err error) { info, ok := m.manifest.Images[ImageName()] if !ok { return "", "", false, fmt.Errorf("image not installed") } current = info.Version // Find first available source var src sources.ImageSource for _, s := range m.sources { if s.Available() { src = s break } } if src == nil { return current, "", false, fmt.Errorf("no image source available") } latest, err = src.LatestVersion(ctx) if err != nil { return current, "", false, err } hasUpdate = current != latest return current, latest, hasUpdate, nil } func loadManifest(path string) (*Manifest, error) { m := &Manifest{ Images: make(map[string]ImageInfo), path: path, } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return m, nil } return nil, err } if err := json.Unmarshal(data, m); err != nil { return nil, err } m.path = path return m, nil } // Save writes the manifest to disk. func (m *Manifest) Save() error { data, err := json.MarshalIndent(m, "", " ") if err != nil { return err } return os.WriteFile(m.path, data, 0644) } ``` **Step 4: Run tests** Run: `cd /Users/snider/Code/Core && go test ./pkg/devops/... -run TestImageManager -v` Expected: PASS **Step 5: Commit** ```bash git add pkg/devops/images.go pkg/devops/images_test.go git commit -m "feat(devops): add ImageManager Manages image downloads, manifest tracking, and update checking. Tries sources in priority order (GitHub, CDN). Co-Authored-By: Claude Opus 4.5 " ``` --- ### Task 7: Implement Boot/Stop/Status **Files:** - Modify: `pkg/devops/devops.go` - Create: `pkg/devops/devops_test.go` **Step 1: Add boot/stop/status methods to devops.go** ```go // Add to devops.go // BootOptions configures how to boot the dev environment. type BootOptions struct { Memory int // MB, default 4096 CPUs int // default 2 Name string // container name Fresh bool // destroy existing and start fresh } // DefaultBootOptions returns sensible defaults. func DefaultBootOptions() BootOptions { return BootOptions{ Memory: 4096, CPUs: 2, Name: "core-dev", } } // Boot starts the dev environment. func (d *DevOps) Boot(ctx context.Context, opts BootOptions) error { if !d.images.IsInstalled() { return fmt.Errorf("dev image not installed (run 'core dev install' first)") } // Check if already running if !opts.Fresh { running, err := d.IsRunning(ctx) if err == nil && running { return fmt.Errorf("dev environment already running (use 'core dev stop' first or --fresh)") } } // Stop existing if fresh if opts.Fresh { _ = d.Stop(ctx) } imagePath, err := ImagePath() if err != nil { return err } runOpts := container.RunOptions{ Name: opts.Name, Detach: true, Memory: opts.Memory, CPUs: opts.CPUs, SSHPort: 2222, } _, err = d.container.Run(ctx, imagePath, runOpts) return err } // Stop stops the dev environment. func (d *DevOps) Stop(ctx context.Context) error { containers, err := d.container.List(ctx) if err != nil { return err } for _, c := range containers { if c.Name == "core-dev" && c.Status == container.StatusRunning { return d.container.Stop(ctx, c.ID) } } return nil } // IsRunning checks if the dev environment is running. func (d *DevOps) IsRunning(ctx context.Context) (bool, error) { containers, err := d.container.List(ctx) if err != nil { return false, err } for _, c := range containers { if c.Name == "core-dev" && c.Status == container.StatusRunning { return true, nil } } return false, nil } // Status returns information about the dev environment. type DevStatus struct { Installed bool Running bool ImageVersion string ContainerID string Memory int CPUs int SSHPort int Uptime time.Duration } // Status returns the current dev environment status. func (d *DevOps) Status(ctx context.Context) (*DevStatus, error) { status := &DevStatus{ Installed: d.images.IsInstalled(), } if info, ok := d.images.manifest.Images[ImageName()]; ok { status.ImageVersion = info.Version } containers, err := d.container.List(ctx) if err != nil { return status, nil } for _, c := range containers { if c.Name == "core-dev" && c.Status == container.StatusRunning { status.Running = true status.ContainerID = c.ID status.Memory = c.Memory status.CPUs = c.CPUs status.SSHPort = 2222 status.Uptime = time.Since(c.StartedAt) break } } return status, nil } ``` **Step 2: Add missing import to devops.go** ```go import ( "time" // ... other imports ) ``` **Step 3: Verify it compiles** Run: `cd /Users/snider/Code/Core && go build ./pkg/devops/...` Expected: No errors **Step 4: Commit** ```bash git add pkg/devops/devops.go git commit -m "feat(devops): add Boot/Stop/Status methods Manages dev VM lifecycle using LinuxKitManager. Supports fresh boot, status checking, graceful stop. Co-Authored-By: Claude Opus 4.5 " ``` --- ### Task 8: Implement Shell Command **Files:** - Create: `pkg/devops/shell.go` **Step 1: Create shell.go** ```go package devops import ( "context" "fmt" "os" "os/exec" ) // ShellOptions configures the shell connection. type ShellOptions struct { Console bool // Use serial console instead of SSH Command []string // Command to run (empty = interactive shell) } // Shell connects to the dev environment. func (d *DevOps) Shell(ctx context.Context, opts ShellOptions) error { running, err := d.IsRunning(ctx) if err != nil { return err } if !running { return fmt.Errorf("dev environment not running (run 'core dev boot' first)") } if opts.Console { return d.serialConsole(ctx) } return d.sshShell(ctx, opts.Command) } // sshShell connects via SSH. func (d *DevOps) sshShell(ctx context.Context, command []string) error { args := []string{ "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", "-A", // Agent forwarding "-p", "2222", "root@localhost", } if len(command) > 0 { args = append(args, command...) } cmd := exec.CommandContext(ctx, "ssh", args...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } // serialConsole attaches to the QEMU serial console. func (d *DevOps) serialConsole(ctx context.Context) error { // Find the container to get its console socket containers, err := d.container.List(ctx) if err != nil { return err } for _, c := range containers { if c.Name == "core-dev" { // Use socat to connect to the console socket socketPath := fmt.Sprintf("/tmp/core-%s-console.sock", c.ID) cmd := exec.CommandContext(ctx, "socat", "-,raw,echo=0", "unix-connect:"+socketPath) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } } return fmt.Errorf("console not available") } ``` **Step 2: Verify it compiles** Run: `cd /Users/snider/Code/Core && go build ./pkg/devops/...` Expected: No errors **Step 3: Commit** ```bash git add pkg/devops/shell.go git commit -m "feat(devops): add Shell for SSH and console access Connects to dev VM via SSH (default) or serial console (--console). Supports SSH agent forwarding for credential access. Co-Authored-By: Claude Opus 4.5 " ``` --- ### Task 9: Implement Test Detection **Files:** - Create: `pkg/devops/test.go` - Create: `pkg/devops/test_test.go` **Step 1: Write the failing test** ```go package devops import ( "os" "path/filepath" "testing" ) func TestDetectTestCommand_Good_ComposerJSON(t *testing.T) { tmpDir := t.TempDir() os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest"}}`), 0644) cmd := DetectTestCommand(tmpDir) if cmd != "composer test" { t.Errorf("expected 'composer test', got %q", cmd) } } func TestDetectTestCommand_Good_PackageJSON(t *testing.T) { tmpDir := t.TempDir() os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"vitest"}}`), 0644) cmd := DetectTestCommand(tmpDir) if cmd != "npm test" { t.Errorf("expected 'npm test', got %q", cmd) } } func TestDetectTestCommand_Good_GoMod(t *testing.T) { tmpDir := t.TempDir() os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) cmd := DetectTestCommand(tmpDir) if cmd != "go test ./..." { t.Errorf("expected 'go test ./...', got %q", cmd) } } func TestDetectTestCommand_Good_CoreTestYaml(t *testing.T) { tmpDir := t.TempDir() coreDir := filepath.Join(tmpDir, ".core") os.MkdirAll(coreDir, 0755) os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: custom-test"), 0644) cmd := DetectTestCommand(tmpDir) if cmd != "custom-test" { t.Errorf("expected 'custom-test', got %q", cmd) } } ``` **Step 2: Run test to verify it fails** Run: `cd /Users/snider/Code/Core && go test ./pkg/devops/... -run TestDetectTestCommand -v` Expected: FAIL **Step 3: Write implementation** ```go package devops import ( "context" "encoding/json" "fmt" "os" "path/filepath" "gopkg.in/yaml.v3" ) // TestConfig holds test configuration from .core/test.yaml. type TestConfig struct { Version int `yaml:"version"` Command string `yaml:"command,omitempty"` Commands []TestCommand `yaml:"commands,omitempty"` Env map[string]string `yaml:"env,omitempty"` } // TestCommand is a named test command. type TestCommand struct { Name string `yaml:"name"` Run string `yaml:"run"` } // TestOptions configures test execution. type TestOptions struct { Name string // Run specific named command from .core/test.yaml Command []string // Override command (from -- args) } // Test runs tests in the dev environment. func (d *DevOps) Test(ctx context.Context, projectDir string, opts TestOptions) error { running, err := d.IsRunning(ctx) if err != nil { return err } if !running { return fmt.Errorf("dev environment not running (run 'core dev boot' first)") } var cmd string // Priority: explicit command > named command > auto-detect if len(opts.Command) > 0 { cmd = joinCommand(opts.Command) } else if opts.Name != "" { cfg, err := LoadTestConfig(projectDir) if err != nil { return err } for _, c := range cfg.Commands { if c.Name == opts.Name { cmd = c.Run break } } if cmd == "" { return fmt.Errorf("test command %q not found in .core/test.yaml", opts.Name) } } else { cmd = DetectTestCommand(projectDir) if cmd == "" { return fmt.Errorf("could not detect test command (create .core/test.yaml)") } } // Run via SSH return d.sshShell(ctx, []string{"cd", "/app", "&&", cmd}) } // DetectTestCommand auto-detects the test command for a project. func DetectTestCommand(projectDir string) string { // 1. Check .core/test.yaml cfg, err := LoadTestConfig(projectDir) if err == nil && cfg.Command != "" { return cfg.Command } // 2. Check composer.json if hasFile(projectDir, "composer.json") { return "composer test" } // 3. Check package.json if hasFile(projectDir, "package.json") { return "npm test" } // 4. Check go.mod if hasFile(projectDir, "go.mod") { return "go test ./..." } // 5. Check pytest if hasFile(projectDir, "pytest.ini") || hasFile(projectDir, "pyproject.toml") { return "pytest" } // 6. Check Taskfile if hasFile(projectDir, "Taskfile.yaml") || hasFile(projectDir, "Taskfile.yml") { return "task test" } return "" } // LoadTestConfig loads .core/test.yaml. func LoadTestConfig(projectDir string) (*TestConfig, error) { path := filepath.Join(projectDir, ".core", "test.yaml") data, err := os.ReadFile(path) if err != nil { return nil, err } var cfg TestConfig if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, err } return &cfg, nil } func hasFile(dir, name string) bool { _, err := os.Stat(filepath.Join(dir, name)) return err == nil } func joinCommand(parts []string) string { result := "" for i, p := range parts { if i > 0 { result += " " } result += p } return result } ``` **Step 4: Run tests** Run: `cd /Users/snider/Code/Core && go test ./pkg/devops/... -run TestDetectTestCommand -v` Expected: PASS **Step 5: Commit** ```bash git add pkg/devops/test.go pkg/devops/test_test.go git commit -m "feat(devops): add test detection and execution Auto-detects test framework from project files. Supports .core/test.yaml for custom configuration. Co-Authored-By: Claude Opus 4.5 " ``` --- ### Task 10: Implement Serve with Mount **Files:** - Create: `pkg/devops/serve.go` **Step 1: Create serve.go** ```go package devops import ( "context" "fmt" "os" "os/exec" "path/filepath" ) // ServeOptions configures the dev server. type ServeOptions struct { Port int // Port to serve on (default 8000) Path string // Subdirectory to serve (default: current dir) } // Serve mounts the project and starts a dev server. func (d *DevOps) Serve(ctx context.Context, projectDir string, opts ServeOptions) error { running, err := d.IsRunning(ctx) if err != nil { return err } if !running { return fmt.Errorf("dev environment not running (run 'core dev boot' first)") } if opts.Port == 0 { opts.Port = 8000 } servePath := projectDir if opts.Path != "" { servePath = filepath.Join(projectDir, opts.Path) } // Mount project directory via SSHFS if err := d.mountProject(ctx, servePath); err != nil { return fmt.Errorf("failed to mount project: %w", err) } // Detect and run serve command serveCmd := DetectServeCommand(servePath) fmt.Printf("Starting server: %s\n", serveCmd) fmt.Printf("Listening on http://localhost:%d\n", opts.Port) // Run serve command via SSH return d.sshShell(ctx, []string{"cd", "/app", "&&", serveCmd}) } // mountProject mounts a directory into the VM via SSHFS. func (d *DevOps) mountProject(ctx context.Context, path string) error { absPath, err := filepath.Abs(path) if err != nil { return err } // Use reverse SSHFS mount // The VM connects back to host to mount the directory cmd := exec.CommandContext(ctx, "ssh", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-R", "10000:localhost:22", // Reverse tunnel for SSHFS "-p", "2222", "root@localhost", "mkdir -p /app && sshfs -p 10000 "+os.Getenv("USER")+"@localhost:"+absPath+" /app -o allow_other", ) return cmd.Run() } // DetectServeCommand auto-detects the serve command for a project. func DetectServeCommand(projectDir string) string { // Laravel/Octane if hasFile(projectDir, "artisan") { return "php artisan octane:start --host=0.0.0.0 --port=8000" } // Node.js with dev script if hasFile(projectDir, "package.json") { if hasPackageScript(projectDir, "dev") { return "npm run dev -- --host 0.0.0.0" } if hasPackageScript(projectDir, "start") { return "npm start" } } // PHP with composer if hasFile(projectDir, "composer.json") { return "frankenphp php-server -l :8000" } // Go if hasFile(projectDir, "go.mod") { if hasFile(projectDir, "main.go") { return "go run ." } } // Python if hasFile(projectDir, "manage.py") { return "python manage.py runserver 0.0.0.0:8000" } // Fallback: simple HTTP server return "python3 -m http.server 8000" } func hasPackageScript(projectDir, script string) bool { data, err := os.ReadFile(filepath.Join(projectDir, "package.json")) if err != nil { return false } var pkg struct { Scripts map[string]string `json:"scripts"` } if err := json.Unmarshal(data, &pkg); err != nil { return false } _, ok := pkg.Scripts[script] return ok } ``` **Step 2: Verify it compiles** Run: `cd /Users/snider/Code/Core && go build ./pkg/devops/...` Expected: No errors **Step 3: Commit** ```bash git add pkg/devops/serve.go git commit -m "feat(devops): add Serve with project mounting Mounts project via SSHFS and runs auto-detected dev server. Supports Laravel, Node.js, PHP, Go, Python projects. Co-Authored-By: Claude Opus 4.5 " ``` --- ### Task 11: Implement Claude Sandbox **Files:** - Create: `pkg/devops/claude.go` **Step 1: Create claude.go** ```go package devops import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" ) // ClaudeOptions configures the Claude sandbox session. type ClaudeOptions struct { NoAuth bool // Don't forward any auth Auth []string // Selective auth: "gh", "anthropic", "ssh", "git" Model string // Model to use: opus, sonnet } // Claude starts a sandboxed Claude session in the dev environment. func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptions) error { // Auto-boot if not running running, err := d.IsRunning(ctx) if err != nil { return err } if !running { fmt.Println("Dev environment not running, booting...") if err := d.Boot(ctx, DefaultBootOptions()); err != nil { return fmt.Errorf("failed to boot: %w", err) } } // Mount project if err := d.mountProject(ctx, projectDir); err != nil { return fmt.Errorf("failed to mount project: %w", err) } // Prepare environment variables to forward envVars := []string{} if !opts.NoAuth { authTypes := opts.Auth if len(authTypes) == 0 { authTypes = []string{"gh", "anthropic", "ssh", "git"} } for _, auth := range authTypes { switch auth { case "anthropic": if key := os.Getenv("ANTHROPIC_API_KEY"); key != "" { envVars = append(envVars, "ANTHROPIC_API_KEY="+key) } case "git": // Forward git config name, _ := exec.Command("git", "config", "user.name").Output() email, _ := exec.Command("git", "config", "user.email").Output() if len(name) > 0 { envVars = append(envVars, "GIT_AUTHOR_NAME="+strings.TrimSpace(string(name))) envVars = append(envVars, "GIT_COMMITTER_NAME="+strings.TrimSpace(string(name))) } if len(email) > 0 { envVars = append(envVars, "GIT_AUTHOR_EMAIL="+strings.TrimSpace(string(email))) envVars = append(envVars, "GIT_COMMITTER_EMAIL="+strings.TrimSpace(string(email))) } } } } // Build SSH command with agent forwarding args := []string{ "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", "-A", // SSH agent forwarding "-p", "2222", } // Add environment variables for _, env := range envVars { args = append(args, "-o", "SendEnv="+strings.Split(env, "=")[0]) } args = append(args, "root@localhost") // Build command to run inside claudeCmd := "cd /app && claude" if opts.Model != "" { claudeCmd += " --model " + opts.Model } args = append(args, claudeCmd) // Set environment for SSH cmd := exec.CommandContext(ctx, "ssh", args...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Env = append(os.Environ(), envVars...) fmt.Println("Starting Claude in sandboxed environment...") fmt.Println("Project mounted at /app") fmt.Println("Auth forwarded: SSH agent" + formatAuthList(opts)) fmt.Println() return cmd.Run() } func formatAuthList(opts ClaudeOptions) string { if opts.NoAuth { return " (none)" } if len(opts.Auth) == 0 { return ", gh, anthropic, git" } return ", " + strings.Join(opts.Auth, ", ") } // CopyGHAuth copies GitHub CLI auth to the VM. func (d *DevOps) CopyGHAuth(ctx context.Context) error { home, err := os.UserHomeDir() if err != nil { return err } ghConfigDir := filepath.Join(home, ".config", "gh") if _, err := os.Stat(ghConfigDir); os.IsNotExist(err) { return nil // No gh config to copy } // Use scp to copy gh config cmd := exec.CommandContext(ctx, "scp", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-P", "2222", "-r", ghConfigDir, "root@localhost:/root/.config/", ) return cmd.Run() } ``` **Step 2: Verify it compiles** Run: `cd /Users/snider/Code/Core && go build ./pkg/devops/...` Expected: No errors **Step 3: Commit** ```bash git add pkg/devops/claude.go git commit -m "feat(devops): add Claude sandbox session Starts Claude in immutable dev environment with auth forwarding. Auto-boots VM, mounts project, forwards credentials. Co-Authored-By: Claude Opus 4.5 " ``` --- ### Task 12: Add CLI Commands **Files:** - Create: `cmd/core/cmd/dev.go` - Modify: `cmd/core/cmd/root.go` **Step 1: Create dev.go** ```go package cmd import ( "context" "fmt" "os" "strings" "github.com/charmbracelet/lipgloss" "github.com/host-uk/core/pkg/devops" "github.com/leaanthony/clir" ) var ( devHeaderStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#3b82f6")) devSuccessStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#22c55e")) devErrorStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#ef4444")) devDimStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#6b7280")) ) // AddDevCommand adds the dev command group. func AddDevCommand(app *clir.Cli) { devCmd := app.NewSubCommand("dev", "Portable development environment") devCmd.LongDescription("Manage the core-devops portable development environment.\n" + "A sandboxed, immutable Linux VM with 100+ development tools.") addDevInstallCommand(devCmd) addDevBootCommand(devCmd) addDevStopCommand(devCmd) addDevStatusCommand(devCmd) addDevShellCommand(devCmd) addDevServeCommand(devCmd) addDevTestCommand(devCmd) addDevClaudeCommand(devCmd) addDevUpdateCommand(devCmd) } func addDevInstallCommand(parent *clir.Cli) { var source string cmd := parent.NewSubCommand("install", "Download the dev environment image") cmd.StringFlag("source", "Image source: auto, github, registry, cdn", &source) cmd.Action(func() error { ctx := context.Background() d, err := devops.New() if err != nil { return err } if d.IsInstalled() { fmt.Printf("%s Dev image already installed\n", devSuccessStyle.Render("OK:")) fmt.Println("Use 'core dev update' to check for updates") return nil } fmt.Printf("%s Downloading dev image...\n", devHeaderStyle.Render("Install:")) progress := func(downloaded, total int64) { if total > 0 { pct := float64(downloaded) / float64(total) * 100 fmt.Printf("\r %.1f%% (%d / %d MB)", pct, downloaded/1024/1024, total/1024/1024) } } if err := d.Install(ctx, progress); err != nil { return err } fmt.Println() fmt.Printf("%s Dev image installed\n", devSuccessStyle.Render("Success:")) return nil }) } func addDevBootCommand(parent *clir.Cli) { var memory, cpus int var fresh bool cmd := parent.NewSubCommand("boot", "Start the dev environment") cmd.IntFlag("memory", "Memory in MB (default: 4096)", &memory) cmd.IntFlag("cpus", "Number of CPUs (default: 2)", &cpus) cmd.BoolFlag("fresh", "Destroy existing and start fresh", &fresh) cmd.Action(func() error { ctx := context.Background() d, err := devops.New() if err != nil { return err } opts := devops.DefaultBootOptions() if memory > 0 { opts.Memory = memory } if cpus > 0 { opts.CPUs = cpus } opts.Fresh = fresh fmt.Printf("%s Starting dev environment...\n", devHeaderStyle.Render("Boot:")) if err := d.Boot(ctx, opts); err != nil { return err } fmt.Printf("%s Dev environment running\n", devSuccessStyle.Render("Success:")) fmt.Printf(" Memory: %d MB\n", opts.Memory) fmt.Printf(" CPUs: %d\n", opts.CPUs) fmt.Printf(" SSH: ssh -p 2222 root@localhost\n") return nil }) } func addDevStopCommand(parent *clir.Cli) { cmd := parent.NewSubCommand("stop", "Stop the dev environment") cmd.Action(func() error { ctx := context.Background() d, err := devops.New() if err != nil { return err } fmt.Printf("%s Stopping dev environment...\n", devHeaderStyle.Render("Stop:")) if err := d.Stop(ctx); err != nil { return err } fmt.Printf("%s Dev environment stopped\n", devSuccessStyle.Render("Success:")) return nil }) } func addDevStatusCommand(parent *clir.Cli) { cmd := parent.NewSubCommand("status", "Show dev environment status") cmd.Action(func() error { ctx := context.Background() d, err := devops.New() if err != nil { return err } status, err := d.Status(ctx) if err != nil { return err } fmt.Printf("%s Dev Environment\n\n", devHeaderStyle.Render("Status:")) if status.Installed { fmt.Printf(" Image: %s\n", devSuccessStyle.Render("installed")) fmt.Printf(" Version: %s\n", status.ImageVersion) } else { fmt.Printf(" Image: %s\n", devDimStyle.Render("not installed")) } if status.Running { fmt.Printf(" Status: %s\n", devSuccessStyle.Render("running")) fmt.Printf(" ID: %s\n", status.ContainerID[:8]) fmt.Printf(" Memory: %d MB\n", status.Memory) fmt.Printf(" CPUs: %d\n", status.CPUs) fmt.Printf(" SSH: port %d\n", status.SSHPort) fmt.Printf(" Uptime: %s\n", status.Uptime.Round(1000000000)) } else { fmt.Printf(" Status: %s\n", devDimStyle.Render("stopped")) } return nil }) } func addDevShellCommand(parent *clir.Cli) { var console bool cmd := parent.NewSubCommand("shell", "Open a shell in the dev environment") cmd.BoolFlag("console", "Use serial console instead of SSH", &console) cmd.Action(func() error { ctx := context.Background() d, err := devops.New() if err != nil { return err } return d.Shell(ctx, devops.ShellOptions{Console: console}) }) } func addDevServeCommand(parent *clir.Cli) { var port int var path string cmd := parent.NewSubCommand("serve", "Mount project and start dev server") cmd.IntFlag("port", "Port to serve on (default: 8000)", &port) cmd.StringFlag("path", "Subdirectory to serve", &path) cmd.Action(func() error { ctx := context.Background() d, err := devops.New() if err != nil { return err } projectDir, _ := os.Getwd() return d.Serve(ctx, projectDir, devops.ServeOptions{Port: port, Path: path}) }) } func addDevTestCommand(parent *clir.Cli) { var name string cmd := parent.NewSubCommand("test", "Run tests in dev environment") cmd.StringFlag("name", "Run specific named test from .core/test.yaml", &name) cmd.Action(func() error { ctx := context.Background() d, err := devops.New() if err != nil { return err } projectDir, _ := os.Getwd() args := cmd.OtherArgs() return d.Test(ctx, projectDir, devops.TestOptions{ Name: name, Command: args, }) }) } func addDevClaudeCommand(parent *clir.Cli) { var noAuth bool var auth string var model string cmd := parent.NewSubCommand("claude", "Start Claude in sandboxed dev environment") cmd.BoolFlag("no-auth", "Don't forward any credentials", &noAuth) cmd.StringFlag("auth", "Selective auth forwarding: gh,anthropic,ssh,git", &auth) cmd.StringFlag("model", "Model to use: opus, sonnet", &model) cmd.Action(func() error { ctx := context.Background() d, err := devops.New() if err != nil { return err } projectDir, _ := os.Getwd() var authList []string if auth != "" { authList = strings.Split(auth, ",") } return d.Claude(ctx, projectDir, devops.ClaudeOptions{ NoAuth: noAuth, Auth: authList, Model: model, }) }) } func addDevUpdateCommand(parent *clir.Cli) { var force bool cmd := parent.NewSubCommand("update", "Check for and download image updates") cmd.BoolFlag("force", "Force download even if up to date", &force) cmd.Action(func() error { ctx := context.Background() d, err := devops.New() if err != nil { return err } if !d.IsInstalled() { return fmt.Errorf("dev image not installed (run 'core dev install' first)") } fmt.Printf("%s Checking for updates...\n", devHeaderStyle.Render("Update:")) current, latest, hasUpdate, err := d.CheckUpdate(ctx) if err != nil { return err } if !hasUpdate && !force { fmt.Printf("%s Already up to date (%s)\n", devSuccessStyle.Render("OK:"), current) return nil } fmt.Printf(" Current: %s\n", current) fmt.Printf(" Latest: %s\n", latest) progress := func(downloaded, total int64) { if total > 0 { pct := float64(downloaded) / float64(total) * 100 fmt.Printf("\r Downloading: %.1f%%", pct) } } if err := d.Install(ctx, progress); err != nil { return err } fmt.Println() fmt.Printf("%s Updated to %s\n", devSuccessStyle.Render("Success:"), latest) return nil }) } ``` **Step 2: Add to root.go** Add after other command registrations: ```go AddDevCommand(app) ``` **Step 3: Verify it compiles** Run: `cd /Users/snider/Code/Core && go build ./cmd/core/...` Expected: No errors **Step 4: Commit** ```bash git add cmd/core/cmd/dev.go cmd/core/cmd/root.go git commit -m "feat(cli): add dev command group Commands: - core dev install/boot/stop/status - core dev shell/serve/test - core dev claude (sandboxed AI session) - core dev update Co-Authored-By: Claude Opus 4.5 " ``` --- ### Task 13: Final Integration Test **Step 1: Build CLI** Run: `cd /Users/snider/Code/Core && go build -o bin/core ./cmd/core` Expected: No errors **Step 2: Test help output** Run: `./bin/core dev --help` Expected: Shows all dev subcommands **Step 3: Run package tests** Run: `cd /Users/snider/Code/Core && go test ./pkg/devops/... -v` Expected: All tests pass **Step 4: Update TODO.md** Mark S4.6 tasks as complete in tasks/TODO.md **Step 5: Final commit** ```bash git add -A git commit -m "chore(devops): finalize S4.6 core-devops CLI All dev commands implemented: - install/boot/stop/status - shell/serve/test - claude (sandboxed AI session) - update Co-Authored-By: Claude Opus 4.5 " ``` --- ## Summary 13 tasks covering: 1. Package structure 2. Config loading 3. ImageSource interface 4. GitHub source 5. CDN source 6. ImageManager 7. Boot/Stop/Status 8. Shell command 9. Test detection 10. Serve with mount 11. Claude sandbox 12. CLI commands 13. Integration test