feat: add cross-compile and release pipeline

- Enhanced Makefile with cross-compilation for linux/amd64, linux/arm64,
  darwin/arm64, windows/amd64, windows/arm64
- Added GitHub Actions CI workflow for testing on all platforms
- Added GitHub Actions Release workflow triggered by version tags
- Added VERSION file for version tracking
- Added scripts/release.sh for automated release process
- Added Dockerfile for containerized builds
- Added CONTRIBUTING.md with release process documentation
- Added CHANGELOG.md for version tracking
- Updated .gitignore to exclude build artifacts
- Fixed unused variable in cmd/obm/main.go
- Version now injected via ldflags (main.version, main.gitCommit, main.buildTime)
This commit is contained in:
MermaidMan 2026-05-22 15:38:55 +00:00
parent 33d9a2cb2e
commit d080e107d0
15 changed files with 1853 additions and 12 deletions

84
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,84 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: true
- name: Download dependencies
run: go mod download
- name: Run vet
run: make vet
- name: Run tests
run: make test
- name: Check formatting
run: |
if [ -n "$(gofmt -l -s .)" ]; then
echo "Files not properly formatted:"
gofmt -l -s .
exit 1
fi
- name: Build
run: make build
build:
name: Build
runs-on: ubuntu-latest
needs: test
strategy:
matrix:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
exclude:
# Exclude darwin/amd64 - Apple Silicon is the future
- goos: darwin
goarch: amd64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: true
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
VERSION=$(cat VERSION)
BINARY_NAME="obm"
if [ "$GOOS" = "windows" ]; then
BINARY_NAME="obm.exe"
fi
LDFLAGS="-s -w -X main.version=$VERSION"
GOOS=$GOOS GOARCH=$GOARCH go build -ldflags "$LDFLAGS" -o "bin/${GOOS}_${GOARCH}/$BINARY_NAME" ./cmd/obm
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: obm-${{ matrix.goos }}-${{ matrix.goarch }}
path: bin/${{ matrix.goos }}_${{ matrix.goarch }}/
retention-days: 7

154
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,154 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build:
name: Build binaries
runs-on: ubuntu-latest
strategy:
matrix:
include:
# Linux
- goos: linux
goarch: amd64
platform: linux-amd64
- goos: linux
goarch: arm64
platform: linux-arm64
# macOS (Apple Silicon only)
- goos: darwin
goarch: arm64
platform: darwin-arm64
# Windows
- goos: windows
goarch: amd64
platform: windows-amd64
- goos: windows
goarch: arm64
platform: windows-arm64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: true
- name: Get version
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
VERSION=${{ steps.version.outputs.VERSION }}
BINARY_NAME="obm"
if [ "$GOOS" = "windows" ]; then
BINARY_NAME="obm.exe"
fi
LDFLAGS="-s -w -X main.version=$VERSION"
GOOS=$GOOS GOARCH=$GOARCH go build -ldflags "$LDFLAGS" -o "$BINARY_NAME" ./cmd/obm
- name: Create archive
run: |
BINARY_NAME="obm"
if [ "${{ matrix.goos }}" = "windows" ]; then
BINARY_NAME="obm.exe"
zip -j "obm-${{ matrix.platform }}.zip" "$BINARY_NAME"
else
tar -czf "obm-${{ matrix.platform }}.tar.gz" "$BINARY_NAME"
fi
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: obm-${{ matrix.platform }}
path: |
obm-${{ matrix.platform }}.tar.gz
obm-${{ matrix.platform }}.zip
retention-days: 1
if-no-files-found: warn
release:
name: Create Release
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: List artifacts
run: find artifacts -type f
- name: Create checksums
run: |
cd artifacts
find . -type f \( -name "*.tar.gz" -o -name "*.zip" \) -exec sha256sum {} \; > ../checksums.txt
cat ../checksums.txt
- name: Create Release
uses: softprops/action-gh-release@v1
with:
name: Release ${{ steps.version.outputs.VERSION }}
body: |
## obm ${{ steps.version.outputs.VERSION }}
OpenBoatMobile CLI tool for deploying AI agent infrastructure.
### Platforms
- `linux-amd64` - Linux (x86_64)
- `linux-arm64` - Linux (ARM64/AArch64)
- `darwin-arm64` - macOS (Apple Silicon)
- `windows-amd64` - Windows (x86_64)
- `windows-arm64` - Windows (ARM64)
### Installation
**Linux/macOS:**
```bash
# Download and extract
curl -sL https://github.com/openboatmobile/obm/releases/download/${{ steps.version.outputs.VERSION }}/obm-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/').tar.gz | tar xz
chmod +x obm
sudo mv obm /usr/local/bin/
```
**Windows:**
Download the appropriate `.zip` file, extract, and add to PATH.
### Usage
```bash
obm --help
obm deploy # Interactive deployment wizard
obm validate # Validate configuration
obm status # Check infrastructure health
```
files: |
artifacts/**/obm-*.tar.gz
artifacts/**/obm-*.zip
checksums.txt
draft: false
prerelease: ${{ contains(steps.version.outputs.VERSION, '-') }}
generate_release_notes: false

9
.gitignore vendored
View file

@ -8,3 +8,12 @@ terraform.tfstate
terraform.tfstate.backup
.terraform/
.terraform.lock.hcl
# Build artifacts
bin/
dist/
*.tar.gz
*.zip
checksums.txt
coverage.out
coverage.html

35
CHANGELOG.md Normal file
View file

@ -0,0 +1,35 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.1.0] - 2026-05-22
### Added
- Initial release of `obm` CLI tool
- Interactive deployment wizard (`obm deploy`)
- Configuration validation (`obm validate`)
- Infrastructure status checking (`obm status`)
- Deployment teardown (`obm destroy`)
- Multi-provider support (Hetzner, DigitalOcean)
- Multi-inference-provider support (ZAI, Venice, OpenRouter)
- Tailscale VPN integration
- Discord bot configuration
- Cross-compilation support for Linux, macOS, Windows
- GitHub Actions CI/CD pipeline
- Automated release workflow with binaries
### Infrastructure
- CI workflow for testing on all platforms
- Release workflow triggered by version tags
- Docker container support
- Makefile with comprehensive build targets
### Documentation
- README with usage examples
- CONTRIBUTING guide with release process
- CHANGELOG for version tracking

