diff --git a/backend/internal/cli/client.go b/backend/internal/cli/client.go index e6de41fe6..c4e9f4ee7 100644 --- a/backend/internal/cli/client.go +++ b/backend/internal/cli/client.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" + "io" "net/http" "time" @@ -38,12 +40,29 @@ func (e apiError) String() string { return msg } +// getJSON sends GET /api/v1/ to the running daemon and decodes a 2xx +// response into out. A missing daemon or non-2xx API envelope is rendered the +// same way as mutating calls. +func (c *commandContext) getJSON(ctx context.Context, path string, out any) error { + return c.doJSON(ctx, http.MethodGet, path, nil, out) +} + // postJSON sends body as JSON to POST /api/v1/ on the running daemon and // decodes a 2xx response into out (out may be nil). A non-2xx response becomes // an error built from the API error envelope. A missing run-file or a stale one // (dead PID) yields a clear "not running" message rather than a // connection-refused dump. func (c *commandContext) postJSON(ctx context.Context, path string, body, out any) error { + return c.doJSON(ctx, http.MethodPost, path, body, out) +} + +// deleteJSON sends DELETE /api/v1/ to the running daemon and decodes a +// 2xx response into out. +func (c *commandContext) deleteJSON(ctx context.Context, path string, out any) error { + return c.doJSON(ctx, http.MethodDelete, path, nil, out) +} + +func (c *commandContext) doJSON(ctx context.Context, method, path string, body, out any) error { cfg, err := config.Load() if err != nil { return err @@ -59,19 +78,25 @@ func (c *commandContext) postJSON(ctx context.Context, path string, body, out an return fmt.Errorf("AO daemon is not running (stale run-file at %s) — start it with `ao start`", cfg.RunFilePath) } - payload, err := json.Marshal(body) - if err != nil { - return err + var reader io.Reader = http.NoBody + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return err + } + reader = bytes.NewReader(payload) } url := fmt.Sprintf("http://%s:%d/api/v1/%s", config.LoopbackHost, info.Port, path) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + req, err := http.NewRequestWithContext(ctx, method, url, reader) if err != nil { return err } - req.Header.Set("Content-Type", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } // Reuse the injected client's transport (keeps it stubbable in tests) but - // give mutating calls far more headroom than the 2s status-probe timeout. + // give daemon API calls far more headroom than the 2s status-probe timeout. client := *c.deps.HTTPClient client.Timeout = commandTimeout resp, err := client.Do(req) @@ -90,6 +115,9 @@ func (c *commandContext) postJSON(ctx context.Context, path string, body, out an } if out != nil { if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + if errors.Is(err, io.EOF) { + return nil + } return fmt.Errorf("decode response: %w", err) } } diff --git a/backend/internal/cli/project.go b/backend/internal/cli/project.go index e25d29066..6a5c1fb29 100644 --- a/backend/internal/cli/project.go +++ b/backend/internal/cli/project.go @@ -1,7 +1,13 @@ package cli import ( + "bufio" + "errors" "fmt" + "net/url" + "sort" + "strings" + "text/tabwriter" "github.com/spf13/cobra" ) @@ -12,6 +18,19 @@ type projectAddOptions struct { name string } +type projectListOptions struct { + json bool +} + +type projectGetOptions struct { + json bool +} + +type projectRemoveOptions struct { + json bool + yes bool +} + // addProjectRequest mirrors the daemon's project AddInput body for // POST /api/v1/projects. projectId and name are optional (pointers omit them). type addProjectRequest struct { @@ -20,11 +39,43 @@ type addProjectRequest struct { Name *string `json:"name,omitempty"` } +type projectSummary struct { + ID string `json:"id"` + Name string `json:"name"` + SessionPrefix string `json:"sessionPrefix"` + ResolveError string `json:"resolveError,omitempty"` +} + +type projectDetails struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Repo string `json:"repo"` + DefaultBranch string `json:"defaultBranch"` + DefaultHarness string `json:"agent,omitempty"` + Tracker map[string]any `json:"tracker,omitempty"` + SCM map[string]any `json:"scm,omitempty"` + ResolveError string `json:"resolveError,omitempty"` +} + +type projectListResult struct { + Projects []projectSummary `json:"projects"` +} + +type projectGetResult struct { + Status string `json:"status"` + Project projectDetails `json:"project"` +} + type projectResult struct { - Project struct { - ID string `json:"id"` - Path string `json:"path"` - } `json:"project"` + Project projectDetails `json:"project"` +} + +type projectRemoveResult struct { + OK bool `json:"ok,omitempty"` + ID string `json:"id,omitempty"` + ProjectID string `json:"projectId,omitempty"` + RemovedStorageDir *bool `json:"removedStorageDir,omitempty"` } func newProjectCommand(ctx *commandContext) *cobra.Command { @@ -32,7 +83,65 @@ func newProjectCommand(ctx *commandContext) *cobra.Command { Use: "project", Short: "Manage projects", } + cmd.AddCommand(newProjectListCommand(ctx)) + cmd.AddCommand(newProjectGetCommand(ctx)) cmd.AddCommand(newProjectAddCommand(ctx)) + cmd.AddCommand(newProjectRemoveCommand(ctx)) + return cmd +} + +func newProjectListCommand(ctx *commandContext) *cobra.Command { + var opts projectListOptions + cmd := &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "List registered projects", + Args: noArgs, + RunE: func(cmd *cobra.Command, args []string) error { + var res projectListResult + if err := ctx.getJSON(cmd.Context(), "projects", &res); err != nil { + return err + } + sort.Slice(res.Projects, func(i, j int) bool { + return res.Projects[i].ID < res.Projects[j].ID + }) + if opts.json { + return writeJSON(cmd.OutOrStdout(), res) + } + return writeProjectList(cmd, res.Projects) + }, + } + cmd.Flags().BoolVar(&opts.json, "json", false, "Output projects as JSON") + return cmd +} + +func newProjectGetCommand(ctx *commandContext) *cobra.Command { + var opts projectGetOptions + cmd := &cobra.Command{ + Use: "get ", + Short: "Fetch one registered project", + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(1)(cmd, args); err != nil { + return usageError{err} + } + if strings.TrimSpace(args[0]) == "" { + return usageError{errors.New("usage: project id is required")} + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + id := strings.TrimSpace(args[0]) + var res projectGetResult + if err := ctx.getJSON(cmd.Context(), "projects/"+url.PathEscape(id), &res); err != nil { + return err + } + if opts.json { + return writeJSON(cmd.OutOrStdout(), res) + } + return writeProjectDetails(cmd, res) + }, + } + cmd.Flags().BoolVar(&opts.json, "json", false, "Output project as JSON") return cmd } @@ -69,3 +178,119 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command { f.StringVar(&opts.name, "name", "", "Display name") return cmd } + +func newProjectRemoveCommand(ctx *commandContext) *cobra.Command { + var opts projectRemoveOptions + cmd := &cobra.Command{ + Use: "rm ", + Aliases: []string{"remove", "delete"}, + Short: "Remove a registered project", + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(1)(cmd, args); err != nil { + return usageError{err} + } + if strings.TrimSpace(args[0]) == "" { + return usageError{errors.New("usage: project id is required")} + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + id := strings.TrimSpace(args[0]) + if !opts.yes { + confirmed, err := confirmProjectRemoval(cmd, id) + if err != nil { + return err + } + if !confirmed { + _, err := fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return err + } + } + var res projectRemoveResult + if err := ctx.deleteJSON(cmd.Context(), "projects/"+url.PathEscape(id), &res); err != nil { + return err + } + if opts.json { + return writeJSON(cmd.OutOrStdout(), res) + } + removedID := res.ProjectID + if removedID == "" { + removedID = res.ID + } + if removedID == "" { + removedID = id + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "removed project %s\n", removedID) + return err + }, + } + cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip confirmation prompt") + cmd.Flags().BoolVar(&opts.json, "json", false, "Output removal result as JSON") + return cmd +} + +func writeProjectList(cmd *cobra.Command, projects []projectSummary) error { + out := cmd.OutOrStdout() + if len(projects) == 0 { + if _, err := fmt.Fprintln(out, "No projects registered."); err != nil { + return err + } + _, err := fmt.Fprintln(out, "Run `ao project add --path ` to register one.") + return err + } + + tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "ID\tNAME\tSESSION PREFIX\tSTATUS"); err != nil { + return err + } + for _, p := range projects { + status := "ok" + if p.ResolveError != "" { + status = "degraded: " + p.ResolveError + } + if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", p.ID, p.Name, p.SessionPrefix, status); err != nil { + return err + } + } + return tw.Flush() +} + +func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error { + out := cmd.OutOrStdout() + p := res.Project + if _, err := fmt.Fprintf(out, "Project %s (%s)\n", p.ID, res.Status); err != nil { + return err + } + fields := []struct { + label string + value string + }{ + {label: "name", value: p.Name}, + {label: "path", value: p.Path}, + {label: "repo", value: p.Repo}, + {label: "default branch", value: p.DefaultBranch}, + {label: "default harness", value: p.DefaultHarness}, + {label: "resolve error", value: p.ResolveError}, + } + for _, f := range fields { + if f.value == "" { + continue + } + if _, err := fmt.Fprintf(out, " %s: %s\n", f.label, f.value); err != nil { + return err + } + } + return nil +} + +func confirmProjectRemoval(cmd *cobra.Command, id string) (bool, error) { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Remove project %q? Type the project id to confirm: ", id); err != nil { + return false, err + } + reader := bufio.NewReader(cmd.InOrStdin()) + line, err := reader.ReadString('\n') + if err != nil && line == "" { + return false, err + } + return strings.TrimSpace(line) == id, nil +} diff --git a/backend/internal/cli/project_test.go b/backend/internal/cli/project_test.go new file mode 100644 index 000000000..98892dc92 --- /dev/null +++ b/backend/internal/cli/project_test.go @@ -0,0 +1,312 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type projectCapture struct { + method string + path string +} + +func projectServer(t *testing.T, status int, respBody string) (*httptest.Server, *projectCapture) { + t.Helper() + capture := &projectCapture{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capture.method = r.Method + capture.path = r.URL.Path + if !strings.HasPrefix(r.URL.Path, "/api/v1/projects") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, respBody) + })) + t.Cleanup(srv.Close) + return srv, capture +} + +func TestProjectList_Success(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := projectServer(t, http.StatusOK, `{"projects":[{"id":"zeta","name":"Zeta","sessionPrefix":"zeta"},{"id":"alpha","name":"Alpha","sessionPrefix":"alpha","resolveError":"config missing"}]}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "ls") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodGet || capture.path != "/api/v1/projects" { + t.Fatalf("request = %s %s, want GET /api/v1/projects", capture.method, capture.path) + } + if !strings.Contains(out, "ID") || !strings.Contains(out, "SESSION PREFIX") { + t.Fatalf("output missing table header:\n%s", out) + } + if strings.Index(out, "alpha") > strings.Index(out, "zeta") { + t.Fatalf("projects should be sorted by id in output:\n%s", out) + } + if !strings.Contains(out, "degraded: config missing") { + t.Fatalf("output missing degraded status:\n%s", out) + } +} + +func TestProjectList_JSON(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := projectServer(t, http.StatusOK, `{"projects":[{"id":"demo","name":"Demo","sessionPrefix":"demo"}]}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "ls", "--json") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + var got projectListResult + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("decode json output: %v\nout=%s", err, out) + } + if len(got.Projects) != 1 || got.Projects[0].ID != "demo" { + t.Fatalf("projects = %#v, want demo", got.Projects) + } +} + +func TestProjectList_Empty(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := projectServer(t, http.StatusOK, `{"projects":[]}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "ls") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if !strings.Contains(out, "No projects registered") || !strings.Contains(out, "ao project add --path") { + t.Fatalf("empty output missing hint:\n%s", out) + } +} + +func TestProjectGet_Success(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := projectServer(t, http.StatusOK, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","repo":"git@example.com:demo.git","defaultBranch":"main","agent":"codex"}}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "get", "demo") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodGet || capture.path != "/api/v1/projects/demo" { + t.Fatalf("request = %s %s, want GET /api/v1/projects/demo", capture.method, capture.path) + } + for _, want := range []string{"Project demo (ok)", "name: Demo", "path: /repo/demo", "default branch: main", "default harness: codex"} { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q:\n%s", want, out) + } + } +} + +func TestProjectGet_JSON(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := projectServer(t, http.StatusOK, `{"status":"degraded","project":{"id":"demo","name":"Demo","path":"/repo/demo","resolveError":"config missing"}}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "get", "demo", "--json") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodGet || capture.path != "/api/v1/projects/demo" { + t.Fatalf("request = %s %s, want GET /api/v1/projects/demo", capture.method, capture.path) + } + var got projectGetResult + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("decode json output: %v\nout=%s", err, out) + } + if got.Status != "degraded" || got.Project.ID != "demo" || got.Project.ResolveError != "config missing" { + t.Fatalf("get json = %#v, want degraded demo with resolve error", got) + } +} + +func TestProjectGet_MissingArg(t *testing.T) { + setConfigEnv(t) + _, _, err := executeCLI(t, Deps{}, "project", "get") + if err == nil { + t.Fatal("expected missing arg error") + } + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2", got) + } +} + +func TestProjectGet_NotFound(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := projectServer(t, http.StatusNotFound, `{"error":"not_found","code":"PROJECT_NOT_FOUND","message":"Unknown project"}`) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "get", "missing") + if err == nil { + t.Fatal("expected not found error") + } + if got := ExitCode(err); got != 1 { + t.Fatalf("exit code = %d, want 1", got) + } + if !strings.Contains(err.Error(), "PROJECT_NOT_FOUND") && !strings.Contains(errOut, "PROJECT_NOT_FOUND") { + t.Fatalf("error did not surface not found envelope: %v\nstderr=%s", err, errOut) + } +} + +func TestProjectRemove_RequiresID(t *testing.T) { + setConfigEnv(t) + _, _, err := executeCLI(t, Deps{}, "project", "rm") + if err == nil { + t.Fatal("expected missing id error") + } + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2", got) + } +} + +func TestProjectRemove_NotFound(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := projectServer(t, http.StatusNotFound, `{"error":"not_found","code":"PROJECT_NOT_FOUND","message":"Unknown project"}`) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "rm", "missing", "--yes") + if err == nil { + t.Fatal("expected not found error") + } + if got := ExitCode(err); got != 1 { + t.Fatalf("exit code = %d, want 1", got) + } + if !strings.Contains(err.Error(), "PROJECT_NOT_FOUND") && !strings.Contains(errOut, "PROJECT_NOT_FOUND") { + t.Fatalf("error did not surface not found envelope: %v\nstderr=%s", err, errOut) + } +} + +func TestProjectRemove_AbortsWhenConfirmationDoesNotMatch(t *testing.T) { + setConfigEnv(t) + out, _, err := executeCLI(t, Deps{ + In: strings.NewReader("nope\n"), + }, "project", "rm", "demo") + if err != nil { + t.Fatalf("unexpected abort error: %v", err) + } + if !strings.Contains(out, "Type the project id to confirm") || !strings.Contains(out, "aborted") { + t.Fatalf("output missing prompt/abort:\n%s", out) + } +} + +func TestProjectRemove_DeletesAfterConfirmation(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := projectServer(t, http.StatusOK, `{"ok":true,"id":"demo"}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + In: strings.NewReader("demo\n"), + ProcessAlive: func(int) bool { return true }, + }, "project", "rm", "demo") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodDelete || capture.path != "/api/v1/projects/demo" { + t.Fatalf("request = %s %s, want DELETE /api/v1/projects/demo", capture.method, capture.path) + } + if !strings.Contains(out, "removed project demo") { + t.Fatalf("output missing removal message:\n%s", out) + } +} + +func TestProjectRemove_JSONDocumentedEnvelope(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := projectServer(t, http.StatusOK, `{"ok":true,"id":"demo"}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + In: strings.NewReader("wrong\n"), + ProcessAlive: func(int) bool { return true }, + }, "project", "rm", "demo", "--yes", "--json") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodDelete || capture.path != "/api/v1/projects/demo" { + t.Fatalf("request = %s %s, want DELETE /api/v1/projects/demo", capture.method, capture.path) + } + var got projectRemoveResult + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("decode json output: %v\nout=%s", err, out) + } + if !got.OK || got.ID != "demo" || got.ProjectID != "" { + t.Fatalf("remove json = %#v, want documented ok/id envelope", got) + } +} + +func TestProjectRemove_JSONBackendEnvelope(t *testing.T) { + cfg := setConfigEnv(t) + removedStorageDir := false + srv, _ := projectServer(t, http.StatusOK, `{"projectId":"demo","removedStorageDir":false}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "rm", "demo", "--yes", "--json") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + var got projectRemoveResult + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("decode json output: %v\nout=%s", err, out) + } + if got.ProjectID != "demo" || got.RemovedStorageDir == nil || *got.RemovedStorageDir != removedStorageDir { + t.Fatalf("remove json = %#v, want backend projectId/removedStorageDir envelope", got) + } +} + +func TestProjectRemove_EmptySuccessFallsBackToRequestedID(t *testing.T) { + cfg := setConfigEnv(t) + srv, _ := projectServer(t, http.StatusNoContent, ``) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "rm", "demo", "--yes") + if err != nil { + t.Fatalf("unexpected error for empty 2xx body: %v\nstderr=%s", err, errOut) + } + if !strings.Contains(out, "removed project demo") { + t.Fatalf("output missing fallback removal id:\n%s", out) + } +} + +func TestProjectRemove_YesSkipsConfirmationAndSupportsBackendRemoveEnvelope(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := projectServer(t, http.StatusOK, `{"projectId":"demo","removedStorageDir":false}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ + In: strings.NewReader("wrong\n"), + ProcessAlive: func(int) bool { return true }, + }, "project", "rm", "demo", "--yes") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodDelete || capture.path != "/api/v1/projects/demo" { + t.Fatalf("request = %s %s, want DELETE /api/v1/projects/demo", capture.method, capture.path) + } + if strings.Contains(out, "Type the project id") || !strings.Contains(out, "removed project demo") { + t.Fatalf("--yes output should skip prompt and print removal:\n%s", out) + } +}