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:
parent
33d9a2cb2e
commit
d080e107d0
15 changed files with 1853 additions and 12 deletions
84
.github/workflows/ci.yml
vendored
Normal file
84
.github/workflows/ci.yml
vendored
Normal 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
154
.github/workflows/release.yml
vendored
Normal 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
9
.gitignore
vendored
|
|
@ -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
35
CHANGELOG.md
Normal 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
134
CONTRIBUTING.md
Normal 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
43
Dockerfile
Normal 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
137
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"
|
||||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.1.0
|
||||
125
deploy.yaml.example
Normal file
125
deploy.yaml.example
Normal 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
2
go.mod
|
|
@ -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
4
go.sum
Normal 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
438
internal/config/yaml.go
Normal 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]
|
||||
}
|
||||
524
internal/config/yaml_test.go
Normal file
524
internal/config/yaml_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
118
scripts/release.sh
Executable 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 ""
|
||||
Loading…
Add table
Add a link
Reference in a new issue