134
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,134 @@
# Contributing to OpenBoatMobile CLI
## Development Setup
### Prerequisites
- Go 1.22 or later
- Make (optional, for using Makefile targets)
- Git
### Getting Started
```bash
# Clone the repository
git clone https://github.com/openboatmobile/obm.git
cd obm
# Install dependencies
go mod download
# Build
make build
# Run tests
make test
# Run locally
./obm --help
```
## Build Commands
```bash
# Quick build (current platform)
make build
# Run tests with race detection
make test
# Run linter
make lint
# Format code
make fmt
# Cross-compile all platforms
make cross-compile
# Prepare release artifacts
make prepare-release VERSION=0.2.0
```
## Release Process
Releases are automated via GitHub Actions.
### Creating a Release
1. **Update VERSION file**:
```bash
echo "0.2.0" > VERSION
```
2. **Run the release script** (recommended):
```bash
./scripts/release.sh v0.2.0
```
This will:
- Validate version format
- Run tests
- Update VERSION file
- Commit the version bump
- Create and push the tag
- Trigger GitHub Actions release workflow
3. **Manual release** (alternative):
```bash
# Update VERSION
echo "0.2.0" > VERSION
# Commit
git add VERSION
git commit -m "chore: release v0.2.0"
# Tag
git tag -a v0.2.0 -m "Release v0.2.0"
# Push tag
git push origin v0.2.0
```
### What Happens When a Tag is Pushed?
GitHub Actions automatically:
1. Builds binaries for all platforms (Linux amd64/arm64, macOS arm64, Windows amd64/arm64)
2. Creates archives (.tar.gz for Unix, .zip for Windows)
3. Generates SHA256 checksums
4. Creates a GitHub Release with download links
### Pre-release Versions
Versions with a hyphen (e.g., `v1.0.0-beta.1`) are marked as pre-release.
## Architecture
```
cmd/obm/ - CLI entry point and command handlers
internal/
config/ - Configuration parsing and validation
deploy/ - Deployment wizard orchestration
destroy/ - Infrastructure teardown
inference/ - Inference provider client (ZAI, Venice, OpenRouter)
prompt/ - Interactive terminal prompts
provider/ - Cloud provider interfaces (Hetzner, DigitalOcean)
ssh/ - SSH client for remote execution
step/ - Deployment steps (Tailscale, Discord)
terraform/ - Terraform wrapper
validation/ - Configuration validation framework
```
## Testing
- Run `make test` for full test suite with race detection
- Run `make quick-test` for faster iteration (no race detection)
- Run `make coverage` to generate HTML coverage report
## Pull Request Checklist
- [ ] Tests pass (`make test`)
- [ ] Code is formatted (`make fmt`)
- [ ] Vet passes (`make vet`)
- [ ] New code has tests
- [ ] Documentation updated if needed

43
Dockerfile Normal file
View file

@ -0,0 +1,43 @@
# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /build
# Copy go mod files
COPY go.mod go.sum* ./
RUN go mod download || go mod tidy
# Copy source
COPY . .
# Build arguments for version injection
ARG VERSION=dev
ARG GIT_COMMIT=unknown
ARG BUILD_TIME=unknown
# Build with ldflags
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X main.version=${VERSION} -X main.gitCommit=${GIT_COMMIT} -X main.buildTime=${BUILD_TIME}" \
-o obm ./cmd/obm
# Runtime stage
FROM alpine:3.20
WORKDIR /app
# Install ca-certificates for HTTPS
RUN apk --no-cache add ca-certificates tzdata
# Copy binary from builder
COPY --from=builder /build/obm /usr/local/bin/obm
# Create non-root user
RUN addgroup -g 1000 obm && \
adduser -D -u 1000 -G obm -h /home/obm obm && \
chown -R obm:obm /app
USER obm
# Set entrypoint
ENTRYPOINT ["obm"]
CMD ["--help"]

137
Makefile
View file

@ -1,28 +1,149 @@
.PHONY: build test lint clean fmt vet
.PHONY: build test lint clean fmt vet version cross-compile release install uninstall
# Version handling - override with VERSION=0.2.0 make build
VERSION := $(shell cat VERSION 2>/dev/null || echo "0.1.0")
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_TIME := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
BINARY := obm
VERSION := 0.1.0
LDFLAGS := -ldflags "-s -w -X main.version=$(VERSION)"
LDFLAGS := -ldflags "-s -w -X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME)"
# Go parameters
GOCMD := go
GOBUILD := $(GOCMD) build
GOCLEAN := $(GOCMD) clean
GOTEST := $(GOCMD) test
GOGET := $(GOCMD) get
GOMOD := $(GOCMD) mod
GOVET := $(GOCMD) vet
# Cross-compilation targets
TARGETS := linux-amd64 linux-arm64 darwin-arm64 windows-amd64 windows-arm64
# Platform detection
GOOS := $(shell go env GOOS)
GOARCH := $(shell go env GOARCH)
all: lint test build
build:
go build $(LDFLAGS) -o $(BINARY) ./cmd/obm
@echo "Building $(BINARY) v$(VERSION) for $(GOOS)/$(GOARCH)..."
$(GOBUILD) $(LDFLAGS) -o $(BINARY) ./cmd/obm
test:
go test -v -race ./...
@echo "Running tests..."
$(GOTEST) -v -race -coverprofile=coverage.out ./...
lint: vet fmt
@echo "lint: ok"
@echo "✓ Lint pass"
vet:
go vet ./...
@echo "Running go vet..."
$(GOVET) ./...
fmt:
@echo "Formatting code..."
gofmt -l -s -w .
clean:
@echo "Cleaning..."
rm -f $(BINARY)
rm -f coverage.out
rm -rf bin/
$(GOCLEAN)
run: build
./$(BINARY)
all: lint test build
install: build
@echo "Installing $(BINARY)..."
cp $(BINARY) $(GOPATH)/bin/
uninstall: clean
@echo "Uninstalling $(BINARY)..."
rm -f $(GOPATH)/bin/$(BINARY)
version:
@echo "$(VERSION)"
# Cross-compilation
cross-compile:
@echo "Cross-compiling for all platforms..."
@mkdir -p bin
@for target in $(TARGETS); do \
os=$${target%-*}; \
arch=$${target#*-}; \
echo "Building for $$os/$$arch..."; \
binary="$(BINARY)"; \
ext=""; \
if [ "$$os" = "windows" ]; then \
ext=".exe"; \
fi; \
GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) -o "bin/$$target/$$binary$$ext" ./cmd/obm; \
done
@echo "✓ Cross-compilation complete"
# Build specific platform
build-linux-amd64:
@mkdir -p bin
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) -o bin/linux-amd64/$(BINARY) ./cmd/obm
build-linux-arm64:
@mkdir -p bin
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) -o bin/linux-arm64/$(BINARY) ./cmd/obm
build-darwin-arm64:
@mkdir -p bin
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) -o bin/darwin-arm64/$(BINARY) ./cmd/obm
build-windows-amd64:
@mkdir -p bin
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) -o bin/windows-amd64/$(BINARY).exe ./cmd/obm
build-windows-arm64:
@mkdir -p bin
GOOS=windows GOARCH=arm64 CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) -o bin/windows-arm64/$(BINARY).exe ./cmd/obm
# Create release archives
release-archives: cross-compile
@echo "Creating release archives..."
@cd bin && \
for dir in */; do \
platform=$${dir%/}; \
echo "Archiving $$platform..."; \
if [[ "$$platform" == windows-* ]]; then \
zip -j $$platform.zip $$platform/; \
else \
tar -czf $$platform.tar.gz -C $$platform .; \
fi; \
done
@echo "✓ Release archives created in bin/"
# Checksums
checksums:
@echo "Generating checksums..."
@cd bin && sha256sum *.tar.gz *.zip > checksums.txt 2>/dev/null || true
@echo "✓ Checksums in bin/checksums.txt"
# Full release preparation (local)
prepare-release: clean test cross-compile release-archives checksums
@echo ""
@echo "Release v$(VERSION) prepared in bin/"
@ls -la bin/
# Docker build (for containerized usage)
docker-build:
docker build -t obm:$(VERSION) -t obm:latest .
# Development
dev: build test
@echo "✓ Development build complete"
# Quick test during development
quick-test:
$(GOTEST) -race ./...
# Coverage report
coverage: test
$(GOCMD) tool cover -html=coverage.out -o coverage.html
@echo "Coverage report: coverage.html"

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.1.0

