ScuttleBot

feat: standalone deployment — YAML config, ergo auto-fetch, install script - config: LoadFile() reads scuttlebot.yaml; ApplyEnv() overrides it - config: add Config.APIAddr field, Ergo.External bool - ergo: fetch.go auto-downloads ergo binary from GitHub releases on first run - main.go: --config flag, calls EnsureBinary in managed mode - deploy/standalone: install.sh, scuttlebot.yaml.example, README - go.mod: add gopkg.in/yaml.v3 Closes #17

lmata 2026-03-31 05:15 trunk
Commit 61c045e7b221a502567393192bd5053bd0760925c90449e7132c5e758cad6c7e
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -2,10 +2,11 @@
22
33
import (
44
"context"
55
"crypto/rand"
66
"encoding/hex"
7
+ "flag"
78
"fmt"
89
"log/slog"
910
"net/http"
1011
"os"
1112
"os/signal"
@@ -19,15 +20,32 @@
1920
)
2021
2122
var version = "dev"
2223
2324
func main() {
25
+ configPath := flag.String("config", "scuttlebot.yaml", "path to config file (YAML)")
26
+ flag.Parse()
27
+
2428
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
2529
2630
cfg := &config.Config{}
2731
cfg.Defaults()
32
+ if err := cfg.LoadFile(*configPath); err != nil {
33
+ log.Error("load config", "path", *configPath, "err", err)
34
+ os.Exit(1)
35
+ }
2836
cfg.ApplyEnv()
37
+
38
+ // In managed mode, auto-fetch the ergo binary if not found.
39
+ if !cfg.Ergo.External {
40
+ binary, err := ergo.EnsureBinary(cfg.Ergo.BinaryPath, cfg.Ergo.DataDir)
41
+ if err != nil {
42
+ log.Error("ergo binary unavailable", "err", err)
43
+ os.Exit(1)
44
+ }
45
+ cfg.Ergo.BinaryPath = binary
46
+ }
2947
3048
// Generate an API token for the Ergo management API if not set.
3149
if cfg.Ergo.APIToken == "" {
3250
cfg.Ergo.APIToken = mustGenToken()
3351
}
3452
3553
ADDED deploy/standalone/README.md
3654
ADDED deploy/standalone/install.sh
3755
ADDED deploy/standalone/scuttlebot.yaml.example
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -2,10 +2,11 @@
2
3 import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
 
7 "fmt"
8 "log/slog"
9 "net/http"
10 "os"
11 "os/signal"
@@ -19,15 +20,32 @@
19 )
20
21 var version = "dev"
22
23 func main() {
 
 
 
24 log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
25
26 cfg := &config.Config{}
27 cfg.Defaults()
 
 
 
 
28 cfg.ApplyEnv()
 
 
 
 
 
 
 
 
 
 
29
30 // Generate an API token for the Ergo management API if not set.
31 if cfg.Ergo.APIToken == "" {
32 cfg.Ergo.APIToken = mustGenToken()
33 }
34
35 DDED deploy/standalone/README.md
36 DDED deploy/standalone/install.sh
37 DDED deploy/standalone/scuttlebot.yaml.example
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -2,10 +2,11 @@
2
3 import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "flag"
8 "fmt"
9 "log/slog"
10 "net/http"
11 "os"
12 "os/signal"
@@ -19,15 +20,32 @@
20 )
21
22 var version = "dev"
23
24 func main() {
25 configPath := flag.String("config", "scuttlebot.yaml", "path to config file (YAML)")
26 flag.Parse()
27
28 log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
29
30 cfg := &config.Config{}
31 cfg.Defaults()
32 if err := cfg.LoadFile(*configPath); err != nil {
33 log.Error("load config", "path", *configPath, "err", err)
34 os.Exit(1)
35 }
36 cfg.ApplyEnv()
37
38 // In managed mode, auto-fetch the ergo binary if not found.
39 if !cfg.Ergo.External {
40 binary, err := ergo.EnsureBinary(cfg.Ergo.BinaryPath, cfg.Ergo.DataDir)
41 if err != nil {
42 log.Error("ergo binary unavailable", "err", err)
43 os.Exit(1)
44 }
45 cfg.Ergo.BinaryPath = binary
46 }
47
48 // Generate an API token for the Ergo management API if not set.
49 if cfg.Ergo.APIToken == "" {
50 cfg.Ergo.APIToken = mustGenToken()
51 }
52
53 DDED deploy/standalone/README.md
54 DDED deploy/standalone/install.sh
55 DDED deploy/standalone/scuttlebot.yaml.example
--- a/deploy/standalone/README.md
+++ b/deploy/standalone/README.md
@@ -0,0 +1,77 @@
1
+# Standalone deployment
2
+
3
+Single binary. No Docker. No external dependencies. Works on Linux and macOS.
4
+
5
+scuttlebot manages the ergo IRC server as a subprocess and auto-downloads the ergo binary on first run if it isn't already present.
6
+
7
+## Install
8
+
9
+```sh
10
+curl -fsSL https://scuttlebot.dev/install.sh | bash
11
+```
12
+
13
+Or download a release directly from [GitHub Releases](https://github.com/ConflictHQ/scuttlebot/releases).
14
+
15
+## Run
16
+
17
+```sh
18
+# Start with all defaults (SQLite, loopback IRC, auto-download ergo):
19
+scuttlebot
20
+
21
+# With a config file:
22
+scuttlebot --config /etc/scuttlebot/scuttlebot.yaml
23
+```
24
+
25
+On first run, scuttlebot:
26
+1. Checks for an `ergo` binary in the configured data dir
27
+2. If not found, downloads the latest release from GitHub for your OS/arch
28
+3. Writes ergo's `ircd.yaml` config
29
+4. Starts ergo as a managed subprocess
30
+5. Starts the REST API on `:8080`
31
+
32
+The API token is printed to stderr on startup — copy it to use the REST API.
33
+
34
+## Config
35
+
36
+Copy `scuttlebot.yaml.example` to `scuttlebot.yaml` and edit. Every field has a default so the file is optional.
37
+
38
+All config values can also be set via environment variables (prefix `SCUTTLEBOT_`). See the [config reference](https://scuttlebot.dev/docs/config).
39
+
40
+## Data directory
41
+
42
+By default, data is stored under `./data/`:
43
+
44
+```
45
+./data/
46
+ ergo/
47
+ ircd.yaml # generated ergo config
48
+ ircd.db # ergo embedded database (accounts, channels, history)
49
+ ergo # ergo binary (auto-downloaded)
50
+ scuttlebot.db # scuttlebot state (SQLite)
51
+```
52
+
53
+## systemd (Linux)
54
+
55
+```ini
56
+[Unit]
57
+Description=scuttlebot IRC coordination daemon
58
+After=network.target
59
+
60
+[Service]
61
+ExecStart=/usr/local/bin/scuttlebot --config /etc/scuttlebot/scuttlebot.yaml
62
+WorkingDirectory=/var/lib/scuttlebot
63
+Restart=on-failure
64
+RestartSec=5s
65
+
66
+[Install]
67
+WantedBy=multi-user.target
68
+```
69
+
70
+## What runs where
71
+
72
+Even in standalone mode, there are two OS processes:
73
+
74
+- **scuttlebot** — the main daemon (REST API, agent registry, bots)
75
+- **ergo** — the IRC server (managed as a subprocess by scuttlebot)
76
+
77
+scuttlebot starts, monitors, and restarts ergo automatically. You only need to manage scuttlebot.
--- a/deploy/standalone/README.md
+++ b/deploy/standalone/README.md
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/deploy/standalone/README.md
+++ b/deploy/standalone/README.md
@@ -0,0 +1,77 @@
1 # Standalone deployment
2
3 Single binary. No Docker. No external dependencies. Works on Linux and macOS.
4
5 scuttlebot manages the ergo IRC server as a subprocess and auto-downloads the ergo binary on first run if it isn't already present.
6
7 ## Install
8
9 ```sh
10 curl -fsSL https://scuttlebot.dev/install.sh | bash
11 ```
12
13 Or download a release directly from [GitHub Releases](https://github.com/ConflictHQ/scuttlebot/releases).
14
15 ## Run
16
17 ```sh
18 # Start with all defaults (SQLite, loopback IRC, auto-download ergo):
19 scuttlebot
20
21 # With a config file:
22 scuttlebot --config /etc/scuttlebot/scuttlebot.yaml
23 ```
24
25 On first run, scuttlebot:
26 1. Checks for an `ergo` binary in the configured data dir
27 2. If not found, downloads the latest release from GitHub for your OS/arch
28 3. Writes ergo's `ircd.yaml` config
29 4. Starts ergo as a managed subprocess
30 5. Starts the REST API on `:8080`
31
32 The API token is printed to stderr on startup — copy it to use the REST API.
33
34 ## Config
35
36 Copy `scuttlebot.yaml.example` to `scuttlebot.yaml` and edit. Every field has a default so the file is optional.
37
38 All config values can also be set via environment variables (prefix `SCUTTLEBOT_`). See the [config reference](https://scuttlebot.dev/docs/config).
39
40 ## Data directory
41
42 By default, data is stored under `./data/`:
43
44 ```
45 ./data/
46 ergo/
47 ircd.yaml # generated ergo config
48 ircd.db # ergo embedded database (accounts, channels, history)
49 ergo # ergo binary (auto-downloaded)
50 scuttlebot.db # scuttlebot state (SQLite)
51 ```
52
53 ## systemd (Linux)
54
55 ```ini
56 [Unit]
57 Description=scuttlebot IRC coordination daemon
58 After=network.target
59
60 [Service]
61 ExecStart=/usr/local/bin/scuttlebot --config /etc/scuttlebot/scuttlebot.yaml
62 WorkingDirectory=/var/lib/scuttlebot
63 Restart=on-failure
64 RestartSec=5s
65
66 [Install]
67 WantedBy=multi-user.target
68 ```
69
70 ## What runs where
71
72 Even in standalone mode, there are two OS processes:
73
74 - **scuttlebot** — the main daemon (REST API, agent registry, bots)
75 - **ergo** — the IRC server (managed as a subprocess by scuttlebot)
76
77 scuttlebot starts, monitors, and restarts ergo automatically. You only need to manage scuttlebot.
--- a/deploy/standalone/install.sh
+++ b/deploy/standalone/install.sh
@@ -0,0 +1,55 @@
1
+#!/usr/bin/env bash
2
+# install.sh — scuttlebot standalone installer
3
+# Usage: curl -fsSL https://scuttlebot.dev/install.sh | bash
4
+# or: bash install.sh [--version v0.1.0] [--dir /usr/local/bin]
5
+set -euo pipefail
6
+
7
+REPO="ConflictHQ/scuttlebot"
8
+INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
9
+VERSION="${VERSION:-}"
10
+
11
+# Parse flags.
12
+while [[ $# -gt 0 ]]; do
13
+ case "$1" in
14
+ --version) VERSION="$2"; shift 2 ;;
15
+ --dir) INSTALL_DIR="$2"; shift 2 ;;
16
+ *) echo "Unknown option: $1" >&2; exit 1 ;;
17
+ esac
18
+done
19
+
20
+OS=$(uname -s | tr '[:upper:]' '[:lower:]')
21
+ARCH=$(uname -m)
22
+case "$ARCH" in
23
+ x86_64) ARCH="x86_64" ;;
24
+ aarch64|arm64) ARCH="arm64" ;;
25
+ *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;
26
+esac
27
+
28
+# Fetch latest version if not specified.
29
+if [[ -z "$VERSION" ]]; then
30
+ VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \
31
+ | grep '"tag_name"' | sed 's/.*"tag_name": "\(.*\)".*/\1/')
32
+fi
33
+
34
+ASSET="scuttlebot-${VERSION}-${OS}-${ARCH}.tar.gz"
35
+URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET}"
36
+
37
+echo "Installing scuttlebot ${VERSION} for ${OS}/${ARCH}..."
38
+echo "Downloading ${URL}"
39
+
40
+TMP=$(mktemp -d)
41
+trap 'rm -rf "$TMP"' EXIT
42
+
43
+curl -fsSL "$URL" -o "${TMP}/${ASSET}"
44
+tar -xzf "${TMP}/${ASSET}" -C "$TMP"
45
+
46
+install -m 755 "${TMP}/scuttlebot" "${INSTALL_DIR}/scuttlebot"
47
+
48
+echo ""
49
+echo "scuttlebot ${VERSION} installed to ${INSTALL_DIR}/scuttlebot"
50
+echo ""
51
+echo "Quick start:"
52
+echo " scuttlebot # boots ergo + daemon, auto-downloads ergo on first run"
53
+echo " scuttlebot --config /path/to/scuttlebot.yaml"
54
+echo ""
55
+echo "See: https://scuttlebot.dev/docs/deployment/standalone"
--- a/deploy/standalone/install.sh
+++ b/deploy/standalone/install.sh
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/deploy/standalone/install.sh
+++ b/deploy/standalone/install.sh
@@ -0,0 +1,55 @@
1 #!/usr/bin/env bash
2 # install.sh — scuttlebot standalone installer
3 # Usage: curl -fsSL https://scuttlebot.dev/install.sh | bash
4 # or: bash install.sh [--version v0.1.0] [--dir /usr/local/bin]
5 set -euo pipefail
6
7 REPO="ConflictHQ/scuttlebot"
8 INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
9 VERSION="${VERSION:-}"
10
11 # Parse flags.
12 while [[ $# -gt 0 ]]; do
13 case "$1" in
14 --version) VERSION="$2"; shift 2 ;;
15 --dir) INSTALL_DIR="$2"; shift 2 ;;
16 *) echo "Unknown option: $1" >&2; exit 1 ;;
17 esac
18 done
19
20 OS=$(uname -s | tr '[:upper:]' '[:lower:]')
21 ARCH=$(uname -m)
22 case "$ARCH" in
23 x86_64) ARCH="x86_64" ;;
24 aarch64|arm64) ARCH="arm64" ;;
25 *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;
26 esac
27
28 # Fetch latest version if not specified.
29 if [[ -z "$VERSION" ]]; then
30 VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \
31 | grep '"tag_name"' | sed 's/.*"tag_name": "\(.*\)".*/\1/')
32 fi
33
34 ASSET="scuttlebot-${VERSION}-${OS}-${ARCH}.tar.gz"
35 URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET}"
36
37 echo "Installing scuttlebot ${VERSION} for ${OS}/${ARCH}..."
38 echo "Downloading ${URL}"
39
40 TMP=$(mktemp -d)
41 trap 'rm -rf "$TMP"' EXIT
42
43 curl -fsSL "$URL" -o "${TMP}/${ASSET}"
44 tar -xzf "${TMP}/${ASSET}" -C "$TMP"
45
46 install -m 755 "${TMP}/scuttlebot" "${INSTALL_DIR}/scuttlebot"
47
48 echo ""
49 echo "scuttlebot ${VERSION} installed to ${INSTALL_DIR}/scuttlebot"
50 echo ""
51 echo "Quick start:"
52 echo " scuttlebot # boots ergo + daemon, auto-downloads ergo on first run"
53 echo " scuttlebot --config /path/to/scuttlebot.yaml"
54 echo ""
55 echo "See: https://scuttlebot.dev/docs/deployment/standalone"
M go.mod
+2
--- go.mod
+++ go.mod
@@ -3,5 +3,7 @@
33
go 1.26.1
44
55
require github.com/oklog/ulid/v2 v2.1.1
66
77
require github.com/lrstanley/girc v1.1.1
8
+
9
+require gopkg.in/yaml.v3 v3.0.1 // indirect
810
--- go.mod
+++ go.mod
@@ -3,5 +3,7 @@
3 go 1.26.1
4
5 require github.com/oklog/ulid/v2 v2.1.1
6
7 require github.com/lrstanley/girc v1.1.1
 
 
8
--- go.mod
+++ go.mod
@@ -3,5 +3,7 @@
3 go 1.26.1
4
5 require github.com/oklog/ulid/v2 v2.1.1
6
7 require github.com/lrstanley/girc v1.1.1
8
9 require gopkg.in/yaml.v3 v3.0.1 // indirect
10
M go.sum
+3
--- go.sum
+++ go.sum
@@ -1,5 +1,8 @@
11
github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk=
22
github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI=
33
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
44
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
55
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
6
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
7
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
8
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
69
--- go.sum
+++ go.sum
@@ -1,5 +1,8 @@
1 github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk=
2 github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI=
3 github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
4 github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
5 github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
 
 
 
