diff --git a/README.md b/README.md index 2e71f53..1d6307d 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Two ways to have it. `--runtime docker` pulls the published image, and is what w Both put the index in the same place, `$XDG_DATA_HOME/sourceant`, so it does not matter which one indexed it. The container runs as whoever installed, so what it writes there belongs to them. -`sourceant ui` starts the agent and opens the view. +`sourceant ui` starts the agent and opens the view. `sourceant stop` shuts down the agent and its core without removing the index or configuration. Stopping requires an agent with stop support. | Variable | Default | Meaning | |---|---|---| @@ -59,6 +59,7 @@ Both put the index in the same place, `$XDG_DATA_HOME/sourceant`, so it does not | Command | What it does | |---|---| | `sourceant setup` | Put the agent and a core on this machine | +| `sourceant stop` | Stop the agent and its Python core or Docker container | | `sourceant status` | Whether the agent and the indexer are running | | `sourceant repos` | Repositories indexed on this machine | | `sourceant graph ` | What the indexer found in one of them | diff --git a/internal/agent/client.go b/internal/agent/client.go index 9a6dc0e..920906e 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -8,12 +8,14 @@ package agent import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" + "syscall" "time" ) @@ -80,6 +82,11 @@ func (e *Unreachable) Error() string { func (e *Unreachable) Unwrap() error { return e.Cause } +func IsConnectionRefused(err error) bool { + var unreachable *Unreachable + return errors.As(err, &unreachable) && errors.Is(unreachable.Cause, syscall.ECONNREFUSED) +} + // Client talks to one agent. type Client struct { baseURL string @@ -169,3 +176,30 @@ func detail(body []byte) string { } return strings.TrimSpace(string(body)) } + +func (c *Client) Stop(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/stop", nil) + if err != nil { + return err + } + req.Header.Set("X-Sourceant-Client", "cli") + req.Header.Set("Accept", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return &Unreachable{BaseURL: c.baseURL, Cause: err} + } + defer func() { _ = resp.Body.Close() }() + switch resp.StatusCode { + case http.StatusNoContent: + return nil + case http.StatusOK, http.StatusNotFound, http.StatusMethodNotAllowed: + // Only 204 acknowledges shutdown; a generic 200 does not confirm it. + return &Error{StatusCode: resp.StatusCode, Detail: "this agent does not support stop; update it with sourceant setup"} + default: + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return &Error{StatusCode: resp.StatusCode, Detail: detail(body)} + } +} diff --git a/internal/command/root.go b/internal/command/root.go index fd32991..570294b 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -52,6 +52,7 @@ func Run(args []string, stdout, stderr io.Writer) int { root.PersistentFlags().BoolVar(&opts.asJSON, "json", false, "Print the agent's answer as JSON") root.AddCommand( + stopCommand(opts), setupCommand(), statusCommand(opts), reposCommand(opts), diff --git a/internal/command/stop.go b/internal/command/stop.go new file mode 100644 index 0000000..d928c9a --- /dev/null +++ b/internal/command/stop.go @@ -0,0 +1,27 @@ +package command + +import ( + "fmt" + + "github.com/sourceant/cli/internal/agent" + "github.com/spf13/cobra" +) + +func stopCommand(opts *options) *cobra.Command { + return &cobra.Command{ + Use: "stop", + Short: "Stop the agent and its core", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := opts.client().Stop(cmd.Context()); err != nil { + if agent.IsConnectionRefused(err) { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "SourceAnt is already stopped.") + return nil + } + return fmt.Errorf("could not stop SourceAnt: %w", err) + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Stopped SourceAnt.") + return nil + }, + } +} diff --git a/internal/command/stop_test.go b/internal/command/stop_test.go new file mode 100644 index 0000000..e8e3d44 --- /dev/null +++ b/internal/command/stop_test.go @@ -0,0 +1,60 @@ +package command + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestStopCommand(t *testing.T) { + for _, status := range []int{http.StatusNoContent, http.StatusOK, http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusInternalServerError} { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/api/stop" || r.Header.Get("X-Sourceant-Client") != "cli" || r.Header.Get("Accept") != "application/json" { + t.Errorf("unexpected stop request: %s %s", r.Method, r.URL) + } + w.WriteHeader(status) + })) + var out, stderr bytes.Buffer + code := Run([]string{"--agent", server.URL, "stop"}, &out, &stderr) + server.Close() + if (code == 0) != (status == http.StatusNoContent) { + t.Fatalf("status=%d code=%d: %s", status, code, stderr.String()) + } + if status == http.StatusOK || status == http.StatusNotFound || status == http.StatusMethodNotAllowed { + if !strings.Contains(stderr.String(), "this agent does not support stop; update it with sourceant setup") { + t.Fatal(stderr.String()) + } + } + if code == 0 && !strings.Contains(out.String(), "Stopped SourceAnt.") { + t.Fatal(out.String()) + } + } +} + +func TestStopAlreadyStopped(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + server.Close() + var out, stderr bytes.Buffer + if Run([]string{"--agent", server.URL, "stop"}, &out, &stderr) != 0 { + t.Fatal(stderr.String()) + } + if !strings.Contains(out.String(), "already stopped") { + t.Fatal(out.String()) + } +} + +func TestStopTimeoutIsNotAlreadyStopped(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer server.Close() + var out, stderr bytes.Buffer + if Run([]string{"--agent", server.URL, "--timeout", "20ms", "stop"}, &out, &stderr) == 0 { + t.Fatal("a timed-out stop request succeeded") + } + if strings.Contains(out.String(), "already stopped") || stderr.Len() == 0 { + t.Fatalf("stdout=%s stderr=%s", out.String(), stderr.String()) + } +}