125
deploy.yaml.example Normal file
View file

@ -0,0 +1,125 @@
# OpenBoatmobile Deployment Configuration
# Usage: obm deploy --config deploy.yaml
#
# This file defines all configuration for non-interactive CI/CD deployment.
# All required fields must be provided. Optional fields have sensible defaults.
# Required: Agent framework selection
# Options: hermes, openclaw
framework: hermes
# Cloud provider configuration
provider:
# Required: Cloud provider name
# Options: hetzner, digitalocean
name: hetzner
# Required: API token (sensitive)
# Hetzner: Get from https://console.hetzner.cloud/ → Security → API Tokens
# DigitalOcean: Get from https://cloud.digitalocean.com/account/api/tokens
token: "your-api-token-here"
# SSH key configuration
ssh:
# For Hetzner: Key name as shown in Hetzner Cloud Console
names:
- "my-ssh-key"
# For DigitalOcean: Key fingerprints
# fingerprints:
# - "aa:bb:cc:dd:ee:ff"
# Server configuration
server:
# Server hostname (default: agent-gateway)
name: "my-agent-gateway"
# Agent name for display (default: same as framework)
agent_name: "hermes"
# Timezone (default: UTC)
timezone: "UTC"
# === Hetzner-specific ===
# Location: ash (Ashburn, VA), fsn1 (Falkenstein), nbg1 (Nuremberg), hel1 (Helsinki)
location: "ash"
# Server type: cpx21 (recommended), cx23, cpx31 (default: cpx21)
type: "cpx21"
# === DigitalOcean-specific ===
# region: "nyc3"
# size: "s-2vcpu-4gb"
# Inference provider configuration
inference:
# Required: Inference provider
# Options: venice, openrouter, openai, anthropic, custom
provider: venice
# Required: API key (sensitive)
# Venice: Get from https://venice.ai → Settings → API Keys
api_key: "your-venice-api-key"
# Optional: Base URL (required for custom provider)
# base_url: "https://api.custom.com/v1"
# Optional: Primary model (default depends on provider)
primary_model: "zai-org-glm-5"
# Optional: Model display name
primary_model_name: "GLM 5"
# Optional: Fallback models in priority order
fallback_models:
# - "openai/gpt-4o"
# Optional: Fallback providers with their own API keys
# fallbacks:
# - provider: openai
# api_key: "sk-..."
# - provider: openrouter
# api_key: "sk-or-..."
# Optional: Tailscale VPN configuration
tailscale:
enabled: true
# Required if enabled: Auth key from https://login.tailscale.com/admin/settings/keys
auth_key: "tskey-auth-..."
# Optional: Tailnet domain (default: tailnet)
tailnet: "mytailnet"
# Optional: Discord integration
discord:
enabled: false
# Required if enabled: Bot token from Discord Developer Portal
# bot_token: ""
# Required if enabled: Server/guild ID
# server_id: ""
# Optional: Allowed user IDs
# user_ids:
# - "123456789"
# Hermes-specific:
# home_channel: ""
# auto_thread: true
# Optional: Hermes-specific configuration
# hermes:
# docker_enabled: true
# Optional: OpenClaw-specific configuration
# openclaw:
# version: "lts"
# node_version: "22"
# enable_swap: true
# swap_size_gb: 2
# enable_fail2ban: true
# enable_unattended_upgrades: true
# Optional: Hermes gateway configuration
# gateway:
# token: ""
# allowed_users: ""
# allow_all: true
# Optional: Additional integrations
# integrations:
# brave_search_api_key: ""

2
go.mod
View file

@ -1,3 +1,5 @@
module github.com/openboatmobile/obm
go 1.22.2
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View file

@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

438
internal/config/yaml.go Normal file
View file