6
--- go.sum
+++ go.sum
@@ -1,5 +1,8 @@
1 github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk=
2 github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI=
3 github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
4 github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
5 github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
6 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
7 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
8 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
9
--- internal/config/config.go
+++ internal/config/config.go
@@ -1,9 +1,14 @@
11
// Package config defines scuttlebot's configuration schema.
22
package config
33
4
-import "os"
4
+import (
5
+ "fmt"
6
+ "os"
7
+
8
+ "gopkg.in/yaml.v3"
9
+)
510
611
// Config is the top-level scuttlebot configuration.
712
type Config struct {
813
Ergo ErgoConfig `yaml:"ergo"`
914
Datastore DatastoreConfig `yaml:"datastore"`
@@ -113,10 +118,28 @@
113118
c.APIAddr = ":8080"
114119
}
115120
}
116121
117122
func envStr(key string) string { return os.Getenv(key) }
123
+
124
+// LoadFile reads a YAML config file into c. Missing file is not an error —
125
+// returns nil so callers can treat an absent config file as "use defaults".
126
+// Call Defaults() first, then LoadFile(), then ApplyEnv() so that file values
127
+// override defaults and env values override the file.
128
+func (c *Config) LoadFile(path string) error {
129
+ data, err := os.ReadFile(path)
130
+ if os.IsNotExist(err) {
131
+ return nil
132
+ }
133
+ if err != nil {
134
+ return fmt.Errorf("config: read %s: %w", path, err)
135
+ }
136
+ if err := yaml.Unmarshal(data, c); err != nil {
137
+ return fmt.Errorf("config: parse %s: %w", path, err)
138
+ }
139
+ return nil
140
+}
118141
119142
// ApplyEnv overrides config values with SCUTTLEBOT_* environment variables.
120143
// Call after Defaults() to allow env to override defaults.
121144
//
122145
// Supported variables:
123146
124147
ADDED internal/ergo/fetch.go
--- internal/config/config.go
+++ internal/config/config.go
@@ -1,9 +1,14 @@
1 // Package config defines scuttlebot's configuration schema.
2 package config
3
4 import "os"
 
 
 
 
 
