diff --git a/README.md b/README.md index ee090cb..85855ba 100644 --- a/README.md +++ b/README.md @@ -108,42 +108,46 @@ go build -o sao ./cmd/sao sao init-machine ``` -2. In a repo you want `sao` to watch, create the repo config: +2. In a repo you want `sao` to watch, create the repo config and register the repo in the machine config: ```bash -sao init-repo +sao init-project ``` -3. Register that repo in the machine config: - -```bash -sao add-repo /path/to/repo -``` - -4. Validate the setup: +3. Validate the setup: ```bash sao validate ``` -5. Preview what would run: +4. Preview what would run: ```bash sao plan ``` -6. Run one cycle: +5. Run one cycle: ```bash sao once ``` -7. Or run the foreground loop: +6. Or run the foreground loop: ```bash sao ``` +You can still run the setup steps separately with `sao init-repo` and `sao add-repo /path/to/repo` when needed. + +## Update + +Update an installed binary to the latest GitHub release: + +```bash +sao update +``` + ## Default Task Selection By default, a repo is configured to look for: @@ -175,8 +179,10 @@ State: ## Supported Commands - `sao init-machine` +- `sao init-project` - `sao init-repo` - `sao add-repo /path/to/repo` +- `sao update` - `sao validate` - `sao agents` - `sao plan` diff --git a/internal/sao/sao.go b/internal/sao/sao.go index 808bbb6..bf283a1 100644 --- a/internal/sao/sao.go +++ b/internal/sao/sao.go @@ -34,10 +34,14 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error { return runOnce(ctx, stdout, stderr) case "init-machine": return initMachine(stdout) + case "init-project": + return initProject(stdout) case "init-repo": return initRepo(stdout) case "add-repo": return addRepo(args, stdout) + case "update": + return update(ctx, stdout, stderr) case "validate": return validate(stdout, stderr) case "agents": @@ -59,8 +63,10 @@ func printHelp(w io.Writer) { fmt.Fprintln(w, " sao Run the foreground orchestration loop") fmt.Fprintln(w, " sao once Run a single orchestration cycle") fmt.Fprintln(w, " sao init-machine Create ~/.config/sao/config.yaml") + fmt.Fprintln(w, " sao init-project Create repo config and register current repo") fmt.Fprintln(w, " sao init-repo Create .simple-agent-orchestration.yaml in the current repo") fmt.Fprintln(w, " sao add-repo PATH Register a repo in machine config") + fmt.Fprintln(w, " sao update Install the latest released sao binary") fmt.Fprintln(w, " sao validate Validate configs, git remotes, gh auth, and agent prerequisites") fmt.Fprintln(w, " sao agents Show configured agents") fmt.Fprintln(w, " sao plan Show ranked candidate tasks without dispatching") @@ -142,19 +148,62 @@ func initRepo(stdout io.Writer) error { if err != nil { return err } + created, err := ensureRepoConfig(repoRoot) + if err != nil { + return err + } + if created { + fmt.Fprintf(stdout, "created repo config: %s\n", config.RepoConfigPath(repoRoot)) + } else { + fmt.Fprintf(stdout, "repo config already exists: %s\n", config.RepoConfigPath(repoRoot)) + } + return nil +} + +func initProject(stdout io.Writer) error { + repoRoot, err := resolveRepoRoot(".") + if err != nil { + return err + } + + createdRepoConfig, err := ensureRepoConfig(repoRoot) + if err != nil { + return err + } + if createdRepoConfig { + fmt.Fprintf(stdout, "created repo config: %s\n", config.RepoConfigPath(repoRoot)) + } else { + fmt.Fprintf(stdout, "repo config already exists: %s\n", config.RepoConfigPath(repoRoot)) + } + + machinePath, err := config.MachineConfigPath() + if err != nil { + return err + } + cfg, err := ensureMachineConfig(machinePath) + if err != nil { + return err + } + cfg = config.AddProject(cfg, repoRoot) + if err := config.SaveMachineConfig(machinePath, cfg); err != nil { + return err + } + fmt.Fprintf(stdout, "registered repo: %s\n", repoRoot) + return nil +} + +func ensureRepoConfig(repoRoot string) (bool, error) { path := config.RepoConfigPath(repoRoot) if _, err := os.Stat(path); err == nil { - fmt.Fprintf(stdout, "repo config already exists: %s\n", path) - return nil + return false, nil } else if !errors.Is(err, os.ErrNotExist) { - return err + return false, err } cfg := config.DefaultRepoConfig() if err := config.SaveRepoConfig(path, cfg); err != nil { - return err + return false, err } - fmt.Fprintf(stdout, "created repo config: %s\n", path) - return nil + return true, nil } func addRepo(args []string, stdout io.Writer) error { @@ -181,6 +230,37 @@ func addRepo(args []string, stdout io.Writer) error { return nil } +func update(ctx context.Context, stdout, stderr io.Writer) error { + if _, err := exec.LookPath("bash"); err != nil { + return errors.New("required CLI missing: bash") + } + if _, err := exec.LookPath("curl"); err != nil { + return errors.New("required CLI missing: curl") + } + + repo := os.Getenv("SAO_REPO") + if repo == "" { + repo = "code-rabi/simple-agent-orchastration" + } + installerURL := fmt.Sprintf("https://raw.githubusercontent.com/%s/main/install.sh", repo) + + cmd := exec.CommandContext(ctx, "bash", "-c", `set -euo pipefail; curl -fsSL "$SAO_INSTALL_URL" | bash`) + cmd.Stdout = stdout + cmd.Stderr = stderr + cmd.Env = append(os.Environ(), "SAO_INSTALL_URL="+installerURL) + if os.Getenv("SAO_INSTALL_DIR") == "" { + if installDir, ok := currentInstallDir(); ok { + cmd.Env = append(cmd.Env, "SAO_INSTALL_DIR="+installDir) + } + } + + fmt.Fprintf(stdout, "updating sao from %s\n", installerURL) + if err := cmd.Run(); err != nil { + return fmt.Errorf("update failed: %w", err) + } + return nil +} + func validate(stdout, stderr io.Writer) error { var problems []string @@ -336,6 +416,41 @@ func loadMachineConfig() (config.MachineConfig, error) { return config.LoadMachineConfig(machinePath) } +func currentInstallDir() (string, bool) { + executable, err := os.Executable() + if err != nil { + return "", false + } + executable, err = filepath.EvalSymlinks(executable) + if err != nil { + return "", false + } + switch filepath.Base(executable) { + case "sao", "sao.exe": + default: + return "", false + } + + dir := filepath.Dir(executable) + if !dirIsWritable(dir) { + return "", false + } + return dir, true +} + +func dirIsWritable(dir string) bool { + file, err := os.CreateTemp(dir, ".sao-write-test-*") + if err != nil { + return false + } + name := file.Name() + if closeErr := file.Close(); closeErr != nil { + _ = os.Remove(name) + return false + } + return os.Remove(name) == nil +} + func effectivePollInterval(cfg config.MachineConfig) time.Duration { seconds := cfg.Runtime.PollIntervalSeconds if seconds <= 0 { diff --git a/internal/sao/sao_test.go b/internal/sao/sao_test.go index 5fea18b..d5d7daa 100644 --- a/internal/sao/sao_test.go +++ b/internal/sao/sao_test.go @@ -1,6 +1,12 @@ package sao import ( + "bytes" + "context" + "io" + "os" + "os/exec" + "path/filepath" "testing" "time" @@ -10,6 +16,62 @@ import ( "github.com/nitayr/simple-agent-orchastration/internal/state" ) +func TestInitProjectCreatesRepoConfigAndRegistersProject(t *testing.T) { + tmpDir := t.TempDir() + repoDir := filepath.Join(tmpDir, "repo") + if err := os.Mkdir(repoDir, 0o755); err != nil { + t.Fatalf("Mkdir() error = %v", err) + } + cmd := exec.Command("git", "init") + cmd.Dir = repoDir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init error = %v\n%s", err, output) + } + + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "config")) + t.Setenv("HOME", tmpDir) + + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + if err := os.Chdir(repoDir); err != nil { + t.Fatalf("Chdir() error = %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(originalWD); err != nil { + t.Errorf("restore working directory: %v", err) + } + }) + + var stdout bytes.Buffer + if err := Run(context.Background(), []string{"init-project"}, &stdout, io.Discard); err != nil { + t.Fatalf("Run(init-project) error = %v", err) + } + + if _, err := os.Stat(config.RepoConfigPath(repoDir)); err != nil { + t.Fatalf("repo config was not created: %v", err) + } + + machinePath, err := config.MachineConfigPath() + if err != nil { + t.Fatalf("MachineConfigPath() error = %v", err) + } + cfg, err := config.LoadMachineConfig(machinePath) + if err != nil { + t.Fatalf("LoadMachineConfig() error = %v", err) + } + if len(cfg.Projects) != 1 { + t.Fatalf("len(cfg.Projects) = %d, want 1", len(cfg.Projects)) + } + if cfg.Projects[0].Path != repoDir { + t.Fatalf("cfg.Projects[0].Path = %q, want %q", cfg.Projects[0].Path, repoDir) + } + if !cfg.Projects[0].Enabled { + t.Fatal("cfg.Projects[0].Enabled = false, want true") + } +} + func TestSelectDispatchPlansHonorsLimits(t *testing.T) { t.Parallel()