From 6fe28464d513118e9a5c7854447e2c25ea2d6ede Mon Sep 17 00:00:00 2001 From: typebasedio Date: Fri, 10 Apr 2026 14:33:42 -0400 Subject: [PATCH] feat(v0.3): add CopySeedDir with task.yaml/.ctask skip CopySeedDir is the v0.3 user seed overlay primitive: recursive copy from a seed directory to a workspace, overwriting destination files. task.yaml and the .ctask metadata directory at the seed root are intentionally skipped so a stale or hostile seed cannot overwrite ctask-owned state. Missing src is a no-op (the seed directory is optional). --- internal/seed/copy.go | 93 ++++++++++++++++++++++++++++++++ internal/seed/copy_test.go | 107 +++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 internal/seed/copy.go create mode 100644 internal/seed/copy_test.go diff --git a/internal/seed/copy.go b/internal/seed/copy.go new file mode 100644 index 0000000..da69a49 --- /dev/null +++ b/internal/seed/copy.go @@ -0,0 +1,93 @@ +package seed + +import ( + "errors" + "io" + "os" + "path/filepath" +) + +// CopySeedDir recursively copies the contents of srcDir into dstDir. +// Files at the destination are overwritten. Subdirectories are preserved. +// +// The following entries at the top level of srcDir are intentionally skipped: +// - task.yaml (ctask owns this file) +// - .ctask (directory; reserved for ctask metadata) +// +// If srcDir does not exist, this function returns nil (no-op). Other I/O errors +// are returned to the caller. +func CopySeedDir(srcDir, dstDir string) error { + info, err := os.Stat(srcDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + if !info.IsDir() { + return nil + } + + entries, err := os.ReadDir(srcDir) + if err != nil { + return err + } + + for _, entry := range entries { + name := entry.Name() + if name == "task.yaml" || name == ".ctask" { + continue + } + srcPath := filepath.Join(srcDir, name) + dstPath := filepath.Join(dstDir, name) + if err := copyEntry(srcPath, dstPath); err != nil { + return err + } + } + return nil +} + +func copyEntry(src, dst string) error { + info, err := os.Stat(src) + if err != nil { + return err + } + if info.IsDir() { + if err := os.MkdirAll(dst, 0755); err != nil { + return err + } + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, e := range entries { + if err := copyEntry(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil { + return err + } + } + return nil + } + return copyFile(src, dst, info.Mode()) +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode.Perm()) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + return nil +} diff --git a/internal/seed/copy_test.go b/internal/seed/copy_test.go new file mode 100644 index 0000000..89e400a --- /dev/null +++ b/internal/seed/copy_test.go @@ -0,0 +1,107 @@ +package seed + +import ( + "os" + "path/filepath" + "testing" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(data) +} + +func TestCopySeedDirCopiesFilesAndSubdirs(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + writeFile(t, filepath.Join(src, "CLAUDE.md"), "user claude") + writeFile(t, filepath.Join(src, "notes.md"), "user notes") + writeFile(t, filepath.Join(src, "docs", "intro.md"), "doc body") + writeFile(t, filepath.Join(src, "reference", "links.md"), "links") + + if err := CopySeedDir(src, dst); err != nil { + t.Fatalf("CopySeedDir: %v", err) + } + + if got := readFile(t, filepath.Join(dst, "CLAUDE.md")); got != "user claude" { + t.Errorf("CLAUDE.md content: got %q", got) + } + if got := readFile(t, filepath.Join(dst, "docs", "intro.md")); got != "doc body" { + t.Errorf("docs/intro.md content: got %q", got) + } + if got := readFile(t, filepath.Join(dst, "reference", "links.md")); got != "links" { + t.Errorf("reference/links.md content: got %q", got) + } +} + +func TestCopySeedDirOverwritesExistingFiles(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + writeFile(t, filepath.Join(dst, "CLAUDE.md"), "builtin") + writeFile(t, filepath.Join(src, "CLAUDE.md"), "user") + + if err := CopySeedDir(src, dst); err != nil { + t.Fatalf("CopySeedDir: %v", err) + } + if got := readFile(t, filepath.Join(dst, "CLAUDE.md")); got != "user" { + t.Errorf("expected user seed to overwrite, got %q", got) + } +} + +func TestCopySeedDirSkipsTaskYAML(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + writeFile(t, filepath.Join(src, "task.yaml"), "id: bogus") + writeFile(t, filepath.Join(dst, "task.yaml"), "id: real") + + if err := CopySeedDir(src, dst); err != nil { + t.Fatalf("CopySeedDir: %v", err) + } + if got := readFile(t, filepath.Join(dst, "task.yaml")); got != "id: real" { + t.Errorf("task.yaml should not be overwritten by seed: got %q", got) + } +} + +func TestCopySeedDirSkipsCtaskDir(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + writeFile(t, filepath.Join(src, ".ctask", "manifest-start.json"), "{}") + writeFile(t, filepath.Join(src, "CLAUDE.md"), "ok") + + if err := CopySeedDir(src, dst); err != nil { + t.Fatalf("CopySeedDir: %v", err) + } + if _, err := os.Stat(filepath.Join(dst, ".ctask")); !os.IsNotExist(err) { + t.Errorf(".ctask should be skipped, but it exists in dst") + } + if got := readFile(t, filepath.Join(dst, "CLAUDE.md")); got != "ok" { + t.Errorf("CLAUDE.md content: got %q", got) + } +} + +func TestCopySeedDirMissingSrcIsNoOp(t *testing.T) { + dst := t.TempDir() + missing := filepath.Join(t.TempDir(), "does-not-exist") + + if err := CopySeedDir(missing, dst); err != nil { + t.Errorf("missing seed src should be a no-op, got error: %v", err) + } +}