From 45592e39f2994eafba07049971aa942576c8e7dd Mon Sep 17 00:00:00 2001 From: nfebe Date: Fri, 28 Aug 2026 21:34:22 +0100 Subject: [PATCH 1/6] feat: Read the local code graph from the command line The command a person types. It asks the agent what is indexed on this machine and what the indexer found in one repository, and prints a count by kind and by edge, which is what a terminal can say about a graph it cannot draw. Anything that would rather read the answer than look at it asks for JSON and gets the agent's own. It talks only to the agent, never past it to the indexer: the agent is the half that is always up and the half that knows where the indexer landed, so reaching around it would mean learning both. An agent that is not running is reported as what to do about it rather than as a socket error. --- .gitignore | 3 + Makefile | 64 +++++ README.md | 70 ++++++ VERSION | 1 + go.mod | 10 + go.sum | 10 + internal/agent/client.go | 171 ++++++++++++++ internal/command/root.go | 247 ++++++++++++++++++++ internal/command/root_test.go | 174 ++++++++++++++ internal/command/testdata/graph.json | 1 + internal/command/testdata/health.json | 1 + internal/command/testdata/repositories.json | 1 + internal/presentation/presentation.go | 38 +++ 13 files changed, 791 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 VERSION create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/agent/client.go create mode 100644 internal/command/root.go create mode 100644 internal/command/root_test.go create mode 100644 internal/command/testdata/graph.json create mode 100644 internal/command/testdata/health.json create mode 100644 internal/command/testdata/repositories.json create mode 100644 internal/presentation/presentation.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80ea5b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +sourceant +coverage.out +coverage.html diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..254fe08 --- /dev/null +++ b/Makefile @@ -0,0 +1,64 @@ +.PHONY: help deps build test test-race test-coverage fmt fmt-check vet lint lint-install clean qa + +BINARY_NAME=sourceant +VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev") +BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S') +GIT_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +LDFLAGS=-ldflags "-X github.com/sourceant/cli/internal/command.Version=$(VERSION) -X github.com/sourceant/cli/internal/command.BuildTime=$(BUILD_TIME) -X github.com/sourceant/cli/internal/command.GitCommit=$(GIT_COMMIT)" + +help: + @echo "SourceAnt CLI - build commands" + @echo "" + @echo "make deps - Download dependencies" + @echo "make build - Build the CLI binary" + @echo "make test - Run unit tests" + @echo "make test-race - Run unit tests under the race detector" + @echo "make test-coverage - Run tests with a coverage report" + @echo "make fmt - Format code with gofmt" + @echo "make fmt-check - Check gofmt formatting" + @echo "make vet - Run go vet" + @echo "make lint - Run golangci-lint" + @echo "make qa - Run fmt-check, vet, lint, and tests" + @echo "make clean - Clean build artifacts" + +deps: + go mod download + go mod tidy + +build: + go build $(LDFLAGS) -o $(BINARY_NAME) ./cmd/sourceant + +test: + go test ./... + +test-race: + go test -race ./... + +test-coverage: + go test -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report generated: coverage.html" + +fmt: + go fmt ./... + +fmt-check: + @test -z "$$(gofmt -l .)" || (echo "Run gofmt on:" && gofmt -l . && exit 1) + +vet: + go vet ./... + +lint: + @command -v golangci-lint > /dev/null 2>&1 && golangci-lint run ./... || \ + (test -x "$$(go env GOPATH)/bin/golangci-lint" && "$$(go env GOPATH)/bin/golangci-lint" run ./... || \ + (echo "golangci-lint not found. Run 'make lint-install' first." && exit 1)) + +lint-install: + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest + +qa: fmt-check vet lint test + +clean: + rm -f $(BINARY_NAME) + rm -f coverage.out coverage.html + go clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa51740 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# SourceAnt CLI + +The command a person types. It reads the code graph the SourceAnt agent keeps on this machine. + +``` +$ sourceant repos +REPOSITORY PATH +acme/billing /home/you/work/billing + +$ sourceant graph acme/billing +2215 nodes, 2006 links + +KIND COUNT +function 895 +import 854 +class 257 +python 179 + +EDGE COUNT +defines 1152 +imports 854 +``` + +## How the pieces fit + +Three processes, each with one job: + +| | | +|---|---| +| `sourceant` | this CLI, which talks only to the agent | +| `sourceant-agent` | always running: supervises the indexer, keeps the graph current, serves it | +| the SourceAnt core | Python, owns the grammars and the graph | + +The CLI never reaches past the agent. The agent is the process that is always up and the one that knows where the core is listening; going around it would mean learning both. + +## Installing + +```bash +make build +``` + +Then start the agent. See [sourceant/agent](https://github.com/sourceant/agent). + +| Variable | Default | Meaning | +|---|---|---| +| `SOURCEANT_AGENT_URL` | `http://127.0.0.1:8930` | The agent to talk to | + +`--agent`, `--timeout` and `--json` override it per command. + +## Commands + +| Command | What it does | +|---|---| +| `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 | +| `sourceant version` | What this build is | + +`--json` prints the agent's own answer, for anything that wants to read it rather than look at it. + +## Building + +```bash +make qa # fmt-check, vet, lint, test +make build +``` + +## Licence + +MIT. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4e7d52d --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/sourceant/cli + +go 1.26.1 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/agent/client.go b/internal/agent/client.go new file mode 100644 index 0000000..9a6dc0e --- /dev/null +++ b/internal/agent/client.go @@ -0,0 +1,171 @@ +// Package agent talks to the SourceAnt agent running on this machine. +// +// The CLI never reaches past the agent to the Python core. The agent is the +// process that is always up and the one that knows where the core landed; +// going around it would mean learning both. +package agent + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Status is what the agent says about itself. +type Status struct { + Version string `json:"version"` + CoreURL string `json:"core_url"` + CoreUp bool `json:"core_up"` + CoreStarts int `json:"core_starts"` + LastExit string `json:"last_exit,omitempty"` +} + +// Repository is one repository indexed on this machine. +type Repository struct { + Name string `json:"name"` + Path string `json:"path"` +} + +// Node is one file, import or symbol. +type Node struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Labels []string `json:"labels"` + Path string `json:"path"` +} + +// Link is a typed edge between two nodes. +type Link struct { + Source string `json:"source"` + Target string `json:"target"` + Type string `json:"type"` +} + +// Graph is one repository's whole scope. +type Graph struct { + Nodes []Node `json:"nodes"` + Links []Link `json:"links"` + Truncated bool `json:"truncated"` +} + +// Error is a non-2xx answer from the agent. +type Error struct { + StatusCode int + Detail string +} + +func (e *Error) Error() string { + if e.Detail == "" { + return fmt.Sprintf("the agent returned %d", e.StatusCode) + } + return e.Detail +} + +// Unreachable says the agent is not running, or not where we looked. +type Unreachable struct { + BaseURL string + Cause error +} + +func (e *Unreachable) Error() string { + return fmt.Sprintf("no agent answering at %s: %v", e.BaseURL, e.Cause) +} + +func (e *Unreachable) Unwrap() error { return e.Cause } + +// Client talks to one agent. +type Client struct { + baseURL string + http *http.Client +} + +// New builds a client for the agent at baseURL. +func New(baseURL string, timeout time.Duration) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + http: &http.Client{Timeout: timeout}, + } +} + +// BaseURL is the agent this client talks to. +func (c *Client) BaseURL() string { return c.baseURL } + +// Status asks the agent how it and the core are doing. +func (c *Client) Status(ctx context.Context) (Status, error) { + return get[Status](ctx, c, "/health", nil) +} + +// Repositories lists what is indexed on this machine. +func (c *Client) Repositories(ctx context.Context) ([]Repository, error) { + return get[[]Repository](ctx, c, "/api/repositories", nil) +} + +// GraphOptions narrows what a drawing covers. +type GraphOptions struct { + PathPrefix string + IncludeTests bool + NodeLimit int +} + +// Graph reads one repository's whole scope. +func (c *Client) Graph(ctx context.Context, repository string, opts GraphOptions) (Graph, error) { + query := url.Values{"repository": {repository}} + if opts.PathPrefix != "" { + query.Set("path_prefix", opts.PathPrefix) + } + if opts.IncludeTests { + query.Set("include_tests", "true") + } + if opts.NodeLimit > 0 { + query.Set("node_limit", strconv.Itoa(opts.NodeLimit)) + } + return get[Graph](ctx, c, "/api/graph", query) +} + +func get[T any](ctx context.Context, c *Client, path string, query url.Values) (T, error) { + var zero T + target := c.baseURL + path + if len(query) > 0 { + target += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return zero, err + } + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return zero, &Unreachable{BaseURL: c.baseURL, Cause: err} + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return zero, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return zero, &Error{StatusCode: resp.StatusCode, Detail: detail(body)} + } + if err := json.Unmarshal(body, &zero); err != nil { + return zero, fmt.Errorf("the agent answered %s with something other than JSON: %w", path, err) + } + return zero, nil +} + +func detail(body []byte) string { + var parsed struct { + Error string `json:"error"` + } + if err := json.Unmarshal(body, &parsed); err == nil && parsed.Error != "" { + return parsed.Error + } + return strings.TrimSpace(string(body)) +} diff --git a/internal/command/root.go b/internal/command/root.go new file mode 100644 index 0000000..5d2983f --- /dev/null +++ b/internal/command/root.go @@ -0,0 +1,247 @@ +// Package command is the sourceant command tree. +package command + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "sort" + "time" + + "github.com/sourceant/cli/internal/agent" + "github.com/sourceant/cli/internal/presentation" + "github.com/spf13/cobra" +) + +// Set at build time. See the Makefile. +var ( + Version = "dev" + BuildTime = "unknown" + GitCommit = "unknown" +) + +// EnvAgent points the CLI at an agent somewhere other than the default. +const EnvAgent = "SOURCEANT_AGENT_URL" + +// DefaultAgent is where the agent listens unless it was told otherwise. +const DefaultAgent = "http://127.0.0.1:8930" + +type options struct { + agentURL string + timeout time.Duration + asJSON bool +} + +// Run executes the command tree and returns the process exit code. +func Run(args []string, stdout, stderr io.Writer) int { + opts := &options{} + root := &cobra.Command{ + Use: "sourceant", + Short: "Your code, indexed on this machine", + SilenceUsage: true, + SilenceErrors: true, + } + root.SetOut(stdout) + root.SetErr(stderr) + root.SetArgs(args) + + root.PersistentFlags().StringVar(&opts.agentURL, "agent", agentDefault(), "The agent to talk to") + root.PersistentFlags().DurationVar(&opts.timeout, "timeout", 30*time.Second, "How long to wait for the agent") + root.PersistentFlags().BoolVar(&opts.asJSON, "json", false, "Print the agent's answer as JSON") + + root.AddCommand(statusCommand(opts), reposCommand(opts), graphCommand(opts), versionCommand()) + + if err := root.Execute(); err != nil { + _, _ = fmt.Fprintln(stderr, "sourceant:", message(err)) + return 1 + } + return 0 +} + +// message turns an error into the one line a person needs, which for an agent +// that is not running is what to do about it rather than the socket error. +func message(err error) string { + var unreachable *agent.Unreachable + if errors.As(err, &unreachable) { + return fmt.Sprintf("no agent answering at %s. Start it with sourceant-agent", unreachable.BaseURL) + } + return err.Error() +} + +func agentDefault() string { + if value := os.Getenv(EnvAgent); value != "" { + return value + } + return DefaultAgent +} + +func (o *options) client() *agent.Client { + return agent.New(o.agentURL, o.timeout) +} + +func statusCommand(opts *options) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Whether the agent and the indexer are running", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + status, err := opts.client().Status(cmd.Context()) + if err != nil { + return err + } + if opts.asJSON { + return writeJSON(cmd.OutOrStdout(), status) + } + rows := [][]string{ + {"agent", opts.agentURL, "version " + status.Version}, + {"indexer", status.CoreURL, upOrDown(status.CoreUp)}, + } + if status.CoreStarts > 1 { + rows = append(rows, []string{"", "", presentation.Count(status.CoreStarts, "start", "starts")}) + } + if status.LastExit != "" { + rows = append(rows, []string{"", "", "last exit: " + status.LastExit}) + } + presentation.Table(cmd.OutOrStdout(), nil, rows) + return nil + }, + } +} + +func upOrDown(up bool) string { + if up { + return "answering" + } + return "not answering" +} + +func reposCommand(opts *options) *cobra.Command { + return &cobra.Command{ + Use: "repos", + Aliases: []string{"repositories"}, + Short: "Repositories indexed on this machine", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + repositories, err := opts.client().Repositories(cmd.Context()) + if err != nil { + return err + } + if opts.asJSON { + return writeJSON(cmd.OutOrStdout(), repositories) + } + if len(repositories) == 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Nothing indexed yet. Add a repository with: sourceant repo add ") + return nil + } + rows := make([][]string, 0, len(repositories)) + for _, repository := range repositories { + rows = append(rows, []string{repository.Name, repository.Path}) + } + presentation.Table(cmd.OutOrStdout(), []string{"REPOSITORY", "PATH"}, rows) + return nil + }, + } +} + +func graphCommand(opts *options) *cobra.Command { + var ( + pathPrefix string + includeTests bool + nodeLimit int + ) + command := &cobra.Command{ + Use: "graph ", + Short: "What the indexer found in one repository", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + graph, err := opts.client().Graph(cmd.Context(), args[0], agent.GraphOptions{ + PathPrefix: pathPrefix, + IncludeTests: includeTests, + NodeLimit: nodeLimit, + }) + if err != nil { + return err + } + if opts.asJSON { + return writeJSON(cmd.OutOrStdout(), graph) + } + summarise(cmd.OutOrStdout(), graph) + return nil + }, + } + command.Flags().StringVar(&pathPrefix, "path", "", "Only what sits under this directory") + command.Flags().BoolVar(&includeTests, "tests", false, "Include the test suite") + command.Flags().IntVar(&nodeLimit, "limit", 0, "Stop after this many nodes") + return command +} + +// summarise prints what a terminal can say about a graph it cannot draw. +func summarise(out io.Writer, graph agent.Graph) { + _, _ = fmt.Fprintf(out, "%s, %s\n\n", + presentation.Count(len(graph.Nodes), "node", "nodes"), + presentation.Count(len(graph.Links), "link", "links")) + + presentation.Table(out, []string{"KIND", "COUNT"}, tally(kinds(graph))) + if len(graph.Links) > 0 { + _, _ = fmt.Fprintln(out) + presentation.Table(out, []string{"EDGE", "COUNT"}, tally(edgeTypes(graph))) + } + if graph.Truncated { + _, _ = fmt.Fprintln(out, "\nThis repository is larger than the limit, so this is part of it. Raise --limit to see more.") + } +} + +func kinds(graph agent.Graph) map[string]int { + counts := map[string]int{} + for _, node := range graph.Nodes { + counts[node.Kind]++ + } + return counts +} + +func edgeTypes(graph agent.Graph) map[string]int { + counts := map[string]int{} + for _, link := range graph.Links { + counts[link.Type]++ + } + return counts +} + +// tally orders by count, then by name, so the same graph always prints the same. +func tally(counts map[string]int) [][]string { + names := make([]string, 0, len(counts)) + for name := range counts { + names = append(names, name) + } + sort.Slice(names, func(i, j int) bool { + if counts[names[i]] != counts[names[j]] { + return counts[names[i]] > counts[names[j]] + } + return names[i] < names[j] + }) + rows := make([][]string, 0, len(names)) + for _, name := range names { + rows = append(rows, []string{name, fmt.Sprint(counts[name])}) + } + return rows +} + +func versionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "What this build is", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "sourceant %s (%s, built %s)\n", Version, GitCommit, BuildTime) + return nil + }, + } +} + +func writeJSON(out io.Writer, body any) error { + encoder := json.NewEncoder(out) + encoder.SetIndent("", " ") + return encoder.Encode(body) +} diff --git a/internal/command/root_test.go b/internal/command/root_test.go new file mode 100644 index 0000000..ec89065 --- /dev/null +++ b/internal/command/root_test.go @@ -0,0 +1,174 @@ +package command + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// The fixtures are answers captured from a running agent, not written here, so +// a change to what it serves fails these rather than passing against our guess. +func fixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("reading fixture: %v", err) + } + return data +} + +type answer struct { + status int + body []byte +} + +// running starts a stand-in agent and returns a run function that drives the +// CLI the way a person does: arguments in, streams and an exit code out. +func running(t *testing.T, answers map[string]answer) func(args ...string) (string, string, int) { + t.Helper() + mux := http.NewServeMux() + for path, reply := range answers { + mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if reply.status != 0 { + w.WriteHeader(reply.status) + } + _, _ = w.Write(reply.body) + }) + } + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + return func(args ...string) (string, string, int) { + var stdout, stderr bytes.Buffer + code := Run(append([]string{"--agent", server.URL}, args...), &stdout, &stderr) + return stdout.String(), stderr.String(), code + } +} + +func TestStatusSaysWhetherTheIndexerIsAnswering(t *testing.T) { + run := running(t, map[string]answer{ + "/health": {body: fixture(t, "health.json")}, + }) + + stdout, stderr, code := run("status") + + if code != 0 { + t.Fatalf("exited %d: %s", code, stderr) + } + for _, want := range []string{"agent", "indexer", "answering", "0.1.0"} { + if !strings.Contains(stdout, want) { + t.Errorf("%q is missing from:\n%s", want, stdout) + } + } +} + +func TestReposListsWhatIsIndexedHere(t *testing.T) { + run := running(t, map[string]answer{ + "/api/repositories": {body: fixture(t, "repositories.json")}, + }) + + stdout, stderr, code := run("repos") + + if code != 0 { + t.Fatalf("exited %d: %s", code, stderr) + } + if !strings.Contains(stdout, "local/sourceant") { + t.Errorf("the repository is missing from:\n%s", stdout) + } +} + +func TestAnEmptyMachineIsToldWhatToDoNext(t *testing.T) { + run := running(t, map[string]answer{ + "/api/repositories": {body: []byte("[]")}, + }) + + stdout, _, code := run("repos") + + if code != 0 { + t.Fatalf("exited %d", code) + } + if !strings.Contains(stdout, "sourceant repo add") { + t.Errorf("an empty machine was not told how to fill it:\n%s", stdout) + } +} + +func TestGraphCountsWhatTheIndexerFound(t *testing.T) { + run := running(t, map[string]answer{ + "/api/graph": {body: fixture(t, "graph.json")}, + }) + + stdout, stderr, code := run("graph", "local/sourceant") + + if code != 0 { + t.Fatalf("exited %d: %s", code, stderr) + } + for _, want := range []string{"nodes", "links", "KIND", "python", "EDGE", "imports"} { + if !strings.Contains(stdout, want) { + t.Errorf("%q is missing from:\n%s", want, stdout) + } + } +} + +func TestGraphAsJSONIsTheAgentsOwnAnswer(t *testing.T) { + run := running(t, map[string]answer{ + "/api/graph": {body: fixture(t, "graph.json")}, + }) + + stdout, _, code := run("--json", "graph", "local/sourceant") + + if code != 0 { + t.Fatalf("exited %d", code) + } + if !strings.Contains(stdout, `"labels"`) || !strings.Contains(stdout, `"truncated"`) { + t.Errorf("the raw graph was not passed through:\n%s", stdout) + } +} + +func TestAnUnregisteredRepositoryIsReportedInItsOwnWords(t *testing.T) { + run := running(t, map[string]answer{ + "/api/graph": { + status: http.StatusNotFound, + body: []byte(`{"error":"acme/nope is not registered on this machine"}`), + }, + }) + + _, stderr, code := run("graph", "acme/nope") + + if code != 1 { + t.Fatalf("exited %d, want 1", code) + } + if !strings.Contains(stderr, "not registered on this machine") { + t.Errorf("got %q, want the reason the agent gave", stderr) + } +} + +func TestAnAgentThatIsNotRunningSaysHowToStartIt(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := Run([]string{"--agent", "http://127.0.0.1:1", "status"}, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exited %d, want 1", code) + } + if !strings.Contains(stderr.String(), "Start it with sourceant-agent") { + t.Errorf("got %q, want what to do about it", stderr.String()) + } +} + +func TestVersionNeedsNoAgent(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := Run([]string{"--agent", "http://127.0.0.1:1", "version"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("exited %d: %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "sourceant ") { + t.Errorf("got %q, want the build", stdout.String()) + } +} diff --git a/internal/command/testdata/graph.json b/internal/command/testdata/graph.json new file mode 100644 index 0000000..e278c6f --- /dev/null +++ b/internal/command/testdata/graph.json @@ -0,0 +1 @@ +{"nodes":[{"id":"file:src/config/__init__.py","name":"__init__.py","kind":"python","labels":["File"],"path":"src/config/__init__.py"},{"id":"file:src/config/db.py","name":"db.py","kind":"python","labels":["File"],"path":"src/config/db.py"},{"id":"file:src/config/paths.py","name":"paths.py","kind":"python","labels":["File"],"path":"src/config/paths.py"},{"id":"file:src/config/settings.py","name":"settings.py","kind":"python","labels":["File"],"path":"src/config/settings.py"},{"id":"import:src/config/db.py:0","name":"from sqlmodel import create_engine, Session","kind":"import","labels":["Import"],"path":"src/config/db.py"},{"id":"import:src/config/db.py:1","name":"from src.utils.logger import logger","kind":"import","labels":["Import"],"path":"src/config/db.py"},{"id":"import:src/config/db.py:2","name":"from src.config.settings import DATABASE_URL, STATELESS_MODE, DEBUG_MODE","kind":"import","labels":["Import"],"path":"src/config/db.py"},{"id":"import:src/config/paths.py:0","name":"import os","kind":"import","labels":["Import"],"path":"src/config/paths.py"},{"id":"import:src/config/paths.py:1","name":"import secrets","kind":"import","labels":["Import"],"path":"src/config/paths.py"},{"id":"import:src/config/paths.py:2","name":"from pathlib import Path","kind":"import","labels":["Import"],"path":"src/config/paths.py"},{"id":"import:src/config/settings.py:0","name":"import os","kind":"import","labels":["Import"],"path":"src/config/settings.py"},{"id":"import:src/config/settings.py:1","name":"from dotenv import load_dotenv","kind":"import","labels":["Import"],"path":"src/config/settings.py"},{"id":"import:src/config/settings.py:2","name":"from src.config.paths import default_database_url","kind":"import","labels":["Import"],"path":"src/config/settings.py"},{"id":"symbol:src/config/db.py:get_engine:7:0","name":"get_engine","kind":"function","labels":["Function"],"path":"src/config/db.py"},{"id":"symbol:src/config/db.py:get_session:30:1","name":"get_session","kind":"function","labels":["Function"],"path":"src/config/db.py"},{"id":"symbol:src/config/paths.py:data_dir:10:0","name":"data_dir","kind":"function","labels":["Function"],"path":"src/config/paths.py"},{"id":"symbol:src/config/paths.py:default_database_url:26:2","name":"default_database_url","kind":"function","labels":["Function"],"path":"src/config/paths.py"},{"id":"symbol:src/config/paths.py:ensure_data_dir:20:1","name":"ensure_data_dir","kind":"function","labels":["Function"],"path":"src/config/paths.py"},{"id":"symbol:src/config/paths.py:local_jwt_secret:30:3","name":"local_jwt_secret","kind":"function","labels":["Function"],"path":"src/config/paths.py"}],"links":[{"source":"file:src/config/db.py","target":"symbol:src/config/db.py:get_engine:7:0","type":"defines"},{"source":"file:src/config/db.py","target":"symbol:src/config/db.py:get_session:30:1","type":"defines"},{"source":"file:src/config/paths.py","target":"symbol:src/config/paths.py:data_dir:10:0","type":"defines"},{"source":"file:src/config/paths.py","target":"symbol:src/config/paths.py:default_database_url:26:2","type":"defines"},{"source":"file:src/config/paths.py","target":"symbol:src/config/paths.py:ensure_data_dir:20:1","type":"defines"},{"source":"file:src/config/paths.py","target":"symbol:src/config/paths.py:local_jwt_secret:30:3","type":"defines"},{"source":"file:src/config/db.py","target":"import:src/config/db.py:0","type":"imports"},{"source":"file:src/config/db.py","target":"import:src/config/db.py:1","type":"imports"},{"source":"file:src/config/db.py","target":"import:src/config/db.py:2","type":"imports"},{"source":"file:src/config/paths.py","target":"import:src/config/paths.py:0","type":"imports"},{"source":"file:src/config/paths.py","target":"import:src/config/paths.py:1","type":"imports"},{"source":"file:src/config/paths.py","target":"import:src/config/paths.py:2","type":"imports"},{"source":"file:src/config/settings.py","target":"import:src/config/settings.py:0","type":"imports"},{"source":"file:src/config/settings.py","target":"import:src/config/settings.py:1","type":"imports"},{"source":"file:src/config/settings.py","target":"import:src/config/settings.py:2","type":"imports"}],"truncated":false} diff --git a/internal/command/testdata/health.json b/internal/command/testdata/health.json new file mode 100644 index 0000000..1920d83 --- /dev/null +++ b/internal/command/testdata/health.json @@ -0,0 +1 @@ +{"version":"0.1.0","core_url":"http://127.0.0.1:8931","core_up":true,"core_starts":1} diff --git a/internal/command/testdata/repositories.json b/internal/command/testdata/repositories.json new file mode 100644 index 0000000..a42af7e --- /dev/null +++ b/internal/command/testdata/repositories.json @@ -0,0 +1 @@ +[{"name":"local/sourceant","path":"/app"}] diff --git a/internal/presentation/presentation.go b/internal/presentation/presentation.go new file mode 100644 index 0000000..b2e237d --- /dev/null +++ b/internal/presentation/presentation.go @@ -0,0 +1,38 @@ +// Package presentation renders what the CLI has to say. +package presentation + +import ( + "fmt" + "io" + "text/tabwriter" +) + +// Table writes aligned columns, header first. +func Table(out io.Writer, header []string, rows [][]string) { + writer := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + if len(header) > 0 { + writeRow(writer, header) + } + for _, row := range rows { + writeRow(writer, row) + } + _ = writer.Flush() +} + +func writeRow(writer io.Writer, cells []string) { + for i, cell := range cells { + if i > 0 { + _, _ = fmt.Fprint(writer, "\t") + } + _, _ = fmt.Fprint(writer, cell) + } + _, _ = fmt.Fprintln(writer) +} + +// Count renders a number with its noun, singular where that reads better. +func Count(n int, singular, plural string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, singular) + } + return fmt.Sprintf("%d %s", n, plural) +} From 805d7b5a4b981e41132bb66ecaab23ffdc47c22f Mon Sep 17 00:00:00 2001 From: nfebe Date: Fri, 28 Aug 2026 21:49:13 +0100 Subject: [PATCH 2/6] feat: Open the graph in a browser Adds ui, which points a browser at the view the agent serves. It asks the agent first, so an agent that is not running is a line saying so rather than a browser landing on an error page. --no-open prints the address for anyone who would rather open it themselves. --- README.md | 1 + internal/command/root.go | 8 +++++- internal/command/root_test.go | 28 ++++++++++++++++++++ internal/command/ui.go | 50 +++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 internal/command/ui.go diff --git a/README.md b/README.md index aa51740..e0e0b4a 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Then start the agent. See [sourceant/agent](https://github.com/sourceant/agent). | `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 | +| `sourceant ui` | Open the graph in a browser | | `sourceant version` | What this build is | `--json` prints the agent's own answer, for anything that wants to read it rather than look at it. diff --git a/internal/command/root.go b/internal/command/root.go index 5d2983f..792f9eb 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -51,7 +51,13 @@ func Run(args []string, stdout, stderr io.Writer) int { root.PersistentFlags().DurationVar(&opts.timeout, "timeout", 30*time.Second, "How long to wait for the agent") root.PersistentFlags().BoolVar(&opts.asJSON, "json", false, "Print the agent's answer as JSON") - root.AddCommand(statusCommand(opts), reposCommand(opts), graphCommand(opts), versionCommand()) + root.AddCommand( + statusCommand(opts), + reposCommand(opts), + graphCommand(opts), + uiCommand(opts), + versionCommand(), + ) if err := root.Execute(); err != nil { _, _ = fmt.Fprintln(stderr, "sourceant:", message(err)) diff --git a/internal/command/root_test.go b/internal/command/root_test.go index ec89065..5c46f11 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -160,6 +160,34 @@ func TestAnAgentThatIsNotRunningSaysHowToStartIt(t *testing.T) { } } +func TestUIPrintsTheAddressWithoutOpeningAnything(t *testing.T) { + run := running(t, map[string]answer{ + "/health": {body: fixture(t, "health.json")}, + }) + + stdout, stderr, code := run("ui", "--no-open") + + if code != 0 { + t.Fatalf("exited %d: %s", code, stderr) + } + if !strings.Contains(stdout, "http://127.0.0.1:") { + t.Errorf("got %q, want the address to open", stdout) + } +} + +func TestUISaysTheAgentIsDownRatherThanOpeningAnErrorPage(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := Run([]string{"--agent", "http://127.0.0.1:1", "ui", "--no-open"}, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exited %d, want 1", code) + } + if !strings.Contains(stderr.String(), "Start it with sourceant-agent") { + t.Errorf("got %q, want what to do about it", stderr.String()) + } +} + func TestVersionNeedsNoAgent(t *testing.T) { var stdout, stderr bytes.Buffer diff --git a/internal/command/ui.go b/internal/command/ui.go new file mode 100644 index 0000000..59c9f1f --- /dev/null +++ b/internal/command/ui.go @@ -0,0 +1,50 @@ +package command + +import ( + "fmt" + "os/exec" + "runtime" + + "github.com/spf13/cobra" +) + +func uiCommand(opts *options) *cobra.Command { + var stayPut bool + command := &cobra.Command{ + Use: "ui", + Short: "Open the graph in a browser", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + // Asking the agent first turns "the browser opened on an error + // page" into a line saying the agent is not running. + if _, err := opts.client().Status(cmd.Context()); err != nil { + return err + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), opts.agentURL) + if stayPut { + return nil + } + if err := open(opts.agentURL); err != nil { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Could not open a browser. Follow the address above.") + } + return nil + }, + } + command.Flags().BoolVar(&stayPut, "no-open", false, "Print the address instead of opening it") + return command +} + +// open hands a URL to whatever the desktop uses for one. +func open(target string) error { + var name string + var args []string + switch runtime.GOOS { + case "darwin": + name = "open" + case "windows": + name, args = "rundll32", []string{"url.dll,FileProtocolHandler"} + default: + name = "xdg-open" + } + return exec.Command(name, append(args, target)...).Start() +} From f8b9a6797ef3de815682a8a6ca18bc42fa9486b0 Mon Sep 17 00:00:00 2001 From: nfebe Date: Fri, 28 Aug 2026 22:24:46 +0100 Subject: [PATCH 3/6] feat: Put a core on this machine Adds install, which is what makes the agent runnable by somebody who is not already set up to run the core by hand. It puts a core on this machine and writes down which one, so the agent knows what to start. Two ways to have it, chosen at install time. A container, which is what exists today. Or a Python program, for when the core is published as a package: until then that path says so plainly and points at the one that works, rather than recording a runtime that would fail to start later. Both put the index in the same place, so it does not matter which one built it, and a container runs as whoever installed so what it writes there belongs to them. --- README.md | 8 ++ internal/command/install.go | 57 ++++++++ internal/command/root.go | 1 + internal/install/install.go | 219 +++++++++++++++++++++++++++++++ internal/install/install_test.go | 173 ++++++++++++++++++++++++ 5 files changed, 458 insertions(+) create mode 100644 internal/command/install.go create mode 100644 internal/install/install.go create mode 100644 internal/install/install_test.go diff --git a/README.md b/README.md index e0e0b4a..9fdba93 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,15 @@ The CLI never reaches past the agent. The agent is the process that is always up ```bash make build +./sourceant install ``` +`install` puts a core on this machine and writes down which one, so the agent knows what to start. + +Two ways to have it. `--runtime docker` pulls the published image, and is what works today. `--runtime python` builds a virtual environment and pip installs the core, for when the core is published as a package; until then it says so rather than recording something that will not start. + +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. + Then start the agent. See [sourceant/agent](https://github.com/sourceant/agent). | Variable | Default | Meaning | @@ -51,6 +58,7 @@ Then start the agent. See [sourceant/agent](https://github.com/sourceant/agent). | Command | What it does | |---|---| +| `sourceant install` | Put a core on this machine | | `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/command/install.go b/internal/command/install.go new file mode 100644 index 0000000..feaa57e --- /dev/null +++ b/internal/command/install.go @@ -0,0 +1,57 @@ +package command + +import ( + "fmt" + "os" + + "github.com/sourceant/cli/internal/install" + "github.com/spf13/cobra" +) + +func installCommand() *cobra.Command { + var ( + runtime string + image string + from string + noPull bool + ) + command := &cobra.Command{ + Use: "install", + Short: "Put a SourceAnt core on this machine", + Long: "Two ways to have the core. As a container, which is what exists today. " + + "Or as a Python program, for when the core is published as a package.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + config, err := install.Install(install.Options{ + Runtime: install.Runtime(runtime), + Image: image, + From: from, + Pull: !noPull, + Out: cmd.OutOrStdout(), + }, install.Run) + if err != nil { + return err + } + + path := install.ConfigPath() + if err := os.MkdirAll(config.Core.DataDir, 0o755); err != nil { + return fmt.Errorf("could not make %s, where the index lives: %w", config.Core.DataDir, err) + } + if err := install.Save(path, config); err != nil { + return err + } + + out := cmd.OutOrStdout() + _, _ = fmt.Fprintf(out, "\nInstalled %s\n", config.Core.Describe()) + _, _ = fmt.Fprintf(out, "Index at %s\n", config.Core.DataDir) + _, _ = fmt.Fprintf(out, "Written to %s\n\n", path) + _, _ = fmt.Fprintln(out, "Start it with sourceant-agent, then sourceant ui.") + return nil + }, + } + command.Flags().StringVar(&runtime, "runtime", string(install.Docker), "docker or python") + command.Flags().StringVar(&image, "image", install.DefaultImage, "The container to use, for the docker runtime") + command.Flags().StringVar(&from, "from", install.DefaultPackage, "What pip installs, for the python runtime") + command.Flags().BoolVar(&noPull, "no-pull", false, "Use an image already on this machine") + return command +} diff --git a/internal/command/root.go b/internal/command/root.go index 792f9eb..708abb4 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( + installCommand(), statusCommand(opts), reposCommand(opts), graphCommand(opts), diff --git a/internal/install/install.go b/internal/install/install.go new file mode 100644 index 0000000..66adc03 --- /dev/null +++ b/internal/install/install.go @@ -0,0 +1,219 @@ +// Package install puts a SourceAnt core on this machine and writes down which +// one, so the agent can start it. +// +// The file written here is read by the agent. Its field names are the contract +// between the two: change one and change the other, or the agent will start +// nothing. TestTheFileMatchesWhatTheAgentReads pins the shape. +package install + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" +) + +// Runtime is how the core is installed. +type Runtime string + +const ( + Python Runtime = "python" + Docker Runtime = "docker" +) + +// DefaultImage is the published core. +const DefaultImage = "ghcr.io/sourceant/sourceant:latest" + +// DefaultPackage is the core on PyPI. +const DefaultPackage = "sourceant" + +// Core is what was installed. +type Core struct { + Runtime Runtime `json:"runtime"` + Command string `json:"command,omitempty"` + Image string `json:"image,omitempty"` + DataDir string `json:"data_dir,omitempty"` + User string `json:"user,omitempty"` +} + +// Config is the whole file. +type Config struct { + Core Core `json:"core"` +} + +// Home is where SourceAnt keeps what it installed. +func Home() string { + if override := os.Getenv("SOURCEANT_INSTALL_HOME"); override != "" { + return override + } + home, err := os.UserHomeDir() + if err != nil { + return ".sourceant" + } + return filepath.Join(home, ".sourceant") +} + +// ConfigPath is the file the agent reads. +func ConfigPath() string { return filepath.Join(Home(), "config.json") } + +// DataDir is where the index lives, matching what the core picks for itself. +// +// Both runtimes have to agree on it. If they did not, indexing and reading +// would address two different databases and the second would look empty. +func DataDir() string { + if override := os.Getenv("SOURCEANT_HOME"); override != "" { + return override + } + if base := os.Getenv("XDG_DATA_HOME"); base != "" { + return filepath.Join(base, "sourceant") + } + home, err := os.UserHomeDir() + if err != nil { + return ".sourceant-data" + } + return filepath.Join(home, ".local", "share", "sourceant") +} + +// Save writes the runtime for the agent to read. +func Save(path string, config Config) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o644) +} + +// Options describe what to install. +type Options struct { + Runtime Runtime + // Image is the container to pull, for the docker runtime. + Image string + // From is what pip installs, for the python runtime. A package name, a + // version specifier, or a path to a checkout. + From string + // Pull says whether to fetch the image before writing anything down. + Pull bool + // Out receives progress. + Out io.Writer +} + +// Runner runs the commands an install needs. Swapped in tests, because an +// install that really pulls an image is not something to run on every change. +type Runner func(name string, args ...string) ([]byte, error) + +// Run executes a command and returns its combined output. +func Run(name string, args ...string) ([]byte, error) { + return exec.Command(name, args...).CombinedOutput() +} + +// Install puts the core on this machine and returns what to write down. +func Install(opts Options, run Runner) (Config, error) { + switch opts.Runtime { + case Docker: + return installDocker(opts, run) + case Python: + return installPython(opts, run) + default: + return Config{}, fmt.Errorf("runtime %q is neither python nor docker", opts.Runtime) + } +} + +func installDocker(opts Options, run Runner) (Config, error) { + image := opts.Image + if image == "" { + image = DefaultImage + } + if _, err := run("docker", "version", "--format", "{{.Server.Version}}"); err != nil { + return Config{}, errors.New("docker is not running here. Start it, or install the python runtime instead") + } + if opts.Pull { + say(opts.Out, "Pulling %s. This is about 1.4 GB the first time.\n", image) + if output, err := run("docker", "pull", image); err != nil { + return Config{}, fmt.Errorf("could not pull %s: %s", image, trim(output)) + } + } + if _, err := run("docker", "image", "inspect", image); err != nil { + return Config{}, fmt.Errorf("%s is not on this machine. Run without --no-pull to fetch it", image) + } + + return Config{Core: Core{ + Runtime: Docker, + Image: image, + DataDir: DataDir(), + // The image has a user of its own, and where its id differs from this + // person's, everything written into the mounted index would belong to + // somebody who does not exist here. + User: fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), + }}, nil +} + +func installPython(opts Options, run Runner) (Config, error) { + from := opts.From + if from == "" { + from = DefaultPackage + } + python, err := exec.LookPath("python3") + if err != nil { + return Config{}, errors.New("no python3 on PATH. Install one, or install the docker runtime instead") + } + + venv := filepath.Join(Home(), "runtime") + say(opts.Out, "Building a runtime in %s\n", venv) + if output, err := run(python, "-m", "venv", venv); err != nil { + return Config{}, fmt.Errorf("could not build a runtime: %s", trim(output)) + } + + pip := filepath.Join(venv, "bin", "pip") + say(opts.Out, "Installing %s\n", from) + if output, err := run(pip, "install", "--upgrade", from); err != nil { + return Config{}, fmt.Errorf("could not install %s: %s", from, trim(output)) + } + + command := filepath.Join(venv, "bin", "sourceant") + if _, err := os.Stat(command); err != nil { + return Config{}, fmt.Errorf( + "%s installed without a sourceant command, so there is nothing to start. "+ + "The core is not packaged for PyPI yet; use --runtime docker", from) + } + + return Config{Core: Core{ + Runtime: Python, + Command: command, + DataDir: DataDir(), + }}, nil +} + +// Describe is one line naming what was installed. +func (c Core) Describe() string { + switch c.Runtime { + case Python: + return "python · " + c.Command + case Docker: + return "docker · " + c.Image + " as " + c.User + default: + return string(c.Runtime) + } +} + +func say(out io.Writer, format string, args ...any) { + if out != nil { + _, _ = fmt.Fprintf(out, format, args...) + } +} + +// trim keeps a failure readable: the last of a command's output is where it +// says what went wrong, and the rest is progress nobody needs now. +func trim(output []byte) string { + const keep = 400 + text := string(output) + if len(text) <= keep { + return text + } + return "…" + text[len(text)-keep:] +} diff --git a/internal/install/install_test.go b/internal/install/install_test.go new file mode 100644 index 0000000..d0dd073 --- /dev/null +++ b/internal/install/install_test.go @@ -0,0 +1,173 @@ +package install + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// recorder stands in for running real commands, so an install can be driven +// without pulling 1.4 GB on every test run. +type recorder struct { + ran [][]string + fail map[string]error + output []byte +} + +func (r *recorder) run(name string, args ...string) ([]byte, error) { + r.ran = append(r.ran, append([]string{name}, args...)) + if err, found := r.fail[name+" "+strings.Join(args, " ")]; found { + return r.output, err + } + for key, err := range r.fail { + if strings.HasPrefix(name+" "+strings.Join(args, " "), key) { + return r.output, err + } + } + return nil, nil +} + +func (r *recorder) commands() string { + lines := make([]string, 0, len(r.ran)) + for _, command := range r.ran { + lines = append(lines, strings.Join(command, " ")) + } + return strings.Join(lines, "\n") +} + +/* The agent reads this file. These are its field names, spelled out, because + * renaming one here and not there leaves an agent that starts nothing and says + * only that no runtime is installed. */ +func TestTheFileMatchesWhatTheAgentReads(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + err := Save(path, Config{Core: Core{ + Runtime: Docker, + Command: "/somewhere/sourceant", + Image: "ghcr.io/sourceant/sourceant:v1", + DataDir: "/data", + User: "1000:1000", + }}) + if err != nil { + t.Fatalf("saving: %v", err) + } + + var raw map[string]map[string]any + data, _ := os.ReadFile(path) + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("reading back: %v", err) + } + + core, found := raw["core"] + if !found { + t.Fatalf("no core in %s", data) + } + for field, want := range map[string]any{ + "runtime": "docker", + "command": "/somewhere/sourceant", + "image": "ghcr.io/sourceant/sourceant:v1", + "data_dir": "/data", + "user": "1000:1000", + } { + if core[field] != want { + t.Errorf("core.%s is %v, want %v", field, core[field], want) + } + } +} + +func TestInstallingTheImagePullsItAndRecordsWhoIsInstalling(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + t.Setenv("SOURCEANT_HOME", "/somewhere/data") + run := &recorder{} + + config, err := Install(Options{Runtime: Docker, Image: "img:1", Pull: true}, run.run) + if err != nil { + t.Fatalf("installing: %v", err) + } + + if config.Core.Runtime != Docker || config.Core.Image != "img:1" { + t.Errorf("got %+v, want the image asked for", config.Core) + } + if config.Core.DataDir != "/somewhere/data" { + t.Errorf("got data dir %q, want the core's own", config.Core.DataDir) + } + if config.Core.User != fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) { + t.Errorf("got user %q, want whoever is installing", config.Core.User) + } + if !strings.Contains(run.commands(), "docker pull img:1") { + t.Errorf("the image was never pulled:\n%s", run.commands()) + } +} + +func TestNoPullUsesAnImageAlreadyHere(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + run := &recorder{} + + if _, err := Install(Options{Runtime: Docker, Image: "img:1", Pull: false}, run.run); err != nil { + t.Fatalf("installing: %v", err) + } + + if strings.Contains(run.commands(), "docker pull") { + t.Errorf("pulled anyway:\n%s", run.commands()) + } + if !strings.Contains(run.commands(), "docker image inspect img:1") { + t.Errorf("never checked the image is here:\n%s", run.commands()) + } +} + +func TestNoDockerSaysWhatToDoInstead(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + run := &recorder{fail: map[string]error{"docker version": errors.New("not found")}} + + _, err := Install(Options{Runtime: Docker, Pull: true}, run.run) + + if err == nil { + t.Fatal("installed without docker") + } + if !strings.Contains(err.Error(), "python runtime instead") { + t.Errorf("got %q, want the other way out", err) + } +} + +func TestAnImageThatIsNotHereAndWasNotPulledIsRefused(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + run := &recorder{fail: map[string]error{"docker image inspect": errors.New("no such image")}} + + _, err := Install(Options{Runtime: Docker, Image: "img:1", Pull: false}, run.run) + + if err == nil { + t.Fatal("recorded an image that is not on this machine") + } + if !strings.Contains(err.Error(), "--no-pull") { + t.Errorf("got %q, want how to fetch it", err) + } +} + +/* The core has no packaging metadata and nothing is on PyPI, so this path + * cannot work yet. What matters is that it says so rather than recording a + * runtime the agent would fail to start. */ +func TestThePythonRuntimeSaysWhyItCannotWorkYet(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + run := &recorder{} + + _, err := Install(Options{Runtime: Python, From: "sourceant"}, run.run) + + if err == nil { + t.Fatal("recorded a python runtime with no command behind it") + } + if !strings.Contains(err.Error(), "not packaged for PyPI yet") { + t.Errorf("got %q, want why it cannot work", err) + } + if !strings.Contains(err.Error(), "--runtime docker") { + t.Errorf("got %q, want the way that does work", err) + } +} + +func TestARuntimeThatIsNeitherIsRefused(t *testing.T) { + if _, err := Install(Options{Runtime: "podman"}, (&recorder{}).run); err == nil { + t.Fatal("accepted a runtime that is neither") + } +} From 68f20abbb31c27e55efa4a7e09c4a3a8b2f7e05b Mon Sep 17 00:00:00 2001 From: nfebe Date: Fri, 28 Aug 2026 23:07:12 +0100 Subject: [PATCH 4/6] feat: Let a container see the repositories it indexes A container that can only see the index reads no files, so an install now records a directory to make visible and the agent mounts it at the path it already has on this machine. Your home, which is where repositories nearly always are; one outside it is not readable this way. --- internal/install/install.go | 12 ++++++++++++ internal/install/install_test.go | 2 ++ 2 files changed, 14 insertions(+) diff --git a/internal/install/install.go b/internal/install/install.go index 66adc03..633ea43 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -36,6 +36,7 @@ type Core struct { Command string `json:"command,omitempty"` Image string `json:"image,omitempty"` DataDir string `json:"data_dir,omitempty"` + Mount string `json:"mount,omitempty"` User string `json:"user,omitempty"` } @@ -142,10 +143,21 @@ func installDocker(opts Options, run Runner) (Config, error) { return Config{}, fmt.Errorf("%s is not on this machine. Run without --no-pull to fetch it", image) } + home, err := os.UserHomeDir() + if err != nil { + return Config{}, fmt.Errorf("could not find your home directory: %w", err) + } + return Config{Core: Core{ Runtime: Docker, Image: image, DataDir: DataDir(), + // The indexer reads a repository's files, so the container has to be + // able to see them. Your home is mounted at the path it already has, + // which is what lets one registry of absolute paths mean the same + // thing to either runtime. A repository outside it is not readable + // this way. + Mount: home, // The image has a user of its own, and where its id differs from this // person's, everything written into the mounted index would belong to // somebody who does not exist here. diff --git a/internal/install/install_test.go b/internal/install/install_test.go index d0dd073..173fe57 100644 --- a/internal/install/install_test.go +++ b/internal/install/install_test.go @@ -49,6 +49,7 @@ func TestTheFileMatchesWhatTheAgentReads(t *testing.T) { Command: "/somewhere/sourceant", Image: "ghcr.io/sourceant/sourceant:v1", DataDir: "/data", + Mount: "/home/someone", User: "1000:1000", }}) if err != nil { @@ -70,6 +71,7 @@ func TestTheFileMatchesWhatTheAgentReads(t *testing.T) { "command": "/somewhere/sourceant", "image": "ghcr.io/sourceant/sourceant:v1", "data_dir": "/data", + "mount": "/home/someone", "user": "1000:1000", } { if core[field] != want { From 909c4c27a763f6df887daa526e2bc50f750ed7f7 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 30 Aug 2026 03:32:10 +0100 Subject: [PATCH 5/6] ci: Run formatting, vet, tests and a build on every change The targets are the ones in the Makefile, so what runs here is what runs when somebody runs it themselves. --- .github/workflows/checks.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/checks.yml diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..76972f9 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,29 @@ +name: Checks + +on: + push: + branches: [main] + pull_request: + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Formatting + run: make fmt-check + + - name: Vet + run: make vet + + - name: Tests + run: make test-race + + - name: Build + run: make build From f5b15941ae2fb70986213a955e424a8c9b32a010 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 30 Aug 2026 04:01:42 +0100 Subject: [PATCH 6/6] fix: Commit the entry point the build needs The ignore pattern for the built binary was unanchored, so it matched the command directory as well and the entry point was never committed. A fresh clone could not build. --- .gitignore | 4 +++- cmd/sourceant/main.go | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 cmd/sourceant/main.go diff --git a/.gitignore b/.gitignore index 80ea5b5..fe69d6b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ -sourceant +# Anchored, or it also matches cmd/sourceant and the entry point is +# never committed. +/sourceant coverage.out coverage.html diff --git a/cmd/sourceant/main.go b/cmd/sourceant/main.go new file mode 100644 index 0000000..1bff6d9 --- /dev/null +++ b/cmd/sourceant/main.go @@ -0,0 +1,12 @@ +// Command sourceant is the SourceAnt command line client. +package main + +import ( + "os" + + "github.com/sourceant/cli/internal/command" +) + +func main() { + os.Exit(command.Run(os.Args[1:], os.Stdout, os.Stderr)) +}