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
Commit
61c045e7b221a502567393192bd5053bd0760925c90449e7132c5e758cad6c7e
Parent
2d8a379990b4963…
8 files changed
+18
+77
+55
+2
+3
+24
-1
+84
+18
| --- cmd/scuttlebot/main.go | ||
| +++ cmd/scuttlebot/main.go | ||
| @@ -2,10 +2,11 @@ | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "crypto/rand" |
| 6 | 6 | "encoding/hex" |
| 7 | + "flag" | |
| 7 | 8 | "fmt" |
| 8 | 9 | "log/slog" |
| 9 | 10 | "net/http" |
| 10 | 11 | "os" |
| 11 | 12 | "os/signal" |
| @@ -19,15 +20,32 @@ | ||
| 19 | 20 | ) |
| 20 | 21 | |
| 21 | 22 | var version = "dev" |
| 22 | 23 | |
| 23 | 24 | func main() { |
| 25 | + configPath := flag.String("config", "scuttlebot.yaml", "path to config file (YAML)") | |
| 26 | + flag.Parse() | |
| 27 | + | |
| 24 | 28 | log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) |
| 25 | 29 | |
| 26 | 30 | cfg := &config.Config{} |
| 27 | 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 | + } | |
| 28 | 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 | + } | |
| 29 | 47 | |
| 30 | 48 | // Generate an API token for the Ergo management API if not set. |
| 31 | 49 | if cfg.Ergo.APIToken == "" { |
| 32 | 50 | cfg.Ergo.APIToken = mustGenToken() |
| 33 | 51 | } |
| 34 | 52 | |
| 35 | 53 | ADDED deploy/standalone/README.md |
| 36 | 54 | ADDED deploy/standalone/install.sh |
| 37 | 55 | 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" |
No diff available
M
go.mod
+2
| --- go.mod | ||
| +++ go.mod | ||
| @@ -3,5 +3,7 @@ | ||
| 3 | 3 | go 1.26.1 |
| 4 | 4 | |
| 5 | 5 | require github.com/oklog/ulid/v2 v2.1.1 |
| 6 | 6 | |
| 7 | 7 | require github.com/lrstanley/girc v1.1.1 |
| 8 | + | |
| 9 | +require gopkg.in/yaml.v3 v3.0.1 // indirect | |
| 8 | 10 |
| --- 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 @@ | ||
| 1 | 1 | github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk= |
| 2 | 2 | github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI= |
| 3 | 3 | github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= |
| 4 | 4 | github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= |
| 5 | 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= | |
| 6 | 9 |
| --- 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 |
+24
-1
| --- internal/config/config.go | ||
| +++ internal/config/config.go | ||
| @@ -1,9 +1,14 @@ | ||
| 1 | 1 | // Package config defines scuttlebot's configuration schema. |
| 2 | 2 | package config |
| 3 | 3 | |
| 4 | -import "os" | |
| 4 | +import ( | |
| 5 | + "fmt" | |
| 6 | + "os" | |
| 7 | + | |
| 8 | + "gopkg.in/yaml.v3" | |
| 9 | +) | |
| 5 | 10 | |
| 6 | 11 | // Config is the top-level scuttlebot configuration. |
| 7 | 12 | type Config struct { |
| 8 | 13 | Ergo ErgoConfig `yaml:"ergo"` |
| 9 | 14 | Datastore DatastoreConfig `yaml:"datastore"` |
| @@ -113,10 +118,28 @@ | ||
| 113 | 118 | c.APIAddr = ":8080" |
| 114 | 119 | } |
| 115 | 120 | } |
| 116 | 121 | |
| 117 | 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 | +} | |
| 118 | 141 | |
| 119 | 142 | // ApplyEnv overrides config values with SCUTTLEBOT_* environment variables. |
| 120 | 143 | // Call after Defaults() to allow env to override defaults. |
| 121 | 144 | // |
| 122 | 145 | // Supported variables: |
| 123 | 146 | |
| 124 | 147 | 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 |
+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 |
| --- 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 |