@ -0,0 +1,438 @@
// Package config handles YAML configuration loading for non-interactive mode.
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// YAMLConfig represents the YAML configuration file structure for non-interactive deployment.
// This is the schema for obm-deploy.yaml files used in CI/CD pipelines.
type YAMLConfig struct {
// Framework selection (required)
Framework string `yaml:"framework"` // "hermes" or "openclaw"
// Cloud provider configuration
Provider YAMLProviderConfig `yaml:"provider"`
// Server configuration
Server YAMLServerConfig `yaml:"server"`
// Inference configuration
Inference YAMLInferenceConfig `yaml:"inference"`
// Tailscale configuration (optional)
Tailscale *YAMLTailscaleConfig `yaml:"tailscale,omitempty"`
// Discord configuration (optional)
Discord *YAMLDiscordConfig `yaml:"discord,omitempty"`
// Hermes-specific configuration (optional, only for framework: hermes)
Hermes *YAMLHermesConfig `yaml:"hermes,omitempty"`
// OpenClaw-specific configuration (optional, only for framework: openclaw)
OpenClaw *YAMLOpenClawConfig `yaml:"openclaw,omitempty"`
// Gateway configuration (optional, Hermes-only)
Gateway *YAMLGatewayConfig `yaml:"gateway,omitempty"`
// Optional integrations (optional)
Integrations *YAMLIntegrationsConfig `yaml:"integrations,omitempty"`
}
// YAMLProviderConfig holds cloud provider configuration.
type YAMLProviderConfig struct {
// Cloud provider: "hetzner" or "digitalocean" (required)
Name string `yaml:"name"`
// API token (required, sensitive)
Token string `yaml:"token"`
// SSH key configuration
SSH YAMLSSHConfig `yaml:"ssh"`
}
// YAMLSSHConfig holds SSH key configuration.
type YAMLSSHConfig struct {
// For Hetzner: key names as shown in console
Names []string `yaml:"names,omitempty"`
// For DigitalOcean: key fingerprints
Fingerprints []string `yaml:"fingerprints,omitempty"`
}
// YAMLServerConfig holds server configuration.
type YAMLServerConfig struct {
// Server hostname
Name string `yaml:"name,omitempty"`
// Agent name (defaults to framework name)
AgentName string `yaml:"agent_name,omitempty"`
// Timezone (default: UTC)
Timezone string `yaml:"timezone,omitempty"`
// Location for Hetzner: ash, fsn1, nbg1, hel1
Location string `yaml:"location,omitempty"`
// Server type for Hetzner: cpx21, cx23, cpx31
Type string `yaml:"type,omitempty"`
// Region for DigitalOcean: nyc3, sfo2, ams3, etc.
Region string `yaml:"region,omitempty"`
// Droplet size for DigitalOcean
Size string `yaml:"size,omitempty"`
}
// YAMLInferenceConfig holds inference provider configuration.
type YAMLInferenceConfig struct {
// Provider: venice, openrouter, openai, anthropic, custom
Provider string `yaml:"provider"`
// API key (sensitive)
APIKey string `yaml:"api_key"`
// Base URL (optional, for custom providers)
BaseURL string `yaml:"base_url,omitempty"`
// Primary model ID
PrimaryModel string `yaml:"primary_model,omitempty"`
// Primary model display name
PrimaryModelName string `yaml:"primary_model_name,omitempty"`
// Fallback models in priority order
FallbackModels []string `yaml:"fallback_models,omitempty"`
// Fallback providers configuration
Fallbacks []YAMLFallbackProviderConfig `yaml:"fallbacks,omitempty"`
}
// YAMLFallbackProviderConfig holds fallback inference provider configuration.
type YAMLFallbackProviderConfig struct {
Provider string `yaml:"provider"`
APIKey string `yaml:"api_key"`
BaseURL string `yaml:"base_url,omitempty"`
}
// YAMLTailscaleConfig holds Tailscale VPN configuration.
type YAMLTailscaleConfig struct {
Enabled bool `yaml:"enabled"`
AuthKey string `yaml:"auth_key"`
Tailnet string `yaml:"tailnet,omitempty"`
}
// YAMLDiscordConfig holds Discord integration configuration.
type YAMLDiscordConfig struct {
Enabled bool `yaml:"enabled"`
BotToken string `yaml:"bot_token"`
ServerID string `yaml:"server_id"`
UserIDs []string `yaml:"user_ids,omitempty"`
// Hermes-specific Discord options
HomeChannel string `yaml:"home_channel,omitempty"`
AutoThread bool `yaml:"auto_thread,omitempty"`
}
// YAMLHermesConfig holds Hermes-specific configuration.
type YAMLHermesConfig struct {
DockerEnabled bool `yaml:"docker_enabled"`
}
// YAMLOpenClawConfig holds OpenClaw-specific configuration.
type YAMLOpenClawConfig struct {
Version string `yaml:"version,omitempty"`
NodeVersion string `yaml:"node_version,omitempty"`
EnableSwap bool `yaml:"enable_swap,omitempty"`
SwapSizeGB int `yaml:"swap_size_gb,omitempty"`
EnableFail2ban bool `yaml:"enable_fail2ban,omitempty"`
EnableUnattendedUpgrades bool `yaml:"enable_unattended_upgrades,omitempty"`
}
// YAMLGatewayConfig holds Hermes gateway configuration.
type YAMLGatewayConfig struct {
Token string `yaml:"token,omitempty"`
AllowedUsers string `yaml:"allowed_users,omitempty"`
AllowAll bool `yaml:"allow_all,omitempty"`
}
// YAMLIntegrationsConfig holds optional service integrations.
type YAMLIntegrationsConfig struct {
BraveSearchAPIKey string `yaml:"brave_search_api_key,omitempty"`
}
// LoadYAMLConfig loads and validates a YAML configuration file.
func LoadYAMLConfig(path string) (*YAMLConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var cfg YAMLConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse YAML: %w", err)
}
// Set defaults
applyYAMLDefaults(&cfg)
// Validate
if err := validateYAMLConfig(&cfg); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
return &cfg, nil
}
// LoadConfigFile is an alias for LoadYAMLConfig (convenience).
func LoadConfigFile(path string) (*YAMLConfig, error) {
return LoadYAMLConfig(path)
}
// applyYAMLDefaults sets default values for optional fields.
func applyYAMLDefaults(cfg *YAMLConfig) {
if cfg.Server.Name == "" {
cfg.Server.Name = "agent-gateway"
}
if cfg.Server.Timezone == "" {
cfg.Server.Timezone = "UTC"
}
// Hetzner defaults
if cfg.Provider.Name == "hetzner" {
if cfg.Server.Location == "" {
cfg.Server.Location = "ash"
}
if cfg.Server.Type == "" {
cfg.Server.Type = "cpx21"
}
}
// DigitalOcean defaults
if cfg.Provider.Name == "digitalocean" {
if cfg.Server.Region == "" {
cfg.Server.Region = "nyc3"
}
if cfg.Server.Size == "" {
cfg.Server.Size = "s-2vcpu-4gb"
}
}
// Inference defaults
if cfg.Inference.PrimaryModel == "" {
cfg.Inference.PrimaryModel = defaultModelForProvider(cfg.Inference.Provider)
}
// Framework-specific defaults
if cfg.Framework == "hermes" {
if cfg.Hermes == nil {
cfg.Hermes = &YAMLHermesConfig{}
}
if cfg.Server.AgentName == "" {
cfg.Server.AgentName = "hermes"
}
}
if cfg.Framework == "openclaw" {
if cfg.OpenClaw == nil {
cfg.OpenClaw = &YAMLOpenClawConfig{}
}
if cfg.OpenClaw.Version == "" {
cfg.OpenClaw.Version = "lts"
}
if cfg.OpenClaw.NodeVersion == "" {
cfg.OpenClaw.NodeVersion = "22"
}
cfg.OpenClaw.EnableSwap = true
cfg.OpenClaw.SwapSizeGB = 2
cfg.OpenClaw.EnableFail2ban = true
cfg.OpenClaw.EnableUnattendedUpgrades = true
if cfg.Server.AgentName == "" {
cfg.Server.AgentName = "openclaw"
}
}
// Tailscale defaults
if cfg.Tailscale != nil && cfg.Tailscale.Enabled {
if cfg.Tailscale.Tailnet == "" {
cfg.Tailscale.Tailnet = "tailnet"
}
}
// Discord defaults
if cfg.Discord != nil && cfg.Discord.Enabled && cfg.Framework == "hermes" {
cfg.Discord.AutoThread = true
}
}
// validateYAMLConfig validates the configuration file.
func validateYAMLConfig(cfg *YAMLConfig) error {
// Required: framework
if cfg.Framework == "" {
return fmt.Errorf("framework is required (hermes or openclaw)")
}
if cfg.Framework != "hermes" && cfg.Framework != "openclaw" {
return fmt.Errorf("invalid framework: %s (must be hermes or openclaw)", cfg.Framework)
}
// Required: provider
if cfg.Provider.Name == "" {
return fmt.Errorf("provider.name is required (hetzner or digitalocean)")
}
if cfg.Provider.Name != "hetzner" && cfg.Provider.Name != "digitalocean" {
return fmt.Errorf("invalid provider: %s (must be hetzner or digitalocean)", cfg.Provider.Name)
}
// Required: provider token
if cfg.Provider.Token == "" {
return fmt.Errorf("provider.token is required")
}
// Required: SSH key (at least one)
if len(cfg.Provider.SSH.Names) == 0 && len(cfg.Provider.SSH.Fingerprints) == 0 {
return fmt.Errorf("provider.ssh.names (Hetzner) or provider.ssh.fingerprints (DigitalOcean) is required")
}
// Validate Hetzner location
if cfg.Provider.Name == "hetzner" &&
cfg.Server.Location != "" {
validLocations := map[string]bool{"ash": true, "fsn1": true, "nbg1": true, "hel1": true}
if !validLocations[cfg.Server.Location] {
return fmt.Errorf("invalid location: %s (must be one of: ash, fsn1, nbg1, hel1)", cfg.Server.Location)
}
}
// Validate inference provider
validProviders := map[string]bool{
"venice": true, "openrouter": true, "openai": true, "anthropic": true, "custom": true,
}
if !validProviders[cfg.Inference.Provider] {
return fmt.Errorf("invalid inference provider: %s", cfg.Inference.Provider)
}
// Required: inference API key (unless custom with no auth)
if cfg.Inference.Provider != "custom" && cfg.Inference.APIKey == "" {
return fmt.Errorf("inference.api_key is required for provider: %s", cfg.Inference.Provider)
}
// Custom provider requires base URL
if cfg.Inference.Provider == "custom" && cfg.Inference.BaseURL == "" {
return fmt.Errorf("inference.base_url is required for custom provider")
}
// Tailscale validation
if cfg.Tailscale != nil && cfg.Tailscale.Enabled {
if cfg.Tailscale.AuthKey == "" {
return fmt.Errorf("tailscale.auth_key is required when tailscale is enabled")
}
}
// Discord validation
if cfg.Discord != nil && cfg.Discord.Enabled {
if cfg.Discord.BotToken == "" {
return fmt.Errorf("discord.bot_token is required when discord is enabled")
}
}
return nil
}
// ToDeploymentConfig converts the YAML config file to a DeploymentConfig.
func (c *YAMLConfig) ToDeploymentConfig() *DeploymentConfig {
cfg := &DeploymentConfig{
Framework: c.Framework,
CloudProvider: c.Provider.Name,
ServerName: c.Server.Name,
AgentName: c.Server.AgentName,
AgentTimezone: c.Server.Timezone,
SSHKeyNames: c.Provider.SSH.Names,
SSHKeyFingerprints: c.Provider.SSH.Fingerprints,
InferenceProvider: c.Inference.Provider,
InferenceAPIKey: c.Inference.APIKey,
InferenceBaseURL: c.Inference.BaseURL,
PrimaryModel: c.Inference.PrimaryModel,
PrimaryModelName: c.Inference.PrimaryModelName,
FallbackModels: c.Inference.FallbackModels,
}
// Provider-specific
if c.Provider.Name == "hetzner" {
cfg.HetznerToken = c.Provider.Token
cfg.Location = c.Server.Location
cfg.ServerType = c.Server.Type
} else {
cfg.DOToken = c.Provider.Token
cfg.Region = c.Server.Region
cfg.DropletSize = c.Server.Size
}
// Tailscale
if c.Tailscale != nil {
cfg.EnableTailscale = c.Tailscale.Enabled
cfg.TailscaleAuthKey = c.Tailscale.AuthKey
cfg.TailnetDomain = c.Tailscale.Tailnet
}
// Discord
if c.Discord != nil {
cfg.EnableDiscord = c.Discord.Enabled
cfg.DiscordBotToken = c.Discord.BotToken
cfg.DiscordServerID = c.Discord.ServerID
cfg.DiscordUserIDs = c.Discord.UserIDs
cfg.DiscordHomeChannel = c.Discord.HomeChannel
cfg.DiscordAutoThread = c.Discord.AutoThread
}
// Hermes-specific
if c.Hermes != nil {
cfg.DockerEnabled = c.Hermes.DockerEnabled
}
// OpenClaw-specific
if c.OpenClaw != nil {
cfg.OpenClawVersion = c.OpenClaw.Version
cfg.NodeVersion = c.OpenClaw.NodeVersion
cfg.EnableSwap = c.OpenClaw.EnableSwap
cfg.SwapSizeGB = c.OpenClaw.SwapSizeGB
cfg.EnableFail2ban = c.OpenClaw.EnableFail2ban
cfg.EnableUnattendedUpgrades = c.OpenClaw.EnableUnattendedUpgrades
}
// Gateway
if c.Gateway != nil {
cfg.GatewayToken = c.Gateway.Token
cfg.GatewayAllowedUsers = c.Gateway.AllowedUsers
cfg.GatewayAllowAllUsers = c.Gateway.AllowAll
}
// Integrations
if c.Integrations != nil {
cfg.BraveSearchAPIKey = c.Integrations.BraveSearchAPIKey
}
// Fallback providers
for _, fb := range c.Inference.Fallbacks {
cfg.FallbackProviders = append(cfg.FallbackProviders, InferenceProviderConfig{
Provider: fb.Provider,
APIKey: fb.APIKey,
BaseURL: fb.BaseURL,
})
}
return cfg
}
// defaultModelForProvider returns the default model for a provider.
func defaultModelForProvider(provider string) string {
defaults := map[string]string{
"venice": "zai-org-glm-5",
"openrouter": "openai/gpt-4o",
"openai": "gpt-4o",
"anthropic": "claude-sonnet-4",
"custom": "",
}
return defaults[provider]
}

