package config import ( "bytes" "errors" "fmt" "os" "path/filepath" "strings" ) // workspaceSubdirs are the directories scaffolded inside the // workspace by Init. The order is deterministic for predictable // reports. var workspaceSubdirs = []string{"engine", "memory", "workflows", "state", "docs"} // InitReport summarises the result of an Init call. It is safe to use // for both the first run (everything created) and idempotent re-runs // (nothing changed). type InitReport struct { RepoRoot string Username string Workspace string WorkspaceName string Profile Profile Gate [][]string CreatedDirs []string // CreatedKnowledgeDirs lists the project-type knowledge dirs created // this run inside UserDir (siblings of the engine workspace). Empty // when cfg carried no KnowledgeDirs or all already existed. CreatedKnowledgeDirs []string WroteReadme bool GitignoreChanged bool GitignorePath string AlreadyInit bool } // Init scaffolds the workspace described by cfg. It is idempotent: a // second call against an initialised tree is a no-op except for // re-reporting state. Init does not touch any file outside RepoRoot // and creates only files inside the workspace plus a possible // modification to /.gitignore (the documented opt-in // modification). func Init(cfg *Config) (InitReport, error) { if cfg == nil { return InitReport{}, errors.New("config is nil") } rep := InitReport{ RepoRoot: cfg.RepoRoot, Username: cfg.Username, Workspace: cfg.Workspace, WorkspaceName: cfg.WorkspaceName, Profile: cfg.Profile, Gate: append([][]string(nil), cfg.Gate...), } rep.AlreadyInit = IsInitialized(cfg) if err := ensureDir(cfg.Workspace); err != nil { return rep, err } for _, sub := range workspaceSubdirs { p := filepath.Join(cfg.Workspace, sub) created, err := ensureDirCreated(p) if err != nil { return rep, err } if created { rep.CreatedDirs = append(rep.CreatedDirs, sub) } } // Scaffold the project-type knowledge dirs as siblings of the engine // workspace, inside UserDir. They are resolved by `eeco init` from // the project-type detector and carried on cfg.KnowledgeDirs; a // Config built by Load alone has none, so this loop is a no-op there. // UserDir already exists (the workspace was created under it above). if cfg.UserDir != "" { for _, dir := range cfg.KnowledgeDirs { if !safeDirComponent(dir) { continue } created, err := ensureDirCreated(filepath.Join(cfg.UserDir, dir)) if err != nil { return rep, err } if created { rep.CreatedKnowledgeDirs = append(rep.CreatedKnowledgeDirs, dir) } } } rep.GitignorePath = filepath.Join(cfg.RepoRoot, ".gitignore") // The whole per-user directory is gitignored — it holds the engine // workspace plus the knowledge dirs. A Config not produced by Load // (no Username) falls back to ignoring just the workspace name. ignoreName := cfg.Username if ignoreName == "" { ignoreName = cfg.WorkspaceName } changed, err := ensureIgnored(rep.GitignorePath, ignoreName) if err != nil { return rep, fmt.Errorf("update .gitignore: %w", err) } rep.GitignoreChanged = changed wrote, err := writeReadme(cfg) if err != nil { return rep, fmt.Errorf("write workspace README: %w", err) } rep.WroteReadme = wrote return rep, nil } // IsInitialized reports whether the workspace described by cfg looks // scaffolded. The check is structural: all canonical subdirectories // must exist as directories. func IsInitialized(cfg *Config) bool { if cfg == nil { return false } for _, sub := range workspaceSubdirs { info, err := os.Stat(filepath.Join(cfg.Workspace, sub)) if err != nil || !info.IsDir() { return false } } return true } // safeDirComponent reports whether name is usable as a single, // non-escaping path component for a scaffolded knowledge dir. It rejects // empty names, absolute paths, multi-segment paths, and the "." / ".." // specials so a malformed catalog entry can never write outside UserDir. func safeDirComponent(name string) bool { if name == "" || name == "." || name == ".." { return false } if name != filepath.Clean(name) { return false } if filepath.IsAbs(name) || strings.ContainsAny(name, `/\`) { return false } return true } func ensureDir(path string) error { info, err := os.Stat(path) if err == nil { if !info.IsDir() { return fmt.Errorf("%s exists and is not a directory", path) } return nil } if !errors.Is(err, os.ErrNotExist) { return err } return os.MkdirAll(path, 0o755) } func ensureDirCreated(path string) (bool, error) { info, err := os.Stat(path) if err == nil { if !info.IsDir() { return false, fmt.Errorf("%s exists and is not a directory", path) } return false, nil } if !errors.Is(err, os.ErrNotExist) { return false, err } if err := os.MkdirAll(path, 0o755); err != nil { return false, err } return true, nil } // ensureIgnored adds `//` to the .gitignore file at gitignorePath // if no equivalent existing entry is present. It returns true if the // file was created or modified. Equivalent entries are exact-line // matches against ``, `/`, `/`, or `//`. The // check ignores comment lines and surrounding whitespace. func ensureIgnored(gitignorePath, name string) (bool, error) { target := "/" + name + "/" existing, err := os.ReadFile(gitignorePath) if err != nil && !errors.Is(err, os.ErrNotExist) { return false, err } equiv := map[string]struct{}{ name: {}, name + "/": {}, "/" + name: {}, "/" + name + "/": {}, } for _, raw := range strings.Split(string(existing), "\n") { line := strings.TrimSpace(raw) if line == "" || strings.HasPrefix(line, "#") { continue } if _, ok := equiv[line]; ok { return false, nil } } var buf bytes.Buffer if len(existing) > 0 && !bytes.HasSuffix(existing, []byte("\n")) { buf.WriteByte('\n') } buf.WriteString(target) buf.WriteByte('\n') f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return false, err } defer f.Close() if _, err := f.Write(buf.Bytes()); err != nil { return false, err } return true, nil } // writeReadme drops a short, neutral README at the workspace root the // first time Init runs. Subsequent runs leave any existing README // untouched. func writeReadme(cfg *Config) (bool, error) { p := filepath.Join(cfg.Workspace, "README.md") if _, err := os.Stat(p); err == nil { return false, nil } else if !errors.Is(err, os.ErrNotExist) { return false, err } content := fmt.Sprintf(`eeco workspace This directory is the private workspace for the repository at %s. It is gitignored and must not be committed. Detected profile: %s Subdirectories: engine/ engine-side state and templates memory/ fact store, one file per fact workflows/ user-scaffolded workflows (builtins are embedded) state/ queue and other mutable runtime state docs/ per-repo documentation and handover notes Edit config.local in this directory to override the detected profile or the parse/build gate command. `, cfg.RepoRoot, cfg.Profile) return true, os.WriteFile(p, []byte(content), 0o644) }