5
6 // Config is the top-level scuttlebot configuration.
7 type Config struct {
8 Ergo ErgoConfig `yaml:"ergo"`
9 Datastore DatastoreConfig `yaml:"datastore"`
@@ -113,10 +118,28 @@
113 c.APIAddr = ":8080"
114 }
115 }
116
117 func envStr(key string) string { return os.Getenv(key) }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
119 // ApplyEnv overrides config values with SCUTTLEBOT_* environment variables.
120 // Call after Defaults() to allow env to override defaults.
121 //
122 // Supported variables:
123
124 DDED internal/ergo/fetch.go
--- internal/config/config.go
+++ internal/config/config.go
@@ -1,9 +1,14 @@
1 // Package config defines scuttlebot's configuration schema.
2 package config
3
4 import (
5 "fmt"
6 "os"
7
8 "gopkg.in/yaml.v3"
9 )
10
11 // Config is the top-level scuttlebot configuration.
12 type Config struct {
13 Ergo ErgoConfig `yaml:"ergo"`
14 Datastore DatastoreConfig `yaml:"datastore"`
@@ -113,10 +118,28 @@
118 c.APIAddr = ":8080"
119 }
120 }
121
122 func envStr(key string) string { return os.Getenv(key) }
123
124 // LoadFile reads a YAML config file into c. Missing file is not an error —
125 // returns nil so callers can treat an absent config file as "use defaults".
126 // Call Defaults() first, then LoadFile(), then ApplyEnv() so that file values
127 // override defaults and env values override the file.
128 func (c *Config) LoadFile(path string) error {
129 data, err := os.ReadFile(path)
130 if os.IsNotExist(err) {
131 return nil
132 }
133 if err != nil {
134 return fmt.Errorf("config: read %s: %w", path, err)
135 }
136 if err := yaml.Unmarshal(data, c); err != nil {
137 return fmt.Errorf("config: parse %s: %w", path, err)
138 }
139 return nil
140 }
141
142 // ApplyEnv overrides config values with SCUTTLEBOT_* environment variables.
143 // Call after Defaults() to allow env to override defaults.
144 //
145 // Supported variables:
146
147 DDED internal/ergo/fetch.go
--- a/internal/ergo/fetch.go
+++ b/internal/ergo/fetch.go
@@ -0,0 +1,84 @@
1
+package ergo
2
+
3
+import (
4
+ "archive/tar"
5
+ "compress/gzip"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+ "os"
11
+ "path/filepath"
12
+ "runtime"
13
+)
14
+
15
+const ergoGitHubAPI = "https://api.github.com/repos/ergochat/ergo/releases/latest"
16
+
17
+// EnsureBinary checks that the ergo binary exists at binaryPath. If it does
18
+// not, it downloads the latest release from GitHub into destDir and returns
19
+// the path to the installed binary.
20
+//
21
+// binaryPath is the configured path (may be just "ergo" meaning look in PATH).
22
+// destDir is where to install if not found.
23
+func EnsureBinary(binaryPath, destDir string) (string, error) {
24
+ // If it's an absolute path or the caller set a specific path, check it first.
25
+ if filepath.IsAbs(binaryPath) {
26
+ if _, err := os.Stat(binaryPath); err == nil {
27
+ return binaryPath, nil
28
+ }
29
+ }
30
+
31
+ // Check if ergo is already in our da(localPath); err == nil {
32
+ retuath); err == nil {
33
+ m GitHub releases.
34
+ version, downloadURL, err := latestReleaseURL()
35
+ if er!= nil {
36
+ return "", fmt.Errorf("ergo: fetch latest release info: %w", err)
37
+ }
38
+
39
+ fmt.Fprintf(os.Stderr, "ergo binary not found — downloading %s...\n", version)
40
+
41
+ if err := os.MkdirAll(destDir, 0o700); err != nil {
42
+ return "", fmt.Errorf("ergo: create data dir: %w", err)
43
+ }
44
+
45
+ if err := downloadAndExtract(downloadURL, destDir); err != nil {
46
+ return "", fmt.Errorf("ergo: download: %w", err)
47
+ }
48
+
49
+ fmt.Fprintf(os.Stderr, "ergo %s installed to %s\n", version, localPath)
50
+ return localPath, nil
51
+}
52
+
53
+// latestReleaseURL queries GitHub for the latest ergo release and returns
54
+// the version string and the download URL for the current OS/arch.
55
+func latestReleaseURL() (string, string, error) {
56
+ resp, err := http.Get(ergoGitHubAPI) //nolint:gosec // known GitHub API URL
57
+ if err != nil {
58
+ return "", "", err
59
+ }
60
+ defer resp.Body.Close()
61
+
62
+ var release struct {
63
+ TagName string `json:"tag_name"`
64
+ Assets []struct {
65
+ Name string `json:"name"`
66
+ BrowserDownloadURL string `json:"browser_download_url"`
67
+ } `json:"assets"`
68
+ }
69
+ if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
70
+ return "", "", err
71
+ }
72
+
73
+ suffix := platformSuffix()
74
+ for _, asset := range release.Assets {
75
+ if matchesPlatform(asset.Name, suffix) {
76
+ return release.TagName, asset.BrowserDownloadURL, nil
77
+ }
78
+ }
79
+
80
+ return "", "", fmt.Errorf("no release asset found for %s/%s (tag %s)", runtime.GOOS, runtime.GOARCH, release.TagName)
81
+}
82
+
83
+// platformSuffix returns the OS-arch suffix used in ergo release filenames.
84
+// Ergo uses "macos" instead of "darwin" and "x
--- a/internal/ergo/fetch.go
+++ b/internal/ergo/fetch.go
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/ergo/fetch.go
+++ b/internal/ergo/fetch.go
@@ -0,0 +1,84 @@
1 package ergo
2
3 import (
4 "archive/tar"
5 "compress/gzip"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "os"
11 "path/filepath"
12 "runtime"
13 )
14
15 const ergoGitHubAPI = "https://api.github.com/repos/ergochat/ergo/releases/latest"
16
17 // EnsureBinary checks that the ergo binary exists at binaryPath. If it does
18 // not, it downloads the latest release from GitHub into destDir and returns
19 // the path to the installed binary.
20 //
21 // binaryPath is the configured path (may be just "ergo" meaning look in PATH).
22 // destDir is where to install if not found.
23 func EnsureBinary(binaryPath, destDir string) (string, error) {
24 // If it's an absolute path or the caller set a specific path, check it first.
25 if filepath.IsAbs(binaryPath) {
26 if _, err := os.Stat(binaryPath); err == nil {
27 return binaryPath, nil
28 }
29 }
30
31 // Check if ergo is already in our da(localPath); err == nil {
32 retuath); err == nil {
33 m GitHub releases.
34 version, downloadURL, err := latestReleaseURL()
35 if er!= nil {
36 return "", fmt.Errorf("ergo: fetch latest release info: %w", err)
37 }
38
39 fmt.Fprintf(os.Stderr, "ergo binary not found — downloading %s...\n", version)
40
41 if err := os.MkdirAll(destDir, 0o700); err != nil {
42 return "", fmt.Errorf("ergo: create data dir: %w", err)
43 }
44
45 if err := downloadAndExtract(downloadURL, destDir); err != nil {
46 return "", fmt.Errorf("ergo: download: %w", err)
47 }
48
49 fmt.Fprintf(os.Stderr, "ergo %s installed to %s\n", version, localPath)
50 return localPath, nil
51 }
52
53 // latestReleaseURL queries GitHub for the latest ergo release and returns
54 // the version string and the download URL for the current OS/arch.
55 func latestReleaseURL() (string, string, error) {
56 resp, err := http.Get(ergoGitHubAPI) //nolint:gosec // known GitHub API URL
57 if err != nil {
58 return "", "", err
59 }
60 defer resp.Body.Close()
61
62 var release struct {
63 TagName string `json:"tag_name"`
64 Assets []struct {
65 Name string `json:"name"`
66 BrowserDownloadURL string `json:"browser_download_url"`
67 } `json:"assets"`
68 }
69 if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
70 return "", "", err
71 }
72
73 suffix := platformSuffix()
74 for _, asset := range release.Assets {
75 if matchesPlatform(asset.Name, suffix) {
76 return release.TagName, asset.BrowserDownloadURL, nil
77 }
78 }
79
80 return "", "", fmt.Errorf("no release asset found for %s/%s (tag %s)", runtime.GOOS, runtime.GOARCH, release.TagName)
81 }
82
83 // platformSuffix returns the OS-arch suffix used in ergo release filenames.
84 // Ergo uses "macos" instead of "darwin" and "x

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button