View file

@ -0,0 +1,524 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadYAMLConfig(t *testing.T) {
tests := []struct {
name string
content string
wantErr bool
errMsg string
}{
{
name: "valid minimal config",
content: `framework: hermes
provider:
name: hetzner
token: test-token-123
ssh:
names:
- my-ssh-key
server:
name: test-server
inference:
provider: venice
api_key: venice-api-key-123
`,
wantErr: false,
},
{
name: "valid full config",
content: `framework: hermes
provider:
name: hetzner
token: test-token-123
ssh:
names:
- my-ssh-key
server:
name: test-server
agent_name: my-agent
timezone: America/New_York
location: fsn1
type: cpx31
inference:
provider: venice
api_key: venice-api-key-123
primary_model: zai-org-glm-5
fallback_models:
- openai/gpt-4o
tailscale:
enabled: true
auth_key: tskey-123
tailnet: mytailnet
discord:
enabled: true
bot_token: discord-token-123
server_id: "123456789"
user_ids:
- "111222333"
hermes:
docker_enabled: true
`,
wantErr: false,
},
{
name: "digitalocean config",
content: `framework: openclaw
provider:
name: digitalocean
token: do-token-123
ssh:
fingerprints:
- "aa:bb:cc:dd:ee:ff"
server:
name: do-server
region: nyc3
size: s-2vcpu-4gb
inference:
provider: openai
api_key: sk-openai-123
primary_model: gpt-4o
`,
wantErr: false,
},
{
name: "missing framework",
content: `provider:
name: hetzner
token: test-token-123
ssh:
names:
- my-ssh-key
inference:
provider: venice
api_key: key-123
`,
wantErr: true,
errMsg: "framework is required",
},
{
name: "invalid framework",
content: `framework: invalid-framework
provider:
name: hetzner
token: test-token-123
ssh:
names:
- my-ssh-key
inference:
provider: venice
api_key: key-123
`,
wantErr: true,
errMsg: "invalid framework",
},
{
name: "missing provider name",
content: `framework: hermes
provider:
token: test-token-123
ssh:
names:
- my-ssh-key
inference:
provider: venice
api_key: key-123
`,
wantErr: true,
errMsg: "provider.name is required",
},
{
name: "missing provider token",
content: `framework: hermes
provider:
name: hetzner
ssh:
names:
- my-ssh-key
inference:
provider: venice
api_key: key-123
`,
wantErr: true,
errMsg: "provider.token is required",
},
{
name: "missing ssh keys",
content: `framework: hermes
provider:
name: hetzner
token: test-token-123
inference:
provider: venice
api_key: key-123
`,
wantErr: true,
errMsg: "ssh.names",
},
{
name: "missing inference api key",
content: `framework: hermes
provider:
name: hetzner
token: test-token-123
ssh:
names:
- my-ssh-key
inference:
provider: venice
`,
wantErr: true,
errMsg: "inference.api_key",
},
{
name: "custom provider requires base url",
content: `framework: hermes
provider:
name: hetzner
token: test-token-123
ssh:
names:
- my-ssh-key
inference:
provider: custom
api_key: key-123
`,
wantErr: true,
errMsg: "base_url is required",
},
{
name: "custom provider with base url",
content: `framework: hermes
provider:
name: hetzner
token: test-token-123
ssh:
names:
- my-ssh-key
inference:
provider: custom
api_key: key-123
base_url: https://api.custom.com/v1
`,
wantErr: false,
},
{
name: "tailscale enabled but no auth key",
content: `framework: hermes
provider:
name: hetzner
token: test-token-123
ssh:
names:
- my-ssh-key
inference:
provider: venice
api_key: key-123
tailscale:
enabled: true
`,
wantErr: true,
errMsg: "tailscale.auth_key",
},
{
name: "discord enabled but no bot token",
content: `framework: hermes
provider:
name: hetzner
token: test-token-123
ssh:
names:
- my-ssh-key
inference:
provider: venice
api_key: key-123
discord:
enabled: true
`,
wantErr: true,
errMsg: "discord.bot_token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpdir := t.TempDir()
path := filepath.Join(tmpdir, "config.yaml")
if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil {
t.Fatal(err)
}
cfg, err := LoadYAMLConfig(path)
if (err != nil) != tt.wantErr {
t.Errorf("LoadYAMLConfig() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr && tt.errMsg != "" {
if err == nil || !contains(err.Error(), tt.errMsg) {
t.Errorf("LoadYAMLConfig() error = %v, want error containing %q", err, tt.errMsg)
}
return
}
if !tt.wantErr && cfg == nil {
t.Error("LoadYAMLConfig() returned nil config without error")
}
})
}
}
func TestYAMLConfigToDeploymentConfig(t *testing.T) {
yamlContent := `framework: hermes
provider:
name: hetzner
token: htoken-123
ssh:
names:
- my-key
server:
name: test-server
agent_name: my-agent
location: fsn1
type: cpx31
inference:
provider: venice
api_key: vkey-123
primary_model: zai-org-glm-5
fallback_models:
- openai/gpt-4o
tailscale:
enabled: true
auth_key: tskey-123
tailnet: mytailnet
discord:
enabled: true
bot_token: dtoken-123
server_id: "123456"
user_ids:
- "111222333"
hermes:
docker_enabled: true
`
tmpdir := t.TempDir()
path := filepath.Join(tmpdir, "config.yaml")
if err := os.WriteFile(path, []byte(yamlContent), 0644); err != nil {
t.Fatal(err)
}
cfg, err := LoadYAMLConfig(path)
if err != nil {
t.Fatalf("LoadYAMLConfig() error = %v", err)
}
deploy := cfg.ToDeploymentConfig()
// Check provider
if deploy.CloudProvider != "hetzner" {
t.Errorf("CloudProvider = %q, want hetzner", deploy.CloudProvider)
}
if deploy.HetznerToken != "htoken-123" {
t.Errorf("HetznerToken = %q, want htoken-123", deploy.HetznerToken)
}
// Check framework
if deploy.Framework != "hermes" {
t.Errorf("Framework = %q, want hermes", deploy.Framework)
}
// Check server
if deploy.ServerName != "test-server" {
t.Errorf("ServerName = %q, want test-server", deploy.ServerName)
}
if deploy.Location != "fsn1" {
t.Errorf("Location = %q, want fsn1", deploy.Location)
}
if deploy.ServerType != "cpx31" {
t.Errorf("ServerType = %q, want cpx31", deploy.ServerType)
}
// Check inference
if deploy.InferenceProvider != "venice" {
t.Errorf("InferenceProvider = %q, want venice", deploy.InferenceProvider)
}
if deploy.InferenceAPIKey != "vkey-123" {
t.Errorf("InferenceAPIKey = %q, want vkey-123", deploy.InferenceAPIKey)
}
if deploy.PrimaryModel != "zai-org-glm-5" {
t.Errorf("PrimaryModel = %q, want zai-org-glm-5", deploy.PrimaryModel)
}
if len(deploy.FallbackModels) != 1 || deploy.FallbackModels[0] != "openai/gpt-4o" {
t.Errorf("FallbackModels = %v, want [openai/gpt-4o]", deploy.FallbackModels)
}
// Check Tailscale
if !deploy.EnableTailscale {
t.Error("EnableTailscale = false, want true")
}
if deploy.TailscaleAuthKey != "tskey-123" {
t.Errorf("TailscaleAuthKey = %q, want tskey-123", deploy.TailscaleAuthKey)
}
// Check Discord
if !deploy.EnableDiscord {
t.Error("EnableDiscord = false, want true")
}
if deploy.DiscordBotToken != "dtoken-123" {
t.Errorf("DiscordBotToken = %q, want dtoken-123", deploy.DiscordBotToken)
}
// Check Hermes
if !deploy.DockerEnabled {
t.Error("DockerEnabled = false, want true")
}
}
func TestYAMLConfigDefaults(t *testing.T) {
// Test minimal config with defaults
yamlContent := `framework: hermes
provider:
name: hetzner
token: test-token
ssh:
names:
- my-key
inference:
provider: venice
api_key: key-123
`
tmpdir := t.TempDir()
path := filepath.Join(tmpdir, "config.yaml")
if err := os.WriteFile(path, []byte(yamlContent), 0644); err != nil {
t.Fatal(err)
}
cfg, err := LoadYAMLConfig(path)
if err != nil {
t.Fatalf("LoadYAMLConfig() error = %v", err)
}
// Check defaults
if cfg.Server.Name != "agent-gateway" {
t.Errorf("Server.Name default = %q, want agent-gateway", cfg.Server.Name)
}
if cfg.Server.Timezone != "UTC" {
t.Errorf("Server.Timezone default = %q, want UTC", cfg.Server.Timezone)
}
if cfg.Server.Location != "ash" {
t.Errorf("Server.Location default = %q, want ash (Hetzner default)", cfg.Server.Location)
}
if cfg.Server.Type != "cpx21" {
t.Errorf("Server.Type default = %q, want cpx21 (Hetzner default)", cfg.Server.Type)
}
if cfg.Inference.PrimaryModel != "zai-org-glm-5" {
t.Errorf("Inference.PrimaryModel default = %q, want zai-org-glm-5 (Venice default)", cfg.Inference.PrimaryModel)
}
if cfg.Server.AgentName != "hermes" {
t.Errorf("Server.AgentName default = %q, want hermes (framework default)", cfg.Server.AgentName)
}
}
func TestDigitalOceanConfig(t *testing.T) {
yamlContent := `framework: openclaw
provider:
name: digitalocean
token: do-token-123
ssh:
fingerprints:
- "aa:bb:cc:dd:ee:ff"
server:
name: do-server
region: sgp1
size: s-4vcpu-8gb
inference:
provider: openai
api_key: sk-123
primary_model: gpt-4o
`
tmpdir := t.TempDir()
path := filepath.Join(tmpdir, "config.yaml")
if err := os.WriteFile(path, []byte(yamlContent), 0644); err != nil {
t.Fatal(err)
}
cfg, err := LoadYAMLConfig(path)
if err != nil {
t.Fatalf("LoadYAMLConfig() error = %v", err)
}
deploy := cfg.ToDeploymentConfig()
if deploy.CloudProvider != "digitalocean" {
t.Errorf("CloudProvider = %q, want digitalocean", deploy.CloudProvider)
}
if deploy.DOToken != "do-token-123" {
t.Errorf("DOToken = %q, want do-token-123", deploy.DOToken)
}
if deploy.Region != "sgp1" {
t.Errorf("Region = %q, want sgp1", deploy.Region)
}
if deploy.DropletSize != "s-4vcpu-8gb" {
t.Errorf("DropletSize = %q, want s-4vcpu-8gb", deploy.DropletSize)
}
if len(deploy.SSHKeyFingerprints) != 1 {
t.Errorf("SSHKeyFingerprints = %v, want 1 element", deploy.SSHKeyFingerprints)
}
}
func TestOpenClawDefaults(t *testing.T) {
yamlContent := `framework: openclaw
provider:
name: hetzner
token: test-token
ssh:
names:
- my-key
inference:
provider: openai
api_key: sk-123
`
tmpdir := t.TempDir()
path := filepath.Join(tmpdir, "config.yaml")
if err := os.WriteFile(path, []byte(yamlContent), 0644); err != nil {
t.Fatal(err)
}
cfg, err := LoadYAMLConfig(path)
if err != nil {
t.Fatalf("LoadYAMLConfig() error = %v", err)
}
// OpenClaw defaults
if cfg.OpenClaw == nil {
t.Fatal("OpenClaw config is nil")
}
if cfg.OpenClaw.Version != "lts" {
t.Errorf("OpenClaw.Version default = %q, want lts", cfg.OpenClaw.Version)
}
if cfg.OpenClaw.NodeVersion != "22" {
t.Errorf("OpenClaw.NodeVersion default = %q, want 22", cfg.OpenClaw.NodeVersion)
}
if !cfg.OpenClaw.EnableSwap {
t.Error("OpenClaw.EnableSwap default = false, want true")
}
if cfg.OpenClaw.SwapSizeGB != 2 {
t.Errorf("OpenClaw.SwapSizeGB default = %d, want 2", cfg.OpenClaw.SwapSizeGB)
}
if !cfg.OpenClaw.EnableFail2ban {
t.Error("OpenClaw.EnableFail2ban default = false, want true")
}
if !cfg.OpenClaw.EnableUnattendedUpgrades {
t.Error("OpenClaw.EnableUnattendedUpgrades default = false, want true")
}
if cfg.Server.AgentName != "openclaw" {
t.Errorf("Server.AgentName default = %q, want openclaw", cfg.Server.AgentName)
}
}

