Go 252 lines
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 <repo>/.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 `/<name>/` 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 `<name>`, `<name>/`, `/<name>`, or `/<name>/`. 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)
}