diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7f42e87 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8da4835 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index d355dd7..c1c9e6d 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..55393cc --- /dev/null +++ b/CHANGELOG.md @@ -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 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0d6dc2a --- /dev/null +++ b/CONTRIBUTING.md @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b219cb2 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/Makefile b/Makefile index 39e5e21..bfe215d 100644 --- a/Makefile +++ b/Makefile @@ -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" \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6c6aa7c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 \ No newline at end of file diff --git a/deploy.yaml.example b/deploy.yaml.example new file mode 100644 index 0000000..9b23b16 --- /dev/null +++ b/deploy.yaml.example @@ -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: "" \ No newline at end of file diff --git a/go.mod b/go.mod index 7aa8b71..4324445 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/openboatmobile/obm go 1.22.2 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/yaml.go b/internal/config/yaml.go new file mode 100644 index 0000000..f02a311 --- /dev/null +++ b/internal/config/yaml.go @@ -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] +} \ No newline at end of file diff --git a/internal/config/yaml_test.go b/internal/config/yaml_test.go new file mode 100644 index 0000000..acc9b94 --- /dev/null +++ b/internal/config/yaml_test.go @@ -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) + } +} \ No newline at end of file diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go index 9282d47..1f111b5 100644 --- a/internal/deploy/deploy.go +++ b/internal/deploy/deploy.go @@ -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, "") +} + +// 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 != "" { + fmt.Printf("Configuration loaded from: %s\n", sourceFile) + } + fmt.Printf("Configuration written to: %s\n", outputPath) + + return nil +} + // Helpers func defaultModel(provider string) string { diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..2ebc36c --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Release automation script for obm CLI +# Usage: ./scripts/release.sh +# 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 " + 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 "" \ No newline at end of file