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).
This commit is contained in:
2026-04-10 14:33:42 -04:00
parent d3e20821d7
commit 6fe28464d5
2 changed files with 200 additions and 0 deletions
+93
View File
@@ -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
}
+107
View File
@@ -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)
}
}