From e081869ba23bc13a95a9129628a4656ad9e8892c Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 2 Feb 2026 05:00:10 +0000 Subject: [PATCH 1/7] feat(devops): migrate filesystem operations to io.Local abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate config.go: - os.ReadFile → io.Local.Read Migrate devops.go: - os.Stat → io.Local.IsFile Migrate images.go: - os.MkdirAll → io.Local.EnsureDir - os.Stat → io.Local.IsFile - os.ReadFile → io.Local.Read - os.WriteFile → io.Local.Write Migrate test.go: - os.ReadFile → io.Local.Read - os.Stat → io.Local.IsFile Migrate claude.go: - os.Stat → io.Local.IsDir Updated tests to reflect improved behavior: - Manifest.Save() now creates parent directories - hasFile() correctly returns false for directories Part of #101 (io.Medium migration tracking issue). Closes #107 Co-Authored-By: Claude Opus 4.5 --- pkg/devops/claude.go | 4 +++- pkg/devops/config.go | 5 +++-- pkg/devops/devops.go | 4 ++-- pkg/devops/images.go | 12 ++++++------ pkg/devops/images_test.go | 9 ++++++--- pkg/devops/serve_test.go | 4 ++-- pkg/devops/test.go | 39 ++++++++++++++++++++++++++++++--------- 7 files changed, 52 insertions(+), 25 deletions(-) diff --git a/pkg/devops/claude.go b/pkg/devops/claude.go index c6b8bcb0..adec79f3 100644 --- a/pkg/devops/claude.go +++ b/pkg/devops/claude.go @@ -7,6 +7,8 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/host-uk/core/pkg/io" ) // ClaudeOptions configures the Claude sandbox session. @@ -124,7 +126,7 @@ func (d *DevOps) CopyGHAuth(ctx context.Context) error { } ghConfigDir := filepath.Join(home, ".config", "gh") - if _, err := os.Stat(ghConfigDir); os.IsNotExist(err) { + if !io.Local.IsDir(ghConfigDir) { return nil // No gh config to copy } diff --git a/pkg/devops/config.go b/pkg/devops/config.go index 6db1e6ab..ab91790c 100644 --- a/pkg/devops/config.go +++ b/pkg/devops/config.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" + "github.com/host-uk/core/pkg/io" "gopkg.in/yaml.v3" ) @@ -69,7 +70,7 @@ func LoadConfig() (*Config, error) { return DefaultConfig(), nil } - data, err := os.ReadFile(configPath) + content, err := io.Local.Read(configPath) if err != nil { if os.IsNotExist(err) { return DefaultConfig(), nil @@ -78,7 +79,7 @@ func LoadConfig() (*Config, error) { } cfg := DefaultConfig() - if err := yaml.Unmarshal(data, cfg); err != nil { + if err := yaml.Unmarshal([]byte(content), cfg); err != nil { return nil, err } diff --git a/pkg/devops/devops.go b/pkg/devops/devops.go index 9ccffd30..9b0491c4 100644 --- a/pkg/devops/devops.go +++ b/pkg/devops/devops.go @@ -10,6 +10,7 @@ import ( "time" "github.com/host-uk/core/pkg/container" + "github.com/host-uk/core/pkg/io" ) // DevOps manages the portable development environment. @@ -75,8 +76,7 @@ func (d *DevOps) IsInstalled() bool { if err != nil { return false } - _, err = os.Stat(path) - return err == nil + return io.Local.IsFile(path) } // Install downloads and installs the dev image. diff --git a/pkg/devops/images.go b/pkg/devops/images.go index 2fee2809..e6a93edc 100644 --- a/pkg/devops/images.go +++ b/pkg/devops/images.go @@ -9,6 +9,7 @@ import ( "time" "github.com/host-uk/core/pkg/devops/sources" + "github.com/host-uk/core/pkg/io" ) // ImageManager handles image downloads and updates. @@ -40,7 +41,7 @@ func NewImageManager(cfg *Config) (*ImageManager, error) { } // Ensure images directory exists - if err := os.MkdirAll(imagesDir, 0755); err != nil { + if err := io.Local.EnsureDir(imagesDir); err != nil { return nil, err } @@ -86,8 +87,7 @@ func (m *ImageManager) IsInstalled() bool { if err != nil { return false } - _, err = os.Stat(path) - return err == nil + return io.Local.IsFile(path) } // Install downloads and installs the dev image. @@ -167,7 +167,7 @@ func loadManifest(path string) (*Manifest, error) { path: path, } - data, err := os.ReadFile(path) + content, err := io.Local.Read(path) if err != nil { if os.IsNotExist(err) { return m, nil @@ -175,7 +175,7 @@ func loadManifest(path string) (*Manifest, error) { return nil, err } - if err := json.Unmarshal(data, m); err != nil { + if err := json.Unmarshal([]byte(content), m); err != nil { return nil, err } m.path = path @@ -189,5 +189,5 @@ func (m *Manifest) Save() error { if err != nil { return err } - return os.WriteFile(m.path, data, 0644) + return io.Local.Write(m.path, string(data)) } diff --git a/pkg/devops/images_test.go b/pkg/devops/images_test.go index a9edb355..8252efb5 100644 --- a/pkg/devops/images_test.go +++ b/pkg/devops/images_test.go @@ -192,10 +192,13 @@ func TestManifest_Save_Good_CreatesDirs(t *testing.T) { } m.Images["test.img"] = ImageInfo{Version: "1.0.0"} - // Should fail because nested directories don't exist - // (Save doesn't create parent directories, it just writes to path) + // Save creates parent directories automatically via io.Local.Write err := m.Save() - assert.Error(t, err) + assert.NoError(t, err) + + // Verify file was created + _, err = os.Stat(nestedPath) + assert.NoError(t, err) } func TestManifest_Save_Good_Overwrite(t *testing.T) { diff --git a/pkg/devops/serve_test.go b/pkg/devops/serve_test.go index 3ccb78f3..54e1949f 100644 --- a/pkg/devops/serve_test.go +++ b/pkg/devops/serve_test.go @@ -131,6 +131,6 @@ func TestHasFile_Bad_Directory(t *testing.T) { err := os.Mkdir(subDir, 0755) assert.NoError(t, err) - // hasFile returns true for directories too (it's just checking existence) - assert.True(t, hasFile(tmpDir, "subdir")) + // hasFile correctly returns false for directories (only true for regular files) + assert.False(t, hasFile(tmpDir, "subdir")) } diff --git a/pkg/devops/test.go b/pkg/devops/test.go index d5116cdd..e424472e 100644 --- a/pkg/devops/test.go +++ b/pkg/devops/test.go @@ -4,10 +4,10 @@ import ( "context" "encoding/json" "fmt" - "os" "path/filepath" "strings" + "github.com/host-uk/core/pkg/io" "gopkg.in/yaml.v3" ) @@ -114,13 +114,18 @@ func DetectTestCommand(projectDir string) string { // LoadTestConfig loads .core/test.yaml. func LoadTestConfig(projectDir string) (*TestConfig, error) { path := filepath.Join(projectDir, ".core", "test.yaml") - data, err := os.ReadFile(path) + absPath, err := filepath.Abs(path) + if err != nil { + return nil, err + } + + content, err := io.Local.Read(absPath) if err != nil { return nil, err } var cfg TestConfig - if err := yaml.Unmarshal(data, &cfg); err != nil { + if err := yaml.Unmarshal([]byte(content), &cfg); err != nil { return nil, err } @@ -128,12 +133,22 @@ func LoadTestConfig(projectDir string) (*TestConfig, error) { } func hasFile(dir, name string) bool { - _, err := os.Stat(filepath.Join(dir, name)) - return err == nil + path := filepath.Join(dir, name) + absPath, err := filepath.Abs(path) + if err != nil { + return false + } + return io.Local.IsFile(absPath) } func hasPackageScript(projectDir, script string) bool { - data, err := os.ReadFile(filepath.Join(projectDir, "package.json")) + path := filepath.Join(projectDir, "package.json") + absPath, err := filepath.Abs(path) + if err != nil { + return false + } + + content, err := io.Local.Read(absPath) if err != nil { return false } @@ -141,7 +156,7 @@ func hasPackageScript(projectDir, script string) bool { var pkg struct { Scripts map[string]string `json:"scripts"` } - if err := json.Unmarshal(data, &pkg); err != nil { + if err := json.Unmarshal([]byte(content), &pkg); err != nil { return false } @@ -150,7 +165,13 @@ func hasPackageScript(projectDir, script string) bool { } func hasComposerScript(projectDir, script string) bool { - data, err := os.ReadFile(filepath.Join(projectDir, "composer.json")) + path := filepath.Join(projectDir, "composer.json") + absPath, err := filepath.Abs(path) + if err != nil { + return false + } + + content, err := io.Local.Read(absPath) if err != nil { return false } @@ -158,7 +179,7 @@ func hasComposerScript(projectDir, script string) bool { var pkg struct { Scripts map[string]interface{} `json:"scripts"` } - if err := json.Unmarshal(data, &pkg); err != nil { + if err := json.Unmarshal([]byte(content), &pkg); err != nil { return false } From 40451538d6fa84509754b8f163ecbf9c3b927951 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 2 Feb 2026 05:11:15 +0000 Subject: [PATCH 2/7] chore(io): migrate remaining packages to io.Local abstraction Migrate filesystem operations to use the io.Local abstraction for improved security, testability, and consistency: - pkg/cache: Replace os.ReadFile, WriteFile, Remove, RemoveAll with io.Local equivalents. io.Local.Write creates parent dirs automatically. - pkg/agentic: Migrate config.go and context.go to use io.Local for reading config files and gathering file context. - pkg/repos: Use io.Local.Read, Exists, IsDir, List for registry operations and git repo detection. - pkg/release: Use io.Local for config loading, existence checks, and artifact discovery. - pkg/devops/sources: Use io.Local.EnsureDir for CDN download. All paths are converted to absolute using filepath.Abs() before calling io.Local methods to handle relative paths correctly. Closes #104, closes #106, closes #108, closes #111 Co-Authored-By: Claude Opus 4.5 --- pkg/agentic/config.go | 37 +++++++++++++++++++++---------------- pkg/agentic/context.go | 16 ++++++++++++---- pkg/cache/cache.go | 32 ++++++++++++++++++++------------ pkg/devops/sources/cdn.go | 8 +++++--- pkg/release/config.go | 28 ++++++++++++++++++---------- pkg/release/release.go | 6 +++--- pkg/repos/registry.go | 39 +++++++++++++++++++++++---------------- 7 files changed, 102 insertions(+), 64 deletions(-) diff --git a/pkg/agentic/config.go b/pkg/agentic/config.go index 3ad088ad..8620e1ed 100644 --- a/pkg/agentic/config.go +++ b/pkg/agentic/config.go @@ -1,12 +1,12 @@ package agentic import ( - "bufio" "os" "path/filepath" "strings" "github.com/host-uk/core/pkg/errors" + "github.com/host-uk/core/pkg/io" "gopkg.in/yaml.v3" ) @@ -95,15 +95,19 @@ func LoadConfig(dir string) (*Config, error) { // loadEnvFile reads a .env file and extracts agentic configuration. func loadEnvFile(path string, cfg *Config) error { - file, err := os.Open(path) + absPath, err := filepath.Abs(path) if err != nil { return err } - defer file.Close() - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) + content, err := io.Local.Read(absPath) + if err != nil { + return err + } + + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) // Skip empty lines and comments if line == "" || strings.HasPrefix(line, "#") { @@ -134,17 +138,22 @@ func loadEnvFile(path string, cfg *Config) error { } } - return scanner.Err() + return nil } // loadYAMLConfig reads configuration from a YAML file. func loadYAMLConfig(path string, cfg *Config) error { - data, err := os.ReadFile(path) + absPath, err := filepath.Abs(path) if err != nil { return err } - return yaml.Unmarshal(data, cfg) + content, err := io.Local.Read(absPath) + if err != nil { + return err + } + + return yaml.Unmarshal([]byte(content), cfg) } // applyEnvOverrides applies environment variable overrides to the config. @@ -170,19 +179,15 @@ func SaveConfig(cfg *Config) error { return errors.E("agentic.SaveConfig", "failed to get home directory", err) } - configDir := filepath.Join(homeDir, ".core") - if err := os.MkdirAll(configDir, 0755); err != nil { - return errors.E("agentic.SaveConfig", "failed to create config directory", err) - } - - configPath := filepath.Join(configDir, configFileName) + configPath := filepath.Join(homeDir, ".core", configFileName) data, err := yaml.Marshal(cfg) if err != nil { return errors.E("agentic.SaveConfig", "failed to marshal config", err) } - if err := os.WriteFile(configPath, data, 0600); err != nil { + // io.Local.Write creates parent directories automatically + if err := io.Local.Write(configPath, string(data)); err != nil { return errors.E("agentic.SaveConfig", "failed to write config file", err) } diff --git a/pkg/agentic/context.go b/pkg/agentic/context.go index a31ba632..153bee15 100644 --- a/pkg/agentic/context.go +++ b/pkg/agentic/context.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/host-uk/core/pkg/errors" + "github.com/host-uk/core/pkg/io" ) // FileContent represents the content of a file for AI context. @@ -95,8 +96,12 @@ func GatherRelatedFiles(task *Task, dir string) ([]FileContent, error) { // Read files explicitly mentioned in the task for _, relPath := range task.Files { fullPath := filepath.Join(dir, relPath) + absPath, err := filepath.Abs(fullPath) + if err != nil { + continue + } - content, err := os.ReadFile(fullPath) + content, err := io.Local.Read(absPath) if err != nil { // Skip files that don't exist continue @@ -104,7 +109,7 @@ func GatherRelatedFiles(task *Task, dir string) ([]FileContent, error) { files = append(files, FileContent{ Path: relPath, - Content: string(content), + Content: content, Language: detectLanguage(relPath), }) } @@ -154,13 +159,16 @@ func findRelatedCode(task *Task, dir string) ([]FileContent, error) { } fullPath := filepath.Join(dir, line) - content, err := os.ReadFile(fullPath) + absPath, err := filepath.Abs(fullPath) + if err != nil { + continue + } + contentStr, err := io.Local.Read(absPath) if err != nil { continue } // Truncate large files - contentStr := string(content) if len(contentStr) > 5000 { contentStr = contentStr[:5000] + "\n... (truncated)" } diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go index 6081fc37..f660e421 100644 --- a/pkg/cache/cache.go +++ b/pkg/cache/cache.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "time" + + "github.com/host-uk/core/pkg/io" ) // DefaultTTL is the default cache expiry time. @@ -40,11 +42,19 @@ func New(baseDir string, ttl time.Duration) (*Cache, error) { ttl = DefaultTTL } - // Ensure cache directory exists - if err := os.MkdirAll(baseDir, 0755); err != nil { + // Convert to absolute path for io.Local + absBaseDir, err := filepath.Abs(baseDir) + if err != nil { return nil, err } + // Ensure cache directory exists + if err := io.Local.EnsureDir(absBaseDir); err != nil { + return nil, err + } + + baseDir = absBaseDir + return &Cache{ baseDir: baseDir, ttl: ttl, @@ -60,13 +70,14 @@ func (c *Cache) Path(key string) string { func (c *Cache) Get(key string, dest interface{}) (bool, error) { path := c.Path(key) - data, err := os.ReadFile(path) + content, err := io.Local.Read(path) if err != nil { if os.IsNotExist(err) { return false, nil } return false, err } + data := []byte(content) var entry Entry if err := json.Unmarshal(data, &entry); err != nil { @@ -91,11 +102,6 @@ func (c *Cache) Get(key string, dest interface{}) (bool, error) { func (c *Cache) Set(key string, data interface{}) error { path := c.Path(key) - // Ensure parent directory exists - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - // Marshal the data dataBytes, err := json.Marshal(data) if err != nil { @@ -113,13 +119,14 @@ func (c *Cache) Set(key string, data interface{}) error { return err } - return os.WriteFile(path, entryBytes, 0644) + // io.Local.Write creates parent directories automatically + return io.Local.Write(path, string(entryBytes)) } // Delete removes an item from the cache. func (c *Cache) Delete(key string) error { path := c.Path(key) - err := os.Remove(path) + err := io.Local.Delete(path) if os.IsNotExist(err) { return nil } @@ -128,17 +135,18 @@ func (c *Cache) Delete(key string) error { // Clear removes all cached items. func (c *Cache) Clear() error { - return os.RemoveAll(c.baseDir) + return io.Local.DeleteAll(c.baseDir) } // Age returns how old a cached item is, or -1 if not cached. func (c *Cache) Age(key string) time.Duration { path := c.Path(key) - data, err := os.ReadFile(path) + content, err := io.Local.Read(path) if err != nil { return -1 } + data := []byte(content) var entry Entry if err := json.Unmarshal(data, &entry); err != nil { diff --git a/pkg/devops/sources/cdn.go b/pkg/devops/sources/cdn.go index 4af8659b..41269624 100644 --- a/pkg/devops/sources/cdn.go +++ b/pkg/devops/sources/cdn.go @@ -3,10 +3,12 @@ package sources import ( "context" "fmt" - "io" + goio "io" "net/http" "os" "path/filepath" + + "github.com/host-uk/core/pkg/io" ) // CDNSource downloads images from a CDN or S3 bucket. @@ -71,7 +73,7 @@ func (s *CDNSource) Download(ctx context.Context, dest string, progress func(dow } // Ensure dest directory exists - if err := os.MkdirAll(dest, 0755); err != nil { + if err := io.Local.EnsureDir(dest); err != nil { return fmt.Errorf("cdn.Download: %w", err) } @@ -99,7 +101,7 @@ func (s *CDNSource) Download(ctx context.Context, dest string, progress func(dow progress(downloaded, total) } } - if err == io.EOF { + if err == goio.EOF { break } if err != nil { diff --git a/pkg/release/config.go b/pkg/release/config.go index ae3d15b9..24b035c7 100644 --- a/pkg/release/config.go +++ b/pkg/release/config.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "github.com/host-uk/core/pkg/io" "gopkg.in/yaml.v3" ) @@ -170,8 +171,12 @@ type ChangelogConfig struct { // Returns an error if the file exists but cannot be parsed. func LoadConfig(dir string) (*Config, error) { configPath := filepath.Join(dir, ConfigDir, ConfigFileName) + absPath, err := filepath.Abs(configPath) + if err != nil { + return nil, fmt.Errorf("release.LoadConfig: failed to resolve path: %w", err) + } - data, err := os.ReadFile(configPath) + content, err := io.Local.Read(absPath) if err != nil { if os.IsNotExist(err) { cfg := DefaultConfig() @@ -182,7 +187,7 @@ func LoadConfig(dir string) (*Config, error) { } var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { + if err := yaml.Unmarshal([]byte(content), &cfg); err != nil { return nil, fmt.Errorf("release.LoadConfig: failed to parse config file: %w", err) } @@ -263,8 +268,12 @@ func ConfigPath(dir string) string { // ConfigExists checks if a release config file exists in the given directory. func ConfigExists(dir string) bool { - _, err := os.Stat(ConfigPath(dir)) - return err == nil + configPath := ConfigPath(dir) + absPath, err := filepath.Abs(configPath) + if err != nil { + return false + } + return io.Local.IsFile(absPath) } // GetRepository returns the repository from the config. @@ -280,11 +289,9 @@ func (c *Config) GetProjectName() string { // WriteConfig writes the config to the .core/release.yaml file. func WriteConfig(cfg *Config, dir string) error { configPath := ConfigPath(dir) - - // Ensure directory exists - configDir := filepath.Dir(configPath) - if err := os.MkdirAll(configDir, 0755); err != nil { - return fmt.Errorf("release.WriteConfig: failed to create directory: %w", err) + absPath, err := filepath.Abs(configPath) + if err != nil { + return fmt.Errorf("release.WriteConfig: failed to resolve path: %w", err) } data, err := yaml.Marshal(cfg) @@ -292,7 +299,8 @@ func WriteConfig(cfg *Config, dir string) error { return fmt.Errorf("release.WriteConfig: failed to marshal config: %w", err) } - if err := os.WriteFile(configPath, data, 0644); err != nil { + // io.Local.Write creates parent directories automatically + if err := io.Local.Write(absPath, string(data)); err != nil { return fmt.Errorf("release.WriteConfig: failed to write config file: %w", err) } diff --git a/pkg/release/release.go b/pkg/release/release.go index 699e3547..f5dd53b5 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -6,12 +6,12 @@ package release import ( "context" "fmt" - "os" "path/filepath" "strings" "github.com/host-uk/core/pkg/build" "github.com/host-uk/core/pkg/build/builders" + "github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/release/publishers" ) @@ -103,13 +103,13 @@ func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { // findArtifacts discovers pre-built artifacts in the dist directory. func findArtifacts(distDir string) ([]build.Artifact, error) { - if _, err := os.Stat(distDir); os.IsNotExist(err) { + if !io.Local.IsDir(distDir) { return nil, fmt.Errorf("dist/ directory not found") } var artifacts []build.Artifact - entries, err := os.ReadDir(distDir) + entries, err := io.Local.List(distDir) if err != nil { return nil, fmt.Errorf("failed to read dist/: %w", err) } diff --git a/pkg/repos/registry.go b/pkg/repos/registry.go index a13abdb2..6122fd42 100644 --- a/pkg/repos/registry.go +++ b/pkg/repos/registry.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "github.com/host-uk/core/pkg/io" "gopkg.in/yaml.v3" ) @@ -60,10 +61,16 @@ type Repo struct { // LoadRegistry reads and parses a repos.yaml file. func LoadRegistry(path string) (*Registry, error) { - data, err := os.ReadFile(path) + absPath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("failed to resolve path: %w", err) + } + + content, err := io.Local.Read(absPath) if err != nil { return nil, fmt.Errorf("failed to read registry file: %w", err) } + data := []byte(content) var reg Registry if err := yaml.Unmarshal(data, ®); err != nil { @@ -98,7 +105,7 @@ func FindRegistry() (string, error) { for { candidate := filepath.Join(dir, "repos.yaml") - if _, err := os.Stat(candidate); err == nil { + if io.Local.Exists(candidate) { return candidate, nil } @@ -121,7 +128,7 @@ func FindRegistry() (string, error) { } for _, p := range commonPaths { - if _, err := os.Stat(p); err == nil { + if io.Local.Exists(p) { return p, nil } } @@ -132,14 +139,19 @@ func FindRegistry() (string, error) { // ScanDirectory creates a Registry by scanning a directory for git repos. // This is used as a fallback when no repos.yaml is found. func ScanDirectory(dir string) (*Registry, error) { - entries, err := os.ReadDir(dir) + absDir, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("failed to resolve directory path: %w", err) + } + + entries, err := io.Local.List(absDir) if err != nil { return nil, fmt.Errorf("failed to read directory: %w", err) } reg := &Registry{ Version: 1, - BasePath: dir, + BasePath: absDir, Repos: make(map[string]*Repo), } @@ -149,10 +161,10 @@ func ScanDirectory(dir string) (*Registry, error) { continue } - repoPath := filepath.Join(dir, entry.Name()) + repoPath := filepath.Join(absDir, entry.Name()) gitPath := filepath.Join(repoPath, ".git") - if _, err := os.Stat(gitPath); err != nil { + if !io.Local.IsDir(gitPath) { continue // Not a git repo } @@ -176,14 +188,11 @@ func ScanDirectory(dir string) (*Registry, error) { // detectOrg tries to extract the GitHub org from a repo's origin remote. func detectOrg(repoPath string) string { // Try to read git remote - cmd := filepath.Join(repoPath, ".git", "config") - data, err := os.ReadFile(cmd) + configPath := filepath.Join(repoPath, ".git", "config") + content, err := io.Local.Read(configPath) if err != nil { return "" } - - // Simple parse for github.com URLs - content := string(data) // Look for patterns like github.com:org/repo or github.com/org/repo for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) @@ -292,15 +301,13 @@ func (r *Registry) TopologicalOrder() ([]*Repo, error) { // Exists checks if the repo directory exists on disk. func (repo *Repo) Exists() bool { - info, err := os.Stat(repo.Path) - return err == nil && info.IsDir() + return io.Local.IsDir(repo.Path) } // IsGitRepo checks if the repo directory contains a .git folder. func (repo *Repo) IsGitRepo() bool { gitPath := filepath.Join(repo.Path, ".git") - info, err := os.Stat(gitPath) - return err == nil && info.IsDir() + return io.Local.IsDir(gitPath) } // expandPath expands ~ to home directory. From 1adebbdba1d149b9638b04d894d38f02f82712b7 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 2 Feb 2026 05:17:12 +0000 Subject: [PATCH 3/7] chore(io): migrate pkg/cli and pkg/container to io.Local abstraction Continue io.Medium migration for the remaining packages: - pkg/cli/daemon.go: PIDFile Acquire/Release now use io.Local.Read, Delete, and Write for managing daemon PID files. - pkg/container/state.go: LoadState and SaveState use io.Local for JSON state persistence. EnsureLogsDir uses io.Local.EnsureDir. - pkg/container/templates.go: Template loading and directory scanning now use io.Local.IsFile, IsDir, Read, and List. - pkg/container/linuxkit.go: Image validation uses io.Local.IsFile, log file check uses io.Local.IsFile. Streaming log file creation (os.Create) remains unchanged as io.Local doesn't support streaming. Closes #105, closes #107 Co-Authored-By: Claude Opus 4.5 --- pkg/cli/daemon.go | 29 ++++++++++++++++------------- pkg/container/linuxkit.go | 33 ++++++++++++++++----------------- pkg/container/state.go | 21 ++++++++++++++------- pkg/container/templates.go | 18 ++++++++++-------- 4 files changed, 56 insertions(+), 45 deletions(-) diff --git a/pkg/cli/daemon.go b/pkg/cli/daemon.go index 8599eb56..bcee03c1 100644 --- a/pkg/cli/daemon.go +++ b/pkg/cli/daemon.go @@ -13,6 +13,7 @@ import ( "syscall" "time" + "github.com/host-uk/core/pkg/io" "golang.org/x/term" ) @@ -88,9 +89,14 @@ func (p *PIDFile) Acquire() error { p.mu.Lock() defer p.mu.Unlock() + absPath, err := filepath.Abs(p.path) + if err != nil { + return fmt.Errorf("failed to resolve PID file path: %w", err) + } + // Check if PID file exists - if data, err := os.ReadFile(p.path); err == nil { - pid, err := strconv.Atoi(string(data)) + if content, err := io.Local.Read(absPath); err == nil { + pid, err := strconv.Atoi(content) if err == nil && pid > 0 { // Check if process is still running if process, err := os.FindProcess(pid); err == nil { @@ -100,19 +106,12 @@ func (p *PIDFile) Acquire() error { } } // Stale PID file, remove it - _ = os.Remove(p.path) + _ = io.Local.Delete(absPath) } - // Ensure directory exists - if dir := filepath.Dir(p.path); dir != "." { - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create PID directory: %w", err) - } - } - - // Write current PID + // Write current PID (io.Local.Write creates parent directories automatically) pid := os.Getpid() - if err := os.WriteFile(p.path, []byte(strconv.Itoa(pid)), 0644); err != nil { + if err := io.Local.Write(absPath, strconv.Itoa(pid)); err != nil { return fmt.Errorf("failed to write PID file: %w", err) } @@ -123,7 +122,11 @@ func (p *PIDFile) Acquire() error { func (p *PIDFile) Release() error { p.mu.Lock() defer p.mu.Unlock() - return os.Remove(p.path) + absPath, err := filepath.Abs(p.path) + if err != nil { + return err + } + return io.Local.Delete(absPath) } // Path returns the PID file path. diff --git a/pkg/container/linuxkit.go b/pkg/container/linuxkit.go index 25c1ca18..e85f9c1a 100644 --- a/pkg/container/linuxkit.go +++ b/pkg/container/linuxkit.go @@ -4,11 +4,13 @@ import ( "bufio" "context" "fmt" - "io" + goio "io" "os" "os/exec" "syscall" "time" + + "github.com/host-uk/core/pkg/io" ) // LinuxKitManager implements the Manager interface for LinuxKit VMs. @@ -51,7 +53,7 @@ func NewLinuxKitManagerWithHypervisor(state *State, hypervisor Hypervisor) *Linu // Run starts a new LinuxKit VM from the given image. func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions) (*Container, error) { // Validate image exists - if _, err := os.Stat(image); err != nil { + if !io.Local.IsFile(image) { return nil, fmt.Errorf("image not found: %s", image) } @@ -190,12 +192,12 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions // Copy output to both log and stdout go func() { - mw := io.MultiWriter(logFile, os.Stdout) - _, _ = io.Copy(mw, stdout) + mw := goio.MultiWriter(logFile, os.Stdout) + _, _ = goio.Copy(mw, stdout) }() go func() { - mw := io.MultiWriter(logFile, os.Stderr) - _, _ = io.Copy(mw, stderr) + mw := goio.MultiWriter(logFile, os.Stderr) + _, _ = goio.Copy(mw, stderr) }() // Wait for the process to complete @@ -310,7 +312,7 @@ func isProcessRunning(pid int) bool { } // Logs returns a reader for the container's log output. -func (m *LinuxKitManager) Logs(ctx context.Context, id string, follow bool) (io.ReadCloser, error) { +func (m *LinuxKitManager) Logs(ctx context.Context, id string, follow bool) (goio.ReadCloser, error) { _, ok := m.state.Get(id) if !ok { return nil, fmt.Errorf("container not found: %s", id) @@ -321,11 +323,8 @@ func (m *LinuxKitManager) Logs(ctx context.Context, id string, follow bool) (io. return nil, fmt.Errorf("failed to determine log path: %w", err) } - if _, err := os.Stat(logPath); err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("no logs available for container: %s", id) - } - return nil, err + if !io.Local.IsFile(logPath) { + return nil, fmt.Errorf("no logs available for container: %s", id) } if !follow { @@ -337,7 +336,7 @@ func (m *LinuxKitManager) Logs(ctx context.Context, id string, follow bool) (io. return newFollowReader(ctx, logPath) } -// followReader implements io.ReadCloser for following log files. +// followReader implements goio.ReadCloser for following log files. type followReader struct { file *os.File ctx context.Context @@ -352,7 +351,7 @@ func newFollowReader(ctx context.Context, path string) (*followReader, error) { } // Seek to end - _, _ = file.Seek(0, io.SeekEnd) + _, _ = file.Seek(0, goio.SeekEnd) ctx, cancel := context.WithCancel(ctx) @@ -368,7 +367,7 @@ func (f *followReader) Read(p []byte) (int, error) { for { select { case <-f.ctx.Done(): - return 0, io.EOF + return 0, goio.EOF default: } @@ -376,14 +375,14 @@ func (f *followReader) Read(p []byte) (int, error) { if n > 0 { return n, nil } - if err != nil && err != io.EOF { + if err != nil && err != goio.EOF { return 0, err } // No data available, wait a bit and try again select { case <-f.ctx.Done(): - return 0, io.EOF + return 0, goio.EOF case <-time.After(100 * time.Millisecond): // Reset reader to pick up new data f.reader.Reset(f.file) diff --git a/pkg/container/state.go b/pkg/container/state.go index b8d98b97..e99bb051 100644 --- a/pkg/container/state.go +++ b/pkg/container/state.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "sync" + + "github.com/host-uk/core/pkg/io" ) // State manages persistent container state. @@ -56,7 +58,12 @@ func NewState(filePath string) *State { func LoadState(filePath string) (*State, error) { state := NewState(filePath) - data, err := os.ReadFile(filePath) + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, err + } + + content, err := io.Local.Read(absPath) if err != nil { if os.IsNotExist(err) { return state, nil @@ -64,7 +71,7 @@ func LoadState(filePath string) (*State, error) { return nil, err } - if err := json.Unmarshal(data, state); err != nil { + if err := json.Unmarshal([]byte(content), state); err != nil { return nil, err } @@ -76,9 +83,8 @@ func (s *State) SaveState() error { s.mu.RLock() defer s.mu.RUnlock() - // Ensure the directory exists - dir := filepath.Dir(s.filePath) - if err := os.MkdirAll(dir, 0755); err != nil { + absPath, err := filepath.Abs(s.filePath) + if err != nil { return err } @@ -87,7 +93,8 @@ func (s *State) SaveState() error { return err } - return os.WriteFile(s.filePath, data, 0644) + // io.Local.Write creates parent directories automatically + return io.Local.Write(absPath, string(data)) } // Add adds a container to the state and persists it. @@ -166,5 +173,5 @@ func EnsureLogsDir() error { if err != nil { return err } - return os.MkdirAll(logsDir, 0755) + return io.Local.EnsureDir(logsDir) } diff --git a/pkg/container/templates.go b/pkg/container/templates.go index b0068a00..80ec3005 100644 --- a/pkg/container/templates.go +++ b/pkg/container/templates.go @@ -7,6 +7,8 @@ import ( "path/filepath" "regexp" "strings" + + "github.com/host-uk/core/pkg/io" ) //go:embed templates/*.yml @@ -71,12 +73,12 @@ func GetTemplate(name string) (string, error) { userTemplatesDir := getUserTemplatesDir() if userTemplatesDir != "" { templatePath := filepath.Join(userTemplatesDir, name+".yml") - if _, err := os.Stat(templatePath); err == nil { - content, err := os.ReadFile(templatePath) + if io.Local.IsFile(templatePath) { + content, err := io.Local.Read(templatePath) if err != nil { return "", fmt.Errorf("failed to read user template %s: %w", name, err) } - return string(content), nil + return content, nil } } @@ -194,7 +196,7 @@ func getUserTemplatesDir() string { cwd, err := os.Getwd() if err == nil { wsDir := filepath.Join(cwd, ".core", "linuxkit") - if info, err := os.Stat(wsDir); err == nil && info.IsDir() { + if io.Local.IsDir(wsDir) { return wsDir } } @@ -206,7 +208,7 @@ func getUserTemplatesDir() string { } homeDir := filepath.Join(home, ".core", "linuxkit") - if info, err := os.Stat(homeDir); err == nil && info.IsDir() { + if io.Local.IsDir(homeDir) { return homeDir } @@ -217,7 +219,7 @@ func getUserTemplatesDir() string { func scanUserTemplates(dir string) []Template { var templates []Template - entries, err := os.ReadDir(dir) + entries, err := io.Local.List(dir) if err != nil { return templates } @@ -266,12 +268,12 @@ func scanUserTemplates(dir string) []Template { // extractTemplateDescription reads the first comment block from a YAML file // to use as a description. func extractTemplateDescription(path string) string { - content, err := os.ReadFile(path) + content, err := io.Local.Read(path) if err != nil { return "" } - lines := strings.Split(string(content), "\n") + lines := strings.Split(content, "\n") var descLines []string for _, line := range lines { From b291ec0e62cd956dd7af0afaebcb02788c277f81 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 2 Feb 2026 05:25:30 +0000 Subject: [PATCH 4/7] fix: address CodeRabbit feedback - use errors.E for context Add contextual error handling using errors.E helper as suggested: - config.go: Wrap LoadConfig read/parse errors - images.go: Wrap NewImageManager, loadManifest, and Manifest.Save errors Co-Authored-By: Claude Opus 4.5 --- pkg/devops/config.go | 5 +++-- pkg/devops/images.go | 14 +++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/pkg/devops/config.go b/pkg/devops/config.go index ab91790c..bca73ea6 100644 --- a/pkg/devops/config.go +++ b/pkg/devops/config.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" + "github.com/host-uk/core/pkg/errors" "github.com/host-uk/core/pkg/io" "gopkg.in/yaml.v3" ) @@ -75,12 +76,12 @@ func LoadConfig() (*Config, error) { if os.IsNotExist(err) { return DefaultConfig(), nil } - return nil, err + return nil, errors.E("devops.LoadConfig", "failed to read config", err) } cfg := DefaultConfig() if err := yaml.Unmarshal([]byte(content), cfg); err != nil { - return nil, err + return nil, errors.E("devops.LoadConfig", "failed to parse config", err) } return cfg, nil diff --git a/pkg/devops/images.go b/pkg/devops/images.go index e6a93edc..ddebbe57 100644 --- a/pkg/devops/images.go +++ b/pkg/devops/images.go @@ -9,6 +9,7 @@ import ( "time" "github.com/host-uk/core/pkg/devops/sources" + "github.com/host-uk/core/pkg/errors" "github.com/host-uk/core/pkg/io" ) @@ -42,7 +43,7 @@ func NewImageManager(cfg *Config) (*ImageManager, error) { // Ensure images directory exists if err := io.Local.EnsureDir(imagesDir); err != nil { - return nil, err + return nil, errors.E("devops.NewImageManager", "failed to create images directory", err) } // Load or create manifest @@ -172,11 +173,11 @@ func loadManifest(path string) (*Manifest, error) { if os.IsNotExist(err) { return m, nil } - return nil, err + return nil, errors.E("devops.loadManifest", "failed to read manifest", err) } if err := json.Unmarshal([]byte(content), m); err != nil { - return nil, err + return nil, errors.E("devops.loadManifest", "failed to parse manifest", err) } m.path = path @@ -187,7 +188,10 @@ func loadManifest(path string) (*Manifest, error) { func (m *Manifest) Save() error { data, err := json.MarshalIndent(m, "", " ") if err != nil { - return err + return errors.E("devops.Manifest.Save", "failed to marshal manifest", err) } - return io.Local.Write(m.path, string(data)) + if err := io.Local.Write(m.path, string(data)); err != nil { + return errors.E("devops.Manifest.Save", "failed to write manifest", err) + } + return nil } From 6b3e61732a510aa3b796fd1390aa43a6e87e382a Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 2 Feb 2026 05:34:10 +0000 Subject: [PATCH 5/7] fix(io): add contextual error handling with E() helper Address CodeRabbit review feedback by wrapping raw errors with the errors.E() helper to provide service/action context for debugging: - pkg/cache: wrap cache.New, Get, Set, Delete, Clear errors - pkg/devops/test: wrap LoadTestConfig path/read/parse errors - pkg/cli/daemon: wrap PIDFile.Release path resolution error - pkg/container/state: wrap LoadState/SaveState errors - pkg/container/templates: wrap GetTemplate embedded/user read errors Co-Authored-By: Claude Opus 4.5 --- pkg/cache/cache.go | 34 ++++++++++++++++++++++------------ pkg/cli/daemon.go | 7 +++++-- pkg/container/state.go | 16 ++++++++++------ pkg/container/templates.go | 7 ++++--- pkg/devops/test.go | 7 ++++--- 5 files changed, 45 insertions(+), 26 deletions(-) diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go index f660e421..6c8c8cea 100644 --- a/pkg/cache/cache.go +++ b/pkg/cache/cache.go @@ -7,6 +7,7 @@ import ( "path/filepath" "time" + "github.com/host-uk/core/pkg/errors" "github.com/host-uk/core/pkg/io" ) @@ -33,7 +34,7 @@ func New(baseDir string, ttl time.Duration) (*Cache, error) { // Use .core/cache in current working directory cwd, err := os.Getwd() if err != nil { - return nil, err + return nil, errors.E("cache.New", "failed to get working directory", err) } baseDir = filepath.Join(cwd, ".core", "cache") } @@ -45,12 +46,12 @@ func New(baseDir string, ttl time.Duration) (*Cache, error) { // Convert to absolute path for io.Local absBaseDir, err := filepath.Abs(baseDir) if err != nil { - return nil, err + return nil, errors.E("cache.New", "failed to resolve absolute path", err) } // Ensure cache directory exists if err := io.Local.EnsureDir(absBaseDir); err != nil { - return nil, err + return nil, errors.E("cache.New", "failed to create cache directory", err) } baseDir = absBaseDir @@ -75,7 +76,7 @@ func (c *Cache) Get(key string, dest interface{}) (bool, error) { if os.IsNotExist(err) { return false, nil } - return false, err + return false, errors.E("cache.Get", "failed to read cache file", err) } data := []byte(content) @@ -92,7 +93,7 @@ func (c *Cache) Get(key string, dest interface{}) (bool, error) { // Unmarshal the actual data if err := json.Unmarshal(entry.Data, dest); err != nil { - return false, err + return false, errors.E("cache.Get", "failed to unmarshal cache data", err) } return true, nil @@ -105,7 +106,7 @@ func (c *Cache) Set(key string, data interface{}) error { // Marshal the data dataBytes, err := json.Marshal(data) if err != nil { - return err + return errors.E("cache.Set", "failed to marshal data", err) } entry := Entry{ @@ -116,26 +117,35 @@ func (c *Cache) Set(key string, data interface{}) error { entryBytes, err := json.MarshalIndent(entry, "", " ") if err != nil { - return err + return errors.E("cache.Set", "failed to marshal cache entry", err) } // io.Local.Write creates parent directories automatically - return io.Local.Write(path, string(entryBytes)) + if err := io.Local.Write(path, string(entryBytes)); err != nil { + return errors.E("cache.Set", "failed to write cache file", err) + } + return nil } // Delete removes an item from the cache. func (c *Cache) Delete(key string) error { path := c.Path(key) err := io.Local.Delete(path) - if os.IsNotExist(err) { - return nil + if err != nil { + if os.IsNotExist(err) { + return nil + } + return errors.E("cache.Delete", "failed to delete cache file", err) } - return err + return nil } // Clear removes all cached items. func (c *Cache) Clear() error { - return io.Local.DeleteAll(c.baseDir) + if err := io.Local.DeleteAll(c.baseDir); err != nil { + return errors.E("cache.Clear", "failed to clear cache directory", err) + } + return nil } // Age returns how old a cached item is, or -1 if not cached. diff --git a/pkg/cli/daemon.go b/pkg/cli/daemon.go index bcee03c1..c6aa575a 100644 --- a/pkg/cli/daemon.go +++ b/pkg/cli/daemon.go @@ -124,9 +124,12 @@ func (p *PIDFile) Release() error { defer p.mu.Unlock() absPath, err := filepath.Abs(p.path) if err != nil { - return err + return fmt.Errorf("failed to resolve PID file path: %w", err) } - return io.Local.Delete(absPath) + if err := io.Local.Delete(absPath); err != nil { + return fmt.Errorf("failed to delete PID file: %w", err) + } + return nil } // Path returns the PID file path. diff --git a/pkg/container/state.go b/pkg/container/state.go index e99bb051..a5a60c32 100644 --- a/pkg/container/state.go +++ b/pkg/container/state.go @@ -6,6 +6,7 @@ import ( "path/filepath" "sync" + "github.com/host-uk/core/pkg/errors" "github.com/host-uk/core/pkg/io" ) @@ -60,7 +61,7 @@ func LoadState(filePath string) (*State, error) { absPath, err := filepath.Abs(filePath) if err != nil { - return nil, err + return nil, errors.E("container.LoadState", "failed to resolve state file path", err) } content, err := io.Local.Read(absPath) @@ -68,11 +69,11 @@ func LoadState(filePath string) (*State, error) { if os.IsNotExist(err) { return state, nil } - return nil, err + return nil, errors.E("container.LoadState", "failed to read state file", err) } if err := json.Unmarshal([]byte(content), state); err != nil { - return nil, err + return nil, errors.E("container.LoadState", "failed to parse state file", err) } return state, nil @@ -85,16 +86,19 @@ func (s *State) SaveState() error { absPath, err := filepath.Abs(s.filePath) if err != nil { - return err + return errors.E("container.SaveState", "failed to resolve state file path", err) } data, err := json.MarshalIndent(s, "", " ") if err != nil { - return err + return errors.E("container.SaveState", "failed to marshal state", err) } // io.Local.Write creates parent directories automatically - return io.Local.Write(absPath, string(data)) + if err := io.Local.Write(absPath, string(data)); err != nil { + return errors.E("container.SaveState", "failed to write state file", err) + } + return nil } // Add adds a container to the state and persists it. diff --git a/pkg/container/templates.go b/pkg/container/templates.go index 80ec3005..b553fce3 100644 --- a/pkg/container/templates.go +++ b/pkg/container/templates.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" + "github.com/host-uk/core/pkg/errors" "github.com/host-uk/core/pkg/io" ) @@ -63,7 +64,7 @@ func GetTemplate(name string) (string, error) { if t.Name == name { content, err := embeddedTemplates.ReadFile(t.Path) if err != nil { - return "", fmt.Errorf("failed to read embedded template %s: %w", name, err) + return "", errors.E("container.GetTemplate", "failed to read embedded template", err) } return string(content), nil } @@ -76,13 +77,13 @@ func GetTemplate(name string) (string, error) { if io.Local.IsFile(templatePath) { content, err := io.Local.Read(templatePath) if err != nil { - return "", fmt.Errorf("failed to read user template %s: %w", name, err) + return "", errors.E("container.GetTemplate", "failed to read user template", err) } return content, nil } } - return "", fmt.Errorf("template not found: %s", name) + return "", errors.E("container.GetTemplate", "template not found: "+name, nil) } // ApplyTemplate applies variable substitution to a template. diff --git a/pkg/devops/test.go b/pkg/devops/test.go index e424472e..d1a6834c 100644 --- a/pkg/devops/test.go +++ b/pkg/devops/test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/host-uk/core/pkg/errors" "github.com/host-uk/core/pkg/io" "gopkg.in/yaml.v3" ) @@ -116,17 +117,17 @@ func LoadTestConfig(projectDir string) (*TestConfig, error) { path := filepath.Join(projectDir, ".core", "test.yaml") absPath, err := filepath.Abs(path) if err != nil { - return nil, err + return nil, errors.E("devops.LoadTestConfig", "failed to resolve path", err) } content, err := io.Local.Read(absPath) if err != nil { - return nil, err + return nil, errors.E("devops.LoadTestConfig", "failed to read test config", err) } var cfg TestConfig if err := yaml.Unmarshal([]byte(content), &cfg); err != nil { - return nil, err + return nil, errors.E("devops.LoadTestConfig", "failed to parse test config", err) } return &cfg, nil From 5e05be6ba46e03fde6017b79045f7ef8b710a334 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 2 Feb 2026 05:39:13 +0000 Subject: [PATCH 6/7] chore(io): migrate internal/cmd/dev to io.Local abstraction - Replace os.Stat with io.Local.Stat in cmd_file_sync.go - Update test file to use io.Local.EnsureDir and io.Local.Write - Add filepath.Abs for proper path resolution before io.Local calls Closes #114 Co-Authored-By: Claude Opus 4.5 --- internal/cmd/dev/cmd_file_sync.go | 24 ++++++------------------ internal/cmd/dev/cmd_workflow_test.go | 17 +++++++++-------- 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/internal/cmd/dev/cmd_file_sync.go b/internal/cmd/dev/cmd_file_sync.go index 4886683a..bfbb936b 100644 --- a/internal/cmd/dev/cmd_file_sync.go +++ b/internal/cmd/dev/cmd_file_sync.go @@ -9,7 +9,6 @@ package dev import ( "context" - "os" "os/exec" "path/filepath" "strings" @@ -62,25 +61,14 @@ func runFileSync(source string) error { return errors.E("dev.sync", "path traversal not allowed", nil) } - // Validate source exists - sourceInfo, err := os.Stat(source) // Keep os.Stat for local source check or use coreio? coreio.Local.IsFile is bool. - // If source is local file on disk (not in medium), we can use os.Stat. - // But concept is everything is via Medium? - // User is running CLI on host. `source` is relative to CWD. - // coreio.Local uses absolute path or relative to root (which is "/" by default). - // So coreio.Local works. - if !coreio.Local.IsFile(source) { - // Might be directory - // IsFile returns false for directory. + // Convert to absolute path for io.Local + absSource, err := filepath.Abs(source) + if err != nil { + return errors.E("dev.sync", "failed to resolve source path", err) } - // Let's rely on os.Stat for initial source check to distinguish dir vs file easily if coreio doesn't expose Stat. - // coreio doesn't expose Stat. - - // Check using standard os for source determination as we are outside strict sandbox for input args potentially? - // But we should use coreio where possible. - // coreio.Local.List worked for dirs. - // Let's stick to os.Stat for source properties finding as typically allowed for CLI args. + // Validate source exists using io.Local.Stat + sourceInfo, err := coreio.Local.Stat(absSource) if err != nil { return errors.E("dev.sync", i18n.T("cmd.dev.file_sync.error.source_not_found", map[string]interface{}{"Path": source}), err) } diff --git a/internal/cmd/dev/cmd_workflow_test.go b/internal/cmd/dev/cmd_workflow_test.go index 3f0cd822..d4cf4ed3 100644 --- a/internal/cmd/dev/cmd_workflow_test.go +++ b/internal/cmd/dev/cmd_workflow_test.go @@ -1,28 +1,29 @@ package dev import ( - "os" "path/filepath" "testing" + + "github.com/host-uk/core/pkg/io" ) func TestFindWorkflows_Good(t *testing.T) { // Create a temp directory with workflow files tmpDir := t.TempDir() workflowsDir := filepath.Join(tmpDir, ".github", "workflows") - if err := os.MkdirAll(workflowsDir, 0755); err != nil { + if err := io.Local.EnsureDir(workflowsDir); err != nil { t.Fatalf("Failed to create workflows dir: %v", err) } // Create some workflow files for _, name := range []string{"qa.yml", "tests.yml", "codeql.yaml"} { - if err := os.WriteFile(filepath.Join(workflowsDir, name), []byte("name: Test"), 0644); err != nil { + if err := io.Local.Write(filepath.Join(workflowsDir, name), "name: Test"); err != nil { t.Fatalf("Failed to create workflow file: %v", err) } } // Create a non-workflow file (should be ignored) - if err := os.WriteFile(filepath.Join(workflowsDir, "readme.md"), []byte("# Workflows"), 0644); err != nil { + if err := io.Local.Write(filepath.Join(workflowsDir, "readme.md"), "# Workflows"); err != nil { t.Fatalf("Failed to create readme file: %v", err) } @@ -57,12 +58,12 @@ func TestFindWorkflows_NoWorkflowsDir(t *testing.T) { func TestFindTemplateWorkflow_Good(t *testing.T) { tmpDir := t.TempDir() templatesDir := filepath.Join(tmpDir, ".github", "workflow-templates") - if err := os.MkdirAll(templatesDir, 0755); err != nil { + if err := io.Local.EnsureDir(templatesDir); err != nil { t.Fatalf("Failed to create templates dir: %v", err) } templateContent := "name: QA\non: [push]" - if err := os.WriteFile(filepath.Join(templatesDir, "qa.yml"), []byte(templateContent), 0644); err != nil { + if err := io.Local.Write(filepath.Join(templatesDir, "qa.yml"), templateContent); err != nil { t.Fatalf("Failed to create template file: %v", err) } @@ -82,12 +83,12 @@ func TestFindTemplateWorkflow_Good(t *testing.T) { func TestFindTemplateWorkflow_FallbackToWorkflows(t *testing.T) { tmpDir := t.TempDir() workflowsDir := filepath.Join(tmpDir, ".github", "workflows") - if err := os.MkdirAll(workflowsDir, 0755); err != nil { + if err := io.Local.EnsureDir(workflowsDir); err != nil { t.Fatalf("Failed to create workflows dir: %v", err) } templateContent := "name: Tests\non: [push]" - if err := os.WriteFile(filepath.Join(workflowsDir, "tests.yml"), []byte(templateContent), 0644); err != nil { + if err := io.Local.Write(filepath.Join(workflowsDir, "tests.yml"), templateContent); err != nil { t.Fatalf("Failed to create workflow file: %v", err) } From dbe5393695a19d566a1fedc387c7a3bc8bae95c5 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 2 Feb 2026 08:09:31 +0000 Subject: [PATCH 7/7] fix: use log.E instead of errors.E in cmd_file_sync --- internal/cmd/dev/cmd_file_sync.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/dev/cmd_file_sync.go b/internal/cmd/dev/cmd_file_sync.go index ea4c15fe..736c1c41 100644 --- a/internal/cmd/dev/cmd_file_sync.go +++ b/internal/cmd/dev/cmd_file_sync.go @@ -64,7 +64,7 @@ func runFileSync(source string) error { // Convert to absolute path for io.Local absSource, err := filepath.Abs(source) if err != nil { - return errors.E("dev.sync", "failed to resolve source path", err) + return log.E("dev.sync", "failed to resolve source path", err) } // Validate source exists using io.Local.Stat