Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.0.0-beta.3]

### Added

- `sourceant stop` shuts down the local stack without removing its data.
- Core and agent releases can be selected independently during setup.

### Fixed

- Installation finds a release when only prereleases are available.

### Compatibility

- `sourceant stop` requires agent `1.0.0-beta.3` or later with shutdown support.
- Core compatibility is determined by the installed agent.

## [1.0.0-beta.2] - 2026-08-30

First release, versioned alongside the core it reads.
Expand Down
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,23 @@ sourceant setup

`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.
`--runtime docker` pulls the published image. `--runtime python` builds a virtual environment and installs the core's published wheel.

The core and agent versions are independent of the CLI version. By default, Docker uses the `latest` image, Python uses the latest core release, and the agent uses its latest release. Release lookup prefers a stable release and falls back to the newest published prerelease when no stable release exists. The CLI installer uses the same policy.

Pin releases independently:

```bash
sourceant setup --core-version 1.0.0-beta.2 --agent-version 1.0.0-beta.2
```

`--core-version` selects a `v`-prefixed image tag for Docker or a release wheel for Python. Use `--image` for a custom container image or `--from` for a Python package specifier or checkout. Neither can be combined with `--core-version`.

To pin the CLI installer itself:

```bash
curl -fsSL https://raw.githubusercontent.com/sourceant/cli/main/scripts/install.sh | SOURCEANT_VERSION=1.0.0-beta.2 sh
```

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.

Expand Down Expand Up @@ -78,3 +94,13 @@ make build
## Licence

MIT.

## Versioning

The CLI is versioned independently of the core, agent, and design package. A CLI release does not require matching releases of the components it installs. Use `--core-version` and `--agent-version` to select them separately, or `--image` and `--from` to choose the core directly.

CLI `1.0.0-beta.3` requires agent `1.0.0-beta.3` for `sourceant stop`. The agent defines core compatibility. For a reproducible setup, select both explicitly:

```bash
sourceant setup --agent-version 1.0.0-beta.3 --core-version 1.0.0-beta.2
```
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.0-beta.2
1.0.0-beta.3
24 changes: 15 additions & 9 deletions internal/command/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import (

func setupCommand() *cobra.Command {
var (
runtime string
image string
from string
noPull bool
noAgent bool
coreVersion string
agentVersion string
runtime string
image string
from string
noPull bool
noAgent bool
)
command := &cobra.Command{
Use: "setup",
Expand All @@ -35,7 +37,7 @@ func setupCommand() *cobra.Command {
Image: image,
From: from,
Pull: !noPull,
Version: Version,
Version: coreVersion,
Out: cmd.OutOrStdout(),
}, install.Run)
if err != nil {
Expand All @@ -56,7 +58,7 @@ func setupCommand() *cobra.Command {
_, _ = fmt.Fprintf(out, "Written to %s\n\n", path)

if !noAgent {
agentPath, err := install.InstallAgent(Version, install.Get, out)
agentPath, err := install.InstallAgent(agentVersion, install.Get, out)
if err != nil {
return fmt.Errorf("the core is installed, but the agent is not: %w", err)
}
Expand All @@ -67,10 +69,14 @@ func setupCommand() *cobra.Command {
return nil
},
}
command.Flags().StringVar(&coreVersion, "core-version", "latest", "Core release to install, independently of the CLI")
command.Flags().StringVar(&agentVersion, "agent-version", "latest", "Agent release to install, independently of the CLI")
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", "", "What pip installs, for the python runtime. Defaults to the wheel this version published")
command.Flags().StringVar(&image, "image", "", "The container to use, for the docker runtime")
command.Flags().StringVar(&from, "from", "", "What pip installs, for the python runtime. Defaults to the selected core release wheel")
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")
command.MarkFlagsMutuallyExclusive("core-version", "image")
command.MarkFlagsMutuallyExclusive("core-version", "from")
return command
}
183 changes: 183 additions & 0 deletions internal/command/setup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package command

import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func TestSetupVersionsAreIndependent(t *testing.T) {
previous := Version
Version = "unrelated-cli-version"
t.Cleanup(func() { Version = previous })
for _, tc := range []struct {
name string
args []string
want string
}{
{"docker default", []string{"--runtime", "docker"}, "ghcr.io/sourceant/sourceant:latest"},
{"docker pinned", []string{"--runtime", "docker", "--core-version", "1.0.0-beta.2"}, "ghcr.io/sourceant/sourceant:v1.0.0-beta.2"},
{"docker custom", []string{"--runtime", "docker", "--image", "custom:local"}, "custom:local"},
{"python default", []string{"--runtime", "python"}, "v1.0.0-beta.2/sourceant-1.0.0b2-py3-none-any.whl"},
{"python pinned", []string{"--runtime", "python", "--core-version", "v1.0.0-beta.2"}, "v1.0.0-beta.2/sourceant-1.0.0b2-py3-none-any.whl"},
{"python custom", []string{"--runtime", "python", "--from", "./local-core"}, "./local-core"},
} {
t.Run(tc.name, func(t *testing.T) {
home := t.TempDir()
t.Setenv("SOURCEANT_INSTALL_HOME", home)
t.Setenv("SOURCEANT_HOME", filepath.Join(home, "data"))
t.Setenv("CALLS", filepath.Join(home, "calls"))
bin := filepath.Join(home, "bin")
if err := os.MkdirAll(bin, 0755); err != nil {
t.Fatal(err)
}
for name, body := range map[string]string{
"docker": "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CALLS\"\n",
"python3": "#!/bin/sh\nexit 0\n",
} {
if err := os.WriteFile(filepath.Join(bin, name), []byte(body), 0755); err != nil {
t.Fatal(err)
}
}
runtimeBin := filepath.Join(home, "runtime", "bin")
if err := os.MkdirAll(runtimeBin, 0755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"pip", "sourceant"} {
if err := os.WriteFile(filepath.Join(runtimeBin, name), []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CALLS\"\n"), 0755); err != nil {
t.Fatal(err)
}
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/latest" {
t.Errorf("unexpected lookup: %s", r.URL)
http.NotFound(w, r)
return
}
data, err := os.ReadFile("testdata/releases/core.json")
if err != nil {
t.Error(err)
return
}
_, _ = w.Write(data)
}))
defer server.Close()
t.Setenv("SOURCEANT_CORE_API_BASE", server.URL)
var out, stderr bytes.Buffer
if code := Run(append([]string{"setup", "--no-agent"}, tc.args...), &out, &stderr); code != 0 {
t.Fatalf("%s", stderr.String())
}
calls, err := os.ReadFile(filepath.Join(home, "calls"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(calls), tc.want) {
t.Fatalf("wanted %s in %s", tc.want, calls)
}
})
}
}

func TestSetupResolvesAgentIndependently(t *testing.T) {
previous := Version
Version = "unrelated-cli-version"
t.Cleanup(func() { Version = previous })
for _, pinned := range []bool{false, true} {
t.Run(map[bool]string{false: "latest", true: "pinned"}[pinned], func(t *testing.T) {
home := t.TempDir()
t.Setenv("SOURCEANT_INSTALL_HOME", home)
t.Setenv("SOURCEANT_HOME", filepath.Join(home, "data"))
if err := os.WriteFile(filepath.Join(home, "docker"), []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", home+string(os.PathListSeparator)+os.Getenv("PATH"))
var paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.RequestURI())
if r.URL.RawQuery == "per_page=1" {
data, err := os.ReadFile("testdata/releases/agent.json")
if err != nil {
t.Error(err)
return
}
_, _ = w.Write(data)
return
}
http.NotFound(w, r)
}))
defer server.Close()
t.Setenv("SOURCEANT_API_BASE", server.URL)
t.Setenv("SOURCEANT_DOWNLOAD_BASE", server.URL)
args := []string{"setup", "--runtime", "docker"}
if pinned {
args = append(args, "--agent-version", "v1.0.0-beta.2")
}
var out, stderr bytes.Buffer
if Run(args, &out, &stderr) == 0 {
t.Fatal("download should fail")
}
if !strings.Contains(stderr.String(), "could not fetch the agent") {
t.Fatal(stderr.String())
}
if !strings.Contains(paths[len(paths)-1], "/v1.0.0-beta.2/sourceant-agent-1.0.0-beta.2-") {
t.Fatal(paths)
}
if pinned && len(paths) != 1 {
t.Fatal(paths)
}
if !pinned && (len(paths) != 3 || paths[0] != "/latest" || paths[1] != "/?per_page=1") {
t.Fatal(paths)
}
})
}
}

func TestInstallerResolvesPrerelease(t *testing.T) {
var paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.RequestURI())
if r.URL.RawQuery == "per_page=1" {
data, err := os.ReadFile("testdata/releases/cli.json")
if err != nil {
t.Error(err)
return
}
_, _ = w.Write(data)
return
}
http.NotFound(w, r)
}))
defer server.Close()
t.Setenv("SOURCEANT_VERSION", "latest")
t.Setenv("SOURCEANT_API_BASE", server.URL)
t.Setenv("SOURCEANT_DOWNLOAD_BASE", server.URL)
cmd := exec.Command("sh", "../../scripts/install.sh")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("download should fail")
}
if !strings.Contains(string(out), "could not download") {
t.Fatal(string(out))
}
if len(paths) != 3 || paths[0] != "/latest" || paths[1] != "/?per_page=1" || !strings.Contains(paths[2], "/v1.0.0-beta.2/sourceant-1.0.0-beta.2-") {
t.Fatal(paths)
}
}

func TestSetupRejectsConflictingCoreSelection(t *testing.T) {
for _, flag := range []string{"--image", "--from"} {
var out, stderr bytes.Buffer
if Run([]string{"setup", "--core-version", "1.0.0-beta.2", flag, "custom"}, &out, &stderr) == 0 {
t.Fatal("accepted conflicting selectors")
}
if !strings.Contains(stderr.String(), "none of the others can be") {
t.Fatal(stderr.String())
}
}
}
Loading
Loading