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
42 changes: 42 additions & 0 deletions .github/workflows/install.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Install script

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
# 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

# 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
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
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|
Expand All @@ -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 <repository>` | What the indexer found in one of them |
Expand Down
2 changes: 1 addition & 1 deletion internal/command/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
7 changes: 5 additions & 2 deletions internal/command/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,15 +175,18 @@ 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)

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())
}
}
Expand Down
35 changes: 27 additions & 8 deletions internal/command/install.go → internal/command/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,34 @@ 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 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 {
Expand All @@ -45,13 +54,23 @@ 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
},
}
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
}
49 changes: 48 additions & 1 deletion internal/command/ui.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand All @@ -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 {
Expand All @@ -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
Expand Down
Loading
Loading