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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user