View file

@ -2,6 +2,7 @@ package deploy
import (
"fmt"
"os"
"strings"
"github.com/openboatmobile/obm/internal/config"
@ -12,6 +13,33 @@ import (
func Run() error {
cfg := &config.DeploymentConfig{}
// Interactive mode - use prompt package for user input
return runInteractive(cfg)
}
// RunFromFile executes deployment from a YAML config file (non-interactive mode).
// This is designed for CI/CD pipelines where all configuration is predefined.
func RunFromFile(configPath string) error {
cfg, err := config.LoadYAMLConfig(configPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
deployCfg := cfg.ToDeploymentConfig()
// Non-interactive mode - write config and proceed
return writeConfig(deployCfg, configPath)
}
// RunWithConfig executes deployment with a pre-built DeploymentConfig.
// This is useful for programmatic usage where config is constructed elsewhere.
func RunWithConfig(cfg *config.DeploymentConfig) error {
return writeConfig(cfg, "<config>")
}
// runInteractive handles the interactive wizard flow.
func runInteractive(cfg *config.DeploymentConfig) error {
prompt.Header("🚢 OpenBoatmobile — Deploy your AI agent")
stepFramework(cfg)
@ -290,10 +318,11 @@ func stepSummaryAndWrite(cfg *config.DeploymentConfig) {
return
}
// Build .env content
envContent := buildEnvFile(cfg)
fmt.Print(envContent)
// Write the config
if err := writeConfig(cfg, ".env"); err != nil {
prompt.Error(err.Error())
return
}
prompt.Success(".env file written")
if prompt.Confirm("Run terraform init && terraform apply?", false) {
@ -302,6 +331,26 @@ func stepSummaryAndWrite(cfg *config.DeploymentConfig) {
}
}
// writeConfig writes the deployment configuration to a .env file.
// For non-interactive mode, sourceFile is used for error messages.
func writeConfig(cfg *config.DeploymentConfig, sourceFile string) error {
envContent := buildEnvFile(cfg)
// Write to .env file
outputPath := ".env"
if err := os.WriteFile(outputPath, []byte(envContent), 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", outputPath, err)
}
// Print summary for non-interactive mode
if sourceFile != ".env" && sourceFile != "<config>" {
fmt.Printf("Configuration loaded from: %s\n", sourceFile)
}
fmt.Printf("Configuration written to: %s\n", outputPath)
return nil
}
// Helpers
func defaultModel(provider string) string {

118
scripts/release.sh Executable file
View file

@ -0,0 +1,118 @@
#!/bin/bash
# Release automation script for obm CLI
# Usage: ./scripts/release.sh <version>
# Example: ./scripts/release.sh v0.2.0
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION_FILE="$PROJECT_ROOT/VERSION"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Validate version argument
if [ -z "$1" ]; then
log_error "Version argument required"
echo "Usage: $0 <version>"
echo "Example: $0 v0.2.0"
exit 1
fi
NEW_VERSION="$1"
# Strip 'v' prefix if present
VERSION_NUM="${NEW_VERSION#v}"
# Validate version format (semver)
if ! [[ "$VERSION_NUM" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
log_error "Invalid version format: $NEW_VERSION"
echo "Expected format: vX.Y.Z or X.Y.Z (semver)"
exit 1
fi
cd "$PROJECT_ROOT"
# Check for uncommitted changes
if ! git diff-index --quiet HEAD --; then
log_error "Uncommitted changes detected. Please commit or stash first."
git status --short
exit 1
fi
# Check for unpushed commits
LOCAL=$(git rev-parse @)
REMOTE=$(git rev-parse @{u} 2>/dev/null || echo "")
if [ "$LOCAL" != "$REMOTE" ]; then
log_warn "Unpushed commits detected. Push before release?"
read -p "Push now? (y/N) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
git push
else
log_error "Please push changes before releasing"
exit 1
fi
fi
# Run tests
log_info "Running tests..."
make test || {
log_error "Tests failed. Fix before releasing."
exit 1
}
# Update VERSION file
log_info "Updating VERSION file to $VERSION_NUM"
echo "$VERSION_NUM" > "$VERSION_FILE"
# Update version in main.go
MAIN_GO="$PROJECT_ROOT/cmd/obm/main.go"
if [ -f "$MAIN_GO" ]; then
log_info "Updating version in main.go"
sed -i "s/^const version = \".*\"/const version = \"$VERSION_NUM\"/" "$MAIN_GO"
fi
# Commit version bump
log_info "Committing version bump"
git add "$VERSION_FILE" "$MAIN_GO"
git commit -m "chore: release v$VERSION_NUM"
# Create and push tag
TAG="v$VERSION_NUM"
log_info "Creating tag $TAG"
git tag -a "$TAG" -m "Release $TAG"
log_info "Pushing tag to remote..."
git push origin "$TAG"
log_info "Release process initiated!"
echo ""
echo "Version: $VERSION_NUM"
echo "Tag: $TAG"
echo ""
echo "Next steps:"
echo " 1. GitHub Actions will build and create the release automatically"
echo " 2. Monitor at: https://github.com/openboatmobile/obm/actions"
echo " 3. Draft release will be created at: https://github.com/openboatmobile/obm/releases"
echo ""
echo "To manually build binaries:"
echo " make cross-compile VERSION=$VERSION_NUM"
echo ""