* feat(cli): add project ls get rm * fix(cli): satisfy project confirmation lint * chore(ci): remove agent-ci dockerfile * test(cli): cover project json output * fix(cli): label project agent as default harness
This commit is contained in:
parent
fab5451a9f
commit
ae9fa0e341
|
|
@ -4,7 +4,9 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -38,12 +40,29 @@ func (e apiError) String() string {
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getJSON sends GET /api/v1/<path> 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/<path> on the running daemon and
|
// postJSON sends body as JSON to POST /api/v1/<path> on the running daemon and
|
||||||
// decodes a 2xx response into out (out may be nil). A non-2xx response becomes
|
// 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
|
// 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
|
// (dead PID) yields a clear "not running" message rather than a
|
||||||
// connection-refused dump.
|
// connection-refused dump.
|
||||||
func (c *commandContext) postJSON(ctx context.Context, path string, body, out any) error {
|
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/<path> 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()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
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)
|
var reader io.Reader = http.NoBody
|
||||||
if err != nil {
|
if body != nil {
|
||||||
return err
|
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)
|
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 {
|
if err != nil {
|
||||||
return err
|
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
|
// 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 := *c.deps.HTTPClient
|
||||||
client.Timeout = commandTimeout
|
client.Timeout = commandTimeout
|
||||||
resp, err := client.Do(req)
|
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 out != nil {
|
||||||
if err := json.NewDecoder(resp.Body).Decode(out); err != 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)
|
return fmt.Errorf("decode response: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
@ -12,6 +18,19 @@ type projectAddOptions struct {
|
||||||
name string
|
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
|
// addProjectRequest mirrors the daemon's project AddInput body for
|
||||||
// POST /api/v1/projects. projectId and name are optional (pointers omit them).
|
// POST /api/v1/projects. projectId and name are optional (pointers omit them).
|
||||||
type addProjectRequest struct {
|
type addProjectRequest struct {
|
||||||
|
|
@ -20,11 +39,43 @@ type addProjectRequest struct {
|
||||||
Name *string `json:"name,omitempty"`
|
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 {
|
type projectResult struct {
|
||||||
Project struct {
|
Project projectDetails `json:"project"`
|
||||||
ID string `json:"id"`
|
}
|
||||||
Path string `json:"path"`
|
|
||||||
} `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 {
|
func newProjectCommand(ctx *commandContext) *cobra.Command {
|
||||||
|
|
@ -32,7 +83,65 @@ func newProjectCommand(ctx *commandContext) *cobra.Command {
|
||||||
Use: "project",
|
Use: "project",
|
||||||
Short: "Manage projects",
|
Short: "Manage projects",
|
||||||
}
|
}
|
||||||
|
cmd.AddCommand(newProjectListCommand(ctx))
|
||||||
|
cmd.AddCommand(newProjectGetCommand(ctx))
|
||||||
cmd.AddCommand(newProjectAddCommand(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 <id>",
|
||||||
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,3 +178,119 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command {
|
||||||
f.StringVar(&opts.name, "name", "", "Display name")
|
f.StringVar(&opts.name, "name", "", "Display name")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newProjectRemoveCommand(ctx *commandContext) *cobra.Command {
|
||||||
|
var opts projectRemoveOptions
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "rm <id>",
|
||||||
|
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 <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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue