From 768502c6e39211a25bfe21416d5c1cad9566fffe Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 1 Sep 2026 00:31:47 +0100 Subject: [PATCH 1/4] feat(install): Install everything from one command Installing meant finding two binaries on two releases pages and knowing to start the agent first. The CLI is now the only thing to fetch: setup brings down the agent alongside the core, and ui starts what is not running. --- .github/workflows/install.yml | 34 +++++ CHANGELOG.md | 7 +- README.md | 10 +- internal/command/root.go | 2 +- internal/command/root_test.go | 7 +- internal/command/{install.go => setup.go} | 25 +++- internal/command/ui.go | 49 ++++++- internal/install/agent.go | 151 ++++++++++++++++++++++ internal/install/agent_test.go | 112 ++++++++++++++++ scripts/install.sh | 81 ++++++++++++ scripts/test-install.sh | 43 ++++++ 11 files changed, 503 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/install.yml rename internal/command/{install.go => setup.go} (64%) create mode 100644 internal/install/agent.go create mode 100644 internal/install/agent_test.go create mode 100755 scripts/install.sh create mode 100755 scripts/test-install.sh diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml new file mode 100644 index 0000000..2ac3628 --- /dev/null +++ b/.github/workflows/install.yml @@ -0,0 +1,34 @@ +name: Install script + +on: + push: + branches: [main] + pull_request: + +jobs: + # The script is exercised on a machine with nothing on it, because that is + # the machine somebody runs it on. + container: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: It installs a release into a bare Ubuntu + run: sh scripts/test-install.sh + + # Whether the script still works against what is actually published. It has + # nothing to install until the first release exists. + published: + runs-on: ubuntu-latest + container: ubuntu:24.04 + continue-on-error: true + steps: + - name: Tools a bare machine has + run: | + apt-get update -qq + apt-get install -y -qq curl ca-certificates + + - name: Install the published CLI + run: | + curl -fsSL https://raw.githubusercontent.com/sourceant/cli/main/scripts/install.sh | sh + sourceant version diff --git a/CHANGELOG.md b/CHANGELOG.md index e25317e..3ff759f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,10 @@ First release, versioned alongside the core it reads. ### Added -- `sourceant install` puts a core on this machine and writes down which one, so - the agent knows what to start. As a container today, or as a Python package - once the core is published as one +- `sourceant setup` puts the agent and a core on this machine and writes down + which one, so the agent knows what to start. As a container today, or as a + Python package once the core is published as one +- An install script, so the command is the only thing anybody fetches by hand - `sourceant status` says whether the agent and the indexer are running - `sourceant repos` lists what is indexed here, and `sourceant graph` says what the indexer found in one of them diff --git a/README.md b/README.md index 9fdba93..2e71f53 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,17 @@ The CLI never reaches past the agent. The agent is the process that is always up ## Installing ```bash -make build -./sourceant install +curl -fsSL https://raw.githubusercontent.com/sourceant/cli/main/scripts/install.sh | sh +sourceant setup ``` -`install` puts a core on this machine and writes down which one, so the agent knows what to start. +`setup` puts the agent and 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). +`sourceant ui` starts the agent and opens the view. | Variable | Default | Meaning | |---|---|---| @@ -58,7 +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 setup` | Put the agent and 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/root.go b/internal/command/root.go index 708abb4..fd32991 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -52,7 +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(), + setupCommand(), statusCommand(opts), reposCommand(opts), graphCommand(opts), diff --git a/internal/command/root_test.go b/internal/command/root_test.go index 5c46f11..ea579e6 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -175,7 +175,10 @@ func TestUIPrintsTheAddressWithoutOpeningAnything(t *testing.T) { } } -func TestUISaysTheAgentIsDownRatherThanOpeningAnErrorPage(t *testing.T) { +func TestUISaysWhatToInstallRatherThanOpeningAnErrorPage(t *testing.T) { + // A home with no agent in it, so the test cannot start one that happens to + // be installed on the machine running it. + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) var stdout, stderr bytes.Buffer code := Run([]string{"--agent", "http://127.0.0.1:1", "ui", "--no-open"}, &stdout, &stderr) @@ -183,7 +186,7 @@ func TestUISaysTheAgentIsDownRatherThanOpeningAnErrorPage(t *testing.T) { if code != 1 { t.Fatalf("exited %d, want 1", code) } - if !strings.Contains(stderr.String(), "Start it with sourceant-agent") { + if !strings.Contains(stderr.String(), "sourceant setup") { t.Errorf("got %q, want what to do about it", stderr.String()) } } diff --git a/internal/command/install.go b/internal/command/setup.go similarity index 64% rename from internal/command/install.go rename to internal/command/setup.go index feaa57e..c2c0f2b 100644 --- a/internal/command/install.go +++ b/internal/command/setup.go @@ -8,18 +8,21 @@ import ( "github.com/spf13/cobra" ) -func installCommand() *cobra.Command { +func setupCommand() *cobra.Command { var ( runtime string image string from string noPull bool + noAgent 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.", + Use: "setup", + Short: "Set this machine up to run SourceAnt", + Long: "Installs the agent and a core, so the only thing anybody had to " + + "fetch by hand is this command. 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{ @@ -45,7 +48,16 @@ func installCommand() *cobra.Command { _, _ = 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.") + + if !noAgent { + agentPath, err := install.InstallAgent(Version, install.Get, out) + if err != nil { + return fmt.Errorf("the core is installed, but the agent is not: %w", err) + } + _, _ = fmt.Fprintf(out, "Agent at %s\n\n", agentPath) + } + + _, _ = fmt.Fprintln(out, "Run sourceant ui to start it and open the view.") return nil }, } @@ -53,5 +65,6 @@ func installCommand() *cobra.Command { 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") + command.Flags().BoolVar(&noAgent, "no-agent", false, "Leave the agent alone, install only the core") return command } diff --git a/internal/command/ui.go b/internal/command/ui.go index 59c9f1f..8d9f034 100644 --- a/internal/command/ui.go +++ b/internal/command/ui.go @@ -1,10 +1,15 @@ package command import ( + "context" "fmt" + "os" "os/exec" "runtime" + "syscall" + "time" + "github.com/sourceant/cli/internal/install" "github.com/spf13/cobra" ) @@ -18,7 +23,9 @@ func uiCommand(opts *options) *cobra.Command { // 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 + if err := start(cmd.Context(), opts, cmd.OutOrStdout()); err != nil { + return err + } } _, _ = fmt.Fprintln(cmd.OutOrStdout(), opts.agentURL) if stayPut { @@ -34,6 +41,46 @@ func uiCommand(opts *options) *cobra.Command { return command } +// start runs the installed agent and waits for it to answer. It outlives this +// process, because the agent is the thing that stays up. +func start(ctx context.Context, opts *options, out interface{ Write([]byte) (int, error) }) error { + path := install.AgentPath() + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("no agent is running and none is installed here. Run sourceant setup") + } + + logPath := install.Home() + "/agent.log" + log, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer log.Close() + + agent := exec.Command(path) + agent.Stdout = log + agent.Stderr = log + agent.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := agent.Start(); err != nil { + return fmt.Errorf("could not start the agent: %w", err) + } + _, _ = fmt.Fprintf(out, "Started the agent. It logs to %s\n", logPath) + + // The agent has to start the core before it answers, which is the slow + // part on a first run. + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + if _, err := opts.client().Status(ctx); err == nil { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Second): + } + } + return fmt.Errorf("the agent did not answer within 90s. See %s", logPath) +} + // open hands a URL to whatever the desktop uses for one. func open(target string) error { var name string diff --git a/internal/install/agent.go b/internal/install/agent.go new file mode 100644 index 0000000..075e1d7 --- /dev/null +++ b/internal/install/agent.go @@ -0,0 +1,151 @@ +package install + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// AgentRepo publishes the agent binary. +const AgentRepo = "sourceant/agent" + +// AgentName is the binary this installs. +const AgentName = "sourceant-agent" + +// BinDir is where the agent binary is kept. +func BinDir() string { return filepath.Join(Home(), "bin") } + +// AgentPath is the agent this machine runs. +func AgentPath() string { return filepath.Join(BinDir(), AgentName) } + +// Platform is the os-arch pair release assets are named for. +func Platform() (string, error) { + arch := runtime.GOARCH + switch arch { + case "amd64", "arm64": + default: + return "", fmt.Errorf("no agent is published for %s", arch) + } + switch runtime.GOOS { + case "linux", "darwin": + default: + return "", fmt.Errorf("no agent is published for %s", runtime.GOOS) + } + return runtime.GOOS + "-" + arch, nil +} + +// Fetcher gets a URL. Swapped in tests, because an install that really reaches +// GitHub is not something to run on every change. +type Fetcher func(url string) (io.ReadCloser, error) + +// Get fetches over HTTP. +func Get(url string) (io.ReadCloser, error) { + client := &http.Client{Timeout: 5 * time.Minute} + response, err := client.Get(url) + if err != nil { + return nil, err + } + if response.StatusCode != http.StatusOK { + response.Body.Close() + return nil, fmt.Errorf("%s answered %s", url, response.Status) + } + return response.Body, nil +} + +// LatestAgent asks which version to install when none was named. +func LatestAgent(get Fetcher) (string, error) { + body, err := get("https://api.github.com/repos/" + AgentRepo + "/releases/latest") + if err != nil { + return "", err + } + defer body.Close() + var release struct { + Tag string `json:"tag_name"` + } + if err := json.NewDecoder(body).Decode(&release); err != nil { + return "", err + } + tag := strings.TrimPrefix(release.Tag, "v") + if tag == "" { + return "", fmt.Errorf("%s names no latest release", AgentRepo) + } + return tag, nil +} + +// AgentURL is where one version's asset lives. +func AgentURL(version, platform string) string { + asset := fmt.Sprintf("%s-%s-%s.tar.gz", AgentName, version, platform) + return fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", AgentRepo, version, asset) +} + +// InstallAgent puts the agent beside the CLI and returns where it went. The +// archive holds one file named for the version and platform, so what comes out +// is renamed to something a person can type. +func InstallAgent(version string, get Fetcher, out io.Writer) (string, error) { + platform, err := Platform() + if err != nil { + return "", err + } + if version == "" || version == "dev" || version == "latest" { + version, err = LatestAgent(get) + if err != nil { + return "", fmt.Errorf("could not tell which agent to install: %w", err) + } + } + + say(out, "Fetching %s %s for %s.\n", AgentName, version, platform) + body, err := get(AgentURL(version, platform)) + if err != nil { + return "", fmt.Errorf("could not fetch the agent: %w", err) + } + defer body.Close() + + if err := os.MkdirAll(BinDir(), 0o755); err != nil { + return "", err + } + path := AgentPath() + if err := extractOne(body, path); err != nil { + return "", err + } + return path, nil +} + +// extractOne writes the first regular file in a gzipped tar to path. +func extractOne(archive io.Reader, path string) error { + zipped, err := gzip.NewReader(archive) + if err != nil { + return fmt.Errorf("the agent archive is not gzip: %w", err) + } + defer zipped.Close() + + reader := tar.NewReader(zipped) + for { + header, err := reader.Next() + if err == io.EOF { + return fmt.Errorf("the agent archive held no file") + } + if err != nil { + return err + } + if header.Typeflag != tar.TypeReg { + continue + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755) + if err != nil { + return err + } + defer file.Close() + if _, err := io.Copy(file, reader); err != nil { + return err + } + return file.Chmod(0o755) + } +} diff --git a/internal/install/agent_test.go b/internal/install/agent_test.go new file mode 100644 index 0000000..c259d80 --- /dev/null +++ b/internal/install/agent_test.go @@ -0,0 +1,112 @@ +package install + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "io" + "os" + "strings" + "testing" +) + +// archive builds what a release asset actually is: one file named for the +// version and platform, gzipped inside a tar. +func archive(t *testing.T, name, body string) []byte { + t.Helper() + var buffer bytes.Buffer + zipped := gzip.NewWriter(&buffer) + writer := tar.NewWriter(zipped) + header := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg} + if err := writer.WriteHeader(header); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(body)); err != nil { + t.Fatal(err) + } + writer.Close() + zipped.Close() + return buffer.Bytes() +} + +func TestItInstallsTheAgentNamedSomethingAPersonCanType(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + platform, err := Platform() + if err != nil { + t.Skip(err) + } + asset := fmt.Sprintf("sourceant-agent-1.2.3-%s.tar.gz", platform) + var asked string + get := func(url string) (io.ReadCloser, error) { + asked = url + return io.NopCloser(bytes.NewReader(archive(t, strings.TrimSuffix(asset, ".tar.gz"), "binary"))), nil + } + + path, err := InstallAgent("1.2.3", get, io.Discard) + if err != nil { + t.Fatal(err) + } + + if !strings.HasSuffix(asked, asset) { + t.Errorf("fetched %q, want it to end in %q", asked, asset) + } + if got := path; !strings.HasSuffix(got, "/bin/sourceant-agent") { + t.Errorf("installed to %q, want a typeable name", got) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(body) != "binary" { + t.Errorf("wrote %q, want the archived file", body) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Errorf("mode %v, want it executable", info.Mode().Perm()) + } +} + +func TestItAsksWhichVersionWhenTheBuildDoesNotKnow(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + platform, err := Platform() + if err != nil { + t.Skip(err) + } + get := func(url string) (io.ReadCloser, error) { + if strings.Contains(url, "api.github.com") { + return io.NopCloser(strings.NewReader(`{"tag_name":"v9.9.9"}`)), nil + } + if !strings.Contains(url, "v9.9.9") { + t.Errorf("fetched %q, want the version the API named", url) + } + name := fmt.Sprintf("sourceant-agent-9.9.9-%s", platform) + return io.NopCloser(bytes.NewReader(archive(t, name, "binary"))), nil + } + + if _, err := InstallAgent("dev", get, io.Discard); err != nil { + t.Fatal(err) + } +} + +func TestItSaysSoWhenTheAgentCannotBeFetched(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + if _, err := Platform(); err != nil { + t.Skip(err) + } + get := func(string) (io.ReadCloser, error) { + return nil, fmt.Errorf("404 Not Found") + } + + _, err := InstallAgent("1.2.3", get, io.Discard) + + if err == nil { + t.Fatal("installed something from a fetch that failed") + } + if !strings.Contains(err.Error(), "could not fetch the agent") { + t.Errorf("got %q, want it to say what failed", err) + } +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..bd5ea72 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,81 @@ +#!/bin/sh +# Installs the SourceAnt CLI. It is the only thing anybody fetches by hand: +# `sourceant setup` brings down the agent and the core after this. +set -eu + +REPO="sourceant/cli" +BIN="sourceant" +VERSION="${SOURCEANT_VERSION:-latest}" +INSTALL_DIR="${SOURCEANT_INSTALL_DIR:-/usr/local/bin}" +# Where releases are served from. Overridden so this can be tested without +# reaching GitHub. +DOWNLOAD_BASE="${SOURCEANT_DOWNLOAD_BASE:-https://github.com/$REPO/releases/download}" +API_BASE="${SOURCEANT_API_BASE:-https://api.github.com/repos/$REPO/releases}" + +log() { printf '%s\n' "$*" >&2; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } +need curl +need tar + +platform() { + os=$(uname -s | tr '[:upper:]' '[:lower:]') + case "$os" in + linux | darwin) ;; + *) die "no build is published for $os" ;; + esac + arch=$(uname -m) + case "$arch" in + x86_64 | amd64) arch=amd64 ;; + aarch64 | arm64) arch=arm64 ;; + *) die "no build is published for $arch" ;; + esac + printf '%s-%s' "$os" "$arch" +} + +resolve() { + if [ "$VERSION" != "latest" ]; then + printf '%s' "${VERSION#v}" + return + fi + # The tag, read without jq so this works on a machine with nothing on it. + tag=$(curl -fsSL "$API_BASE/latest" | + sed -n 's/.*"tag_name" *: *"\([^"]*\)".*/\1/p' | head -1) + [ -n "$tag" ] || die "could not tell which version is latest" + printf '%s' "${tag#v}" +} + +main() { + plat=$(platform) + version=$(resolve) + asset="$BIN-$version-$plat.tar.gz" + url="$DOWNLOAD_BASE/v$version/$asset" + + log "Installing $BIN $version for $plat" + + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + + curl -fsSL "$url" -o "$tmp/$asset" || die "could not download $url" + tar -xzf "$tmp/$asset" -C "$tmp" + # The archive holds one file named for the version and platform. + [ -f "$tmp/$BIN-$version-$plat" ] || die "the archive did not hold $BIN" + chmod +x "$tmp/$BIN-$version-$plat" + + if [ -w "$INSTALL_DIR" ]; then + mv "$tmp/$BIN-$version-$plat" "$INSTALL_DIR/$BIN" + elif command -v sudo >/dev/null 2>&1; then + sudo mv "$tmp/$BIN-$version-$plat" "$INSTALL_DIR/$BIN" + else + die "$INSTALL_DIR is not writable and sudo is not here. Set SOURCEANT_INSTALL_DIR" + fi + + log "" + log "Installed $INSTALL_DIR/$BIN" + log "" + log "Next: sourceant setup # brings down the agent and the core" + log " sourceant ui # starts it and opens the view" +} + +main "$@" diff --git a/scripts/test-install.sh b/scripts/test-install.sh new file mode 100755 index 0000000..0553ad8 --- /dev/null +++ b/scripts/test-install.sh @@ -0,0 +1,43 @@ +#!/bin/sh +# Runs install.sh against a stub release inside a bare Ubuntu container, so the +# script is exercised on a machine with nothing on it rather than on a laptop +# that already has everything. +set -eu + +IMAGE="${IMAGE:-ubuntu:24.04}" +VERSION="9.9.9" + +root=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +# A release as the Go action builds one: a binary named for its version and +# platform, alone in a gzipped tar. +for plat in linux-amd64 linux-arm64; do + mkdir -p "$work/serve/v$VERSION" + printf '#!/bin/sh\necho "sourceant %s"\n' "$VERSION" > "$work/sourceant-$VERSION-$plat" + chmod +x "$work/sourceant-$VERSION-$plat" + tar -czf "$work/serve/v$VERSION/sourceant-$VERSION-$plat.tar.gz" \ + -C "$work" "sourceant-$VERSION-$plat" +done + +cp "$root/scripts/install.sh" "$work/install.sh" + +docker run --rm \ + -v "$work:/w:ro" \ + -e SOURCEANT_VERSION="$VERSION" \ + -e SOURCEANT_DOWNLOAD_BASE="file:///w/serve" \ + "$IMAGE" sh -c ' + set -eu + apt-get update -qq >/dev/null 2>&1 + apt-get install -y -qq curl >/dev/null 2>&1 + sh /w/install.sh + command -v sourceant >/dev/null || { echo "sourceant is not on PATH"; exit 1; } + [ -x /usr/local/bin/sourceant ] || { echo "not executable"; exit 1; } + out=$(sourceant) + case "$out" in + *9.9.9*) ;; + *) echo "ran but said: $out"; exit 1 ;; + esac + echo "PASS: installed, on PATH, executable, runs" + ' From 79c7713e234c7573869eaa96942ac05724d9518a Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 1 Sep 2026 02:30:48 +0100 Subject: [PATCH 2/4] feat(setup): Take the core from the wheel it publishes The python runtime installed from PyPI, where the core is not. It takes the wheel from the release matching this build instead. Nobody should have to know which runtime they have. Naming neither picks the container where Docker answers and the wheel where it does not. --- internal/command/setup.go | 14 +++++++--- internal/install/agent.go | 21 ++++++++++++-- internal/install/install.go | 47 +++++++++++++++++++++++++++---- internal/install/install_test.go | 48 ++++++++++++++++++++++++++------ 4 files changed, 111 insertions(+), 19 deletions(-) diff --git a/internal/command/setup.go b/internal/command/setup.go index c2c0f2b..e6b31ca 100644 --- a/internal/command/setup.go +++ b/internal/command/setup.go @@ -21,15 +21,21 @@ func setupCommand() *cobra.Command { Short: "Set this machine up to run SourceAnt", Long: "Installs the agent and a core, so the only thing anybody had to " + "fetch by hand is this command. 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.", + "container where there is Docker, and as a Python program where " + + "there is not. Name a runtime to decide it yourself.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + // Nobody should have to know which runtime they have. Asked for + // neither, take the container where Docker answers. + if !cmd.Flags().Changed("runtime") { + runtime = string(install.Chosen(install.Run)) + } config, err := install.Install(install.Options{ Runtime: install.Runtime(runtime), Image: image, From: from, Pull: !noPull, + Version: Version, Out: cmd.OutOrStdout(), }, install.Run) if err != nil { @@ -61,9 +67,9 @@ func setupCommand() *cobra.Command { return nil }, } - command.Flags().StringVar(&runtime, "runtime", string(install.Docker), "docker or python") + command.Flags().StringVar(&runtime, "runtime", string(install.Docker), "docker or python. Chosen for you when not named") 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().StringVar(&from, "from", "", "What pip installs, for the python runtime. Defaults to the wheel this version published") command.Flags().BoolVar(&noPull, "no-pull", false, "Use an image already on this machine") command.Flags().BoolVar(&noAgent, "no-agent", false, "Leave the agent alone, install only the core") return command diff --git a/internal/install/agent.go b/internal/install/agent.go index 075e1d7..53c19e9 100644 --- a/internal/install/agent.go +++ b/internal/install/agent.go @@ -17,6 +17,23 @@ import ( // AgentRepo publishes the agent binary. const AgentRepo = "sourceant/agent" +// downloadBase is where release assets are served from, and apiBase is what is +// asked which version is latest. Both are overridable so an install can be +// exercised without reaching GitHub. +func downloadBase() string { + if base := os.Getenv("SOURCEANT_DOWNLOAD_BASE"); base != "" { + return base + } + return "https://github.com/" + AgentRepo + "/releases/download" +} + +func apiBase() string { + if base := os.Getenv("SOURCEANT_API_BASE"); base != "" { + return base + } + return "https://api.github.com/repos/" + AgentRepo + "/releases" +} + // AgentName is the binary this installs. const AgentName = "sourceant-agent" @@ -62,7 +79,7 @@ func Get(url string) (io.ReadCloser, error) { // LatestAgent asks which version to install when none was named. func LatestAgent(get Fetcher) (string, error) { - body, err := get("https://api.github.com/repos/" + AgentRepo + "/releases/latest") + body, err := get(apiBase() + "/latest") if err != nil { return "", err } @@ -83,7 +100,7 @@ func LatestAgent(get Fetcher) (string, error) { // AgentURL is where one version's asset lives. func AgentURL(version, platform string) string { asset := fmt.Sprintf("%s-%s-%s.tar.gz", AgentName, version, platform) - return fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", AgentRepo, version, asset) + return fmt.Sprintf("%s/v%s/%s", downloadBase(), version, asset) } // InstallAgent puts the agent beside the CLI and returns where it went. The diff --git a/internal/install/install.go b/internal/install/install.go index 633ea43..1c4ad1e 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -14,6 +14,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" ) // Runtime is how the core is installed. @@ -27,8 +28,34 @@ const ( // DefaultImage is the published core. const DefaultImage = "ghcr.io/sourceant/sourceant:latest" -// DefaultPackage is the core on PyPI. -const DefaultPackage = "sourceant" +// CoreRepo publishes the core. +const CoreRepo = "sourceant/sourceant" + +// CoreWheel is the wheel one version publishes. Pip normalises the version, so +// 1.0.0-beta.2 is 1.0.0b2 in the file name. +func CoreWheel(version string) string { + return fmt.Sprintf("%s/v%s/sourceant-%s-py3-none-any.whl", + coreDownloadBase(), version, wheelVersion(version)) +} + +func coreDownloadBase() string { + if base := os.Getenv("SOURCEANT_CORE_DOWNLOAD_BASE"); base != "" { + return base + } + return "https://github.com/" + CoreRepo + "/releases/download" +} + +// wheelVersion is the version as pip writes it into a file name. +func wheelVersion(version string) string { + for _, pre := range []struct{ tag, short string }{ + {"-beta.", "b"}, {"-alpha.", "a"}, {"-rc.", "rc"}, + } { + if base, suffix, found := strings.Cut(version, pre.tag); found { + return base + pre.short + suffix + } + } + return version +} // Core is what was installed. type Core struct { @@ -100,6 +127,8 @@ type Options struct { From string // Pull says whether to fetch the image before writing anything down. Pull bool + // Version is the release to install, when nothing else names one. + Version string // Out receives progress. Out io.Writer } @@ -168,7 +197,7 @@ func installDocker(opts Options, run Runner) (Config, error) { func installPython(opts Options, run Runner) (Config, error) { from := opts.From if from == "" { - from = DefaultPackage + from = CoreWheel(opts.Version) } python, err := exec.LookPath("python3") if err != nil { @@ -190,8 +219,7 @@ func installPython(opts Options, run Runner) (Config, error) { 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) + "%s installed without a sourceant command, so there is nothing to start", from) } return Config{Core: Core{ @@ -229,3 +257,12 @@ func trim(output []byte) string { } return "…" + text[len(text)-keep:] } + +// Chosen is the runtime to use when nobody named one. Docker carries the core +// somebody else already built, so it wins wherever it answers. +func Chosen(run Runner) Runtime { + if _, err := run("docker", "version", "--format", "{{.Server.Version}}"); err == nil { + return Docker + } + return Python +} diff --git a/internal/install/install_test.go b/internal/install/install_test.go index 173fe57..ca1b024 100644 --- a/internal/install/install_test.go +++ b/internal/install/install_test.go @@ -148,10 +148,10 @@ func TestAnImageThatIsNotHereAndWasNotPulledIsRefused(t *testing.T) { } } -/* 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) { +/* A pip install can succeed and still leave nothing to run. What matters is + * that it says so rather than recording a runtime the agent would fail to + * start. */ +func TestThePythonRuntimeRefusesAnInstallThatLeftNoCommand(t *testing.T) { t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) run := &recorder{} @@ -160,11 +160,32 @@ func TestThePythonRuntimeSaysWhyItCannotWorkYet(t *testing.T) { 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(), "without a sourceant command") { + t.Errorf("got %q, want what was wrong with it", err) } - if !strings.Contains(err.Error(), "--runtime docker") { - t.Errorf("got %q, want the way that does work", err) + if !strings.Contains(err.Error(), "sourceant") { + t.Errorf("got %q, want what it tried to install", err) + } +} + +func TestThePythonRuntimeTakesTheWheelThisVersionPublished(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + run := &recorder{} + + _, _ = Install(Options{Runtime: Python, Version: "1.0.0-beta.2"}, run.run) + + var installed string + for _, call := range run.ran { + for _, arg := range call { + if strings.HasSuffix(arg, ".whl") { + installed = arg + } + } + } + // Pip normalises the version, so the tag and the file name differ. + want := "/v1.0.0-beta.2/sourceant-1.0.0b2-py3-none-any.whl" + if !strings.HasSuffix(installed, want) { + t.Errorf("installed %q, want it to end in %q", installed, want) } } @@ -173,3 +194,14 @@ func TestARuntimeThatIsNeitherIsRefused(t *testing.T) { t.Fatal("accepted a runtime that is neither") } } + +func TestTheRuntimeIsChosenByWhatIsHere(t *testing.T) { + if got := Chosen((&recorder{}).run); got != Docker { + t.Errorf("chose %q where docker answers, want docker", got) + } + + noDocker := &recorder{fail: map[string]error{"docker version": errors.New("not found")}} + if got := Chosen(noDocker.run); got != Python { + t.Errorf("chose %q where docker is absent, want python", got) + } +} From d31bb0b5c3a076f0fceec5fa312e73dc9aa87692 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 1 Sep 2026 05:33:31 +0100 Subject: [PATCH 3/4] fix(setup): Prepare the database the core reads A container migrates on every start. Nothing did that for a core installed as a program, so a fresh install served one that answered every read with a missing table. Both runtimes are driven the way somebody would drive them: install, start, and ask the API. A path that installs but never answers is not installed. --- internal/install/install.go | 8 +++ internal/install/install_test.go | 21 ++++++ scripts/test-setup.sh | 110 +++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100755 scripts/test-setup.sh diff --git a/internal/install/install.go b/internal/install/install.go index 1c4ad1e..d32558d 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -222,6 +222,14 @@ func installPython(opts Options, run Runner) (Config, error) { "%s installed without a sourceant command, so there is nothing to start", from) } + // The container migrates on every start. Nothing does that for a program + // installed here, so an unmigrated database would serve a core that answers + // every read with a missing table. + say(opts.Out, "Preparing the database\n") + if output, err := run(command, "db", "upgrade", "head"); err != nil { + return Config{}, fmt.Errorf("could not prepare the database: %s", trim(output)) + } + return Config{Core: Core{ Runtime: Python, Command: command, diff --git a/internal/install/install_test.go b/internal/install/install_test.go index ca1b024..5bfeb6b 100644 --- a/internal/install/install_test.go +++ b/internal/install/install_test.go @@ -205,3 +205,24 @@ func TestTheRuntimeIsChosenByWhatIsHere(t *testing.T) { t.Errorf("chose %q where docker is absent, want python", got) } } + +func TestThePythonRuntimePreparesTheDatabase(t *testing.T) { + home := t.TempDir() + t.Setenv("SOURCEANT_INSTALL_HOME", home) + // pip is stubbed here, so the command it would have written is put there by + // hand. Without it the install stops before any schema is prepared. + bin := filepath.Join(home, "runtime", "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, "sourceant"), nil, 0o755); err != nil { + t.Fatal(err) + } + run := &recorder{} + + _, _ = Install(Options{Runtime: Python, From: "sourceant"}, run.run) + + if !strings.Contains(run.commands(), "db upgrade head") { + t.Errorf("a core was installed without a schema:\n%s", run.commands()) + } +} diff --git a/scripts/test-setup.sh b/scripts/test-setup.sh new file mode 100755 index 0000000..1ca715c --- /dev/null +++ b/scripts/test-setup.sh @@ -0,0 +1,110 @@ +#!/bin/sh +# Drives both install paths the way somebody would: set the machine up, start +# it, and ask the API a question. A path that installs but never answers is not +# installed. +# +# The core comes from its real release. The agent comes from a release too, +# unless AGENT_BINARY names a build to serve instead, which is what makes this +# runnable before the agent has one. +set -eu + +VERSION="${VERSION:-$(cat VERSION)}" +AGENT_BINARY="${AGENT_BINARY:-}" +RUNTIMES="${RUNTIMES:-python docker}" +# Extra flags for setup, so a run can name a local image or wheel instead of +# reaching for what a release published. +SETUP_ARGS="${SETUP_ARGS:-}" + +root=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d) +served="" +agent_pid="" + +cleanup() { + [ -n "$agent_pid" ] && kill "$agent_pid" 2>/dev/null || true + [ -n "$served" ] && kill "$served" 2>/dev/null || true + docker rm -f "$(docker ps -aqf name=sourceant-core- 2>/dev/null)" 2>/dev/null || true + rm -rf "$work" +} +trap cleanup EXIT + +log() { printf '\n== %s\n' "$*"; } +die() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } + +# An agent release, or a local build dressed as one. +serve_agent() { + plat="linux-$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')" + mkdir -p "$work/serve/v$VERSION" + cp "$AGENT_BINARY" "$work/sourceant-agent-$VERSION-$plat" + chmod +x "$work/sourceant-agent-$VERSION-$plat" + tar -czf "$work/serve/v$VERSION/sourceant-agent-$VERSION-$plat.tar.gz" \ + -C "$work" "sourceant-agent-$VERSION-$plat" + (cd "$work/serve" && python3 -m http.server 8971 --bind 127.0.0.1 >/dev/null 2>&1) & + served=$! + export SOURCEANT_DOWNLOAD_BASE="http://127.0.0.1:8971" + # The port has to answer before an install asks it for anything. + for _ in $(seq 1 20); do + curl -fsS -o /dev/null "http://127.0.0.1:8971/" 2>/dev/null && return 0 + sleep 0.5 + done + die "the stub agent release never came up" +} + +answers() { + for _ in $(seq 1 "$2"); do + curl -fsS -o /dev/null "$1" 2>/dev/null && return 0 + sleep 1 + done + return 1 +} + +check() { + runtime=$1 + home="$work/home-$runtime" + log "$runtime: setting up" + SOURCEANT_INSTALL_HOME="$home" "$root/sourceant" setup --runtime "$runtime" $SETUP_ARGS + + [ -f "$home/config.json" ] || die "$runtime: nothing was written down" + grep -q "\"runtime\": \"$runtime\"" "$home/config.json" || + die "$runtime: config.json names another runtime" + [ -x "$home/bin/sourceant-agent" ] || die "$runtime: no agent was installed" + + log "$runtime: starting the agent" + SOURCEANT_INSTALL_HOME="$home" "$home/bin/sourceant-agent" >"$work/agent-$runtime.log" 2>&1 & + agent_pid=$! + + answers "http://127.0.0.1:8930/health" 120 || + die "$runtime: the agent never answered. $(tail -5 "$work/agent-$runtime.log")" + + # The agent answers before the core does. Waiting only for the agent asks + # about a core that has not finished starting and calls it down. + log "$runtime: waiting for the core" + health="" + for _ in $(seq 1 180); do + health=$(curl -fsS "http://127.0.0.1:8930/health" 2>/dev/null || true) + case "$health" in *'"core_up":true'*) break ;; esac + sleep 1 + done + printf ' %s\n' "$health" + case "$health" in + *'"core_up":true'*) ;; + *) die "$runtime: the core never came up. $(tail -8 "$work/agent-$runtime.log")" ;; + esac + + log "$runtime: asking the API" + curl -fsS "http://127.0.0.1:8930/api/repositories" >/dev/null || + die "$runtime: the API did not answer for repositories" + + kill "$agent_pid" 2>/dev/null || true + agent_pid="" + printf ' PASS: %s installs, starts, and answers\n' "$runtime" +} + +[ -x "$root/sourceant" ] || die "build the CLI first: make build" +[ -n "$AGENT_BINARY" ] && serve_agent + +for runtime in $RUNTIMES; do + check "$runtime" +done + +printf '\nPASS: %s\n' "$RUNTIMES" From d69bdf501c741d75b2fc23b49350b94c409f13f5 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 1 Sep 2026 05:36:43 +0100 Subject: [PATCH 4/4] ci: Check the published install where it can say something Installing what is published reads the script from main and takes the latest release, so on a pull request it answered about neither the change nor a release that existed. It runs once something is published, and weekly after that, where a failure means the install path is broken rather than absent. --- .github/workflows/install.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml index 2ac3628..4ffba01 100644 --- a/.github/workflows/install.yml +++ b/.github/workflows/install.yml @@ -4,6 +4,13 @@ on: push: branches: [main] pull_request: + release: + types: [published] + schedule: + # Whatever is published can rot without anybody touching this repository: + # a moved asset name, a deleted release, a GitHub change. + - cron: '0 6 * * 1' + workflow_dispatch: jobs: # The script is exercised on a machine with nothing on it, because that is @@ -16,12 +23,13 @@ jobs: - name: It installs a release into a bare Ubuntu run: sh scripts/test-install.sh - # Whether the script still works against what is actually published. It has - # nothing to install until the first release exists. + # This one reads the script from main and installs whatever is released, so + # it says nothing about a change under review. It runs once something is + # published, and weekly after that. published: + if: github.event_name != 'pull_request' && github.event_name != 'push' runs-on: ubuntu-latest container: ubuntu:24.04 - continue-on-error: true steps: - name: Tools a bare machine has run: |