chore: import upstream snapshot with attribution
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
// Package config handles micro.mu and micro.json configuration parsing
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config represents the micro run configuration
|
||||
type Config struct {
|
||||
Services map[string]*Service `json:"services"`
|
||||
Envs map[string]map[string]string `json:"env"`
|
||||
Deploy map[string]*DeployTarget `json:"deploy"`
|
||||
}
|
||||
|
||||
// DeployTarget represents a deployment target configuration
|
||||
type DeployTarget struct {
|
||||
Name string `json:"-"`
|
||||
SSH string `json:"ssh"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// Service represents a service configuration
|
||||
type Service struct {
|
||||
Name string `json:"-"`
|
||||
Path string `json:"path"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Depends []string `json:"depends,omitempty"`
|
||||
}
|
||||
|
||||
// Load attempts to load configuration from micro.mu or micro.json in the given directory
|
||||
func Load(dir string) (*Config, error) {
|
||||
// Try micro.mu first (preferred)
|
||||
muPath := filepath.Join(dir, "micro.mu")
|
||||
if _, err := os.Stat(muPath); err == nil {
|
||||
return ParseMu(muPath)
|
||||
}
|
||||
|
||||
// Fall back to micro.json
|
||||
jsonPath := filepath.Join(dir, "micro.json")
|
||||
if _, err := os.Stat(jsonPath); err == nil {
|
||||
return ParseJSON(jsonPath)
|
||||
}
|
||||
|
||||
return nil, nil // No config file, not an error
|
||||
}
|
||||
|
||||
// ParseJSON parses a micro.json configuration file
|
||||
func ParseJSON(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read %s: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Set service names from map keys
|
||||
for name, svc := range cfg.Services {
|
||||
svc.Name = name
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// ParseMu parses a micro.mu DSL configuration file
|
||||
//
|
||||
// Format:
|
||||
//
|
||||
// service users
|
||||
// path ./users
|
||||
// port 8081
|
||||
//
|
||||
// service posts
|
||||
// path ./posts
|
||||
// port 8082
|
||||
// depends users
|
||||
//
|
||||
// env development
|
||||
// STORE_ADDRESS file://./data
|
||||
func ParseMu(path string) (*Config, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
cfg := &Config{
|
||||
Services: make(map[string]*Service),
|
||||
Envs: make(map[string]map[string]string),
|
||||
Deploy: make(map[string]*DeployTarget),
|
||||
}
|
||||
|
||||
var currentService *Service
|
||||
var currentEnv string
|
||||
var currentEnvMap map[string]string
|
||||
var currentDeploy *DeployTarget
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
lineNum := 0
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
|
||||
// Skip empty lines and comments
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check indentation
|
||||
indented := strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
|
||||
|
||||
if !indented {
|
||||
// Top-level declaration
|
||||
parts := strings.Fields(trimmed)
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("%s:%d: expected 'service <name>' or 'env <name>'", path, lineNum)
|
||||
}
|
||||
|
||||
keyword := parts[0]
|
||||
name := parts[1]
|
||||
|
||||
switch keyword {
|
||||
case "service":
|
||||
// Save previous env if any
|
||||
if currentEnv != "" && currentEnvMap != nil {
|
||||
cfg.Envs[currentEnv] = currentEnvMap
|
||||
}
|
||||
currentEnv = ""
|
||||
currentEnvMap = nil
|
||||
|
||||
currentService = &Service{Name: name}
|
||||
cfg.Services[name] = currentService
|
||||
|
||||
case "env":
|
||||
// Save previous env if any
|
||||
if currentEnv != "" && currentEnvMap != nil {
|
||||
cfg.Envs[currentEnv] = currentEnvMap
|
||||
}
|
||||
currentService = nil
|
||||
currentDeploy = nil
|
||||
currentEnv = name
|
||||
currentEnvMap = make(map[string]string)
|
||||
|
||||
case "deploy":
|
||||
// Save previous env if any
|
||||
if currentEnv != "" && currentEnvMap != nil {
|
||||
cfg.Envs[currentEnv] = currentEnvMap
|
||||
}
|
||||
currentService = nil
|
||||
currentEnv = ""
|
||||
currentEnvMap = nil
|
||||
currentDeploy = &DeployTarget{Name: name}
|
||||
cfg.Deploy[name] = currentDeploy
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("%s:%d: unknown keyword '%s'", path, lineNum, keyword)
|
||||
}
|
||||
} else {
|
||||
// Indented property
|
||||
parts := strings.Fields(trimmed)
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("%s:%d: expected 'key value'", path, lineNum)
|
||||
}
|
||||
|
||||
key := parts[0]
|
||||
value := strings.Join(parts[1:], " ")
|
||||
|
||||
if currentService != nil {
|
||||
switch key {
|
||||
case "path":
|
||||
currentService.Path = value
|
||||
case "port":
|
||||
port, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s:%d: invalid port '%s'", path, lineNum, value)
|
||||
}
|
||||
currentService.Port = port
|
||||
case "depends":
|
||||
currentService.Depends = parts[1:]
|
||||
default:
|
||||
return nil, fmt.Errorf("%s:%d: unknown service property '%s'", path, lineNum, key)
|
||||
}
|
||||
} else if currentDeploy != nil {
|
||||
switch key {
|
||||
case "ssh":
|
||||
currentDeploy.SSH = value
|
||||
case "path":
|
||||
currentDeploy.Path = value
|
||||
default:
|
||||
return nil, fmt.Errorf("%s:%d: unknown deploy property '%s'", path, lineNum, key)
|
||||
}
|
||||
} else if currentEnvMap != nil {
|
||||
// Environment variable
|
||||
currentEnvMap[key] = value
|
||||
} else {
|
||||
return nil, fmt.Errorf("%s:%d: property outside of service, deploy, or env block", path, lineNum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save final env if any
|
||||
if currentEnv != "" && currentEnvMap != nil {
|
||||
cfg.Envs[currentEnv] = currentEnvMap
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error reading %s: %w", path, err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// TopologicalSort returns services in dependency order
|
||||
func (c *Config) TopologicalSort() ([]*Service, error) {
|
||||
if c == nil || len(c.Services) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build adjacency list and in-degree count
|
||||
inDegree := make(map[string]int)
|
||||
for name := range c.Services {
|
||||
inDegree[name] = 0
|
||||
}
|
||||
|
||||
for _, svc := range c.Services {
|
||||
for _, dep := range svc.Depends {
|
||||
if _, ok := c.Services[dep]; !ok {
|
||||
return nil, fmt.Errorf("service '%s' depends on unknown service '%s'", svc.Name, dep)
|
||||
}
|
||||
inDegree[svc.Name]++
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn's algorithm
|
||||
var queue []string
|
||||
for name, degree := range inDegree {
|
||||
if degree == 0 {
|
||||
queue = append(queue, name)
|
||||
}
|
||||
}
|
||||
|
||||
var result []*Service
|
||||
for len(queue) > 0 {
|
||||
name := queue[0]
|
||||
queue = queue[1:]
|
||||
result = append(result, c.Services[name])
|
||||
|
||||
// Reduce in-degree for dependents
|
||||
for _, svc := range c.Services {
|
||||
for _, dep := range svc.Depends {
|
||||
if dep == name {
|
||||
inDegree[svc.Name]--
|
||||
if inDegree[svc.Name] == 0 {
|
||||
queue = append(queue, svc.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) != len(c.Services) {
|
||||
return nil, fmt.Errorf("circular dependency detected")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetEnv returns environment variables for the given environment name
|
||||
func (c *Config) GetEnv(name string) map[string]string {
|
||||
if c == nil || c.Envs == nil {
|
||||
return nil
|
||||
}
|
||||
return c.Envs[name]
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMu(t *testing.T) {
|
||||
content := `# Micro configuration
|
||||
service users
|
||||
path ./users
|
||||
port 8081
|
||||
|
||||
service posts
|
||||
path ./posts
|
||||
port 8082
|
||||
depends users
|
||||
|
||||
service web
|
||||
path ./web
|
||||
port 8089
|
||||
depends users posts
|
||||
|
||||
env development
|
||||
STORE_ADDRESS file://./data
|
||||
DEBUG true
|
||||
|
||||
env production
|
||||
STORE_ADDRESS postgres://localhost/db
|
||||
`
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
muPath := filepath.Join(tmpDir, "micro.mu")
|
||||
if err := os.WriteFile(muPath, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := ParseMu(muPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMu failed: %v", err)
|
||||
}
|
||||
|
||||
// Check services
|
||||
if len(cfg.Services) != 3 {
|
||||
t.Errorf("expected 3 services, got %d", len(cfg.Services))
|
||||
}
|
||||
|
||||
users := cfg.Services["users"]
|
||||
if users == nil {
|
||||
t.Fatal("users service not found")
|
||||
}
|
||||
if users.Path != "./users" {
|
||||
t.Errorf("users.Path = %q, want %q", users.Path, "./users")
|
||||
}
|
||||
if users.Port != 8081 {
|
||||
t.Errorf("users.Port = %d, want %d", users.Port, 8081)
|
||||
}
|
||||
|
||||
posts := cfg.Services["posts"]
|
||||
if posts == nil {
|
||||
t.Fatal("posts service not found")
|
||||
}
|
||||
if len(posts.Depends) != 1 || posts.Depends[0] != "users" {
|
||||
t.Errorf("posts.Depends = %v, want [users]", posts.Depends)
|
||||
}
|
||||
|
||||
web := cfg.Services["web"]
|
||||
if web == nil {
|
||||
t.Fatal("web service not found")
|
||||
}
|
||||
if len(web.Depends) != 2 {
|
||||
t.Errorf("web.Depends = %v, want [users posts]", web.Depends)
|
||||
}
|
||||
|
||||
// Check envs
|
||||
if len(cfg.Envs) != 2 {
|
||||
t.Errorf("expected 2 envs, got %d", len(cfg.Envs))
|
||||
}
|
||||
|
||||
dev := cfg.GetEnv("development")
|
||||
if dev == nil {
|
||||
t.Fatal("development env not found")
|
||||
}
|
||||
if dev["STORE_ADDRESS"] != "file://./data" {
|
||||
t.Errorf("STORE_ADDRESS = %q, want %q", dev["STORE_ADDRESS"], "file://./data")
|
||||
}
|
||||
if dev["DEBUG"] != "true" {
|
||||
t.Errorf("DEBUG = %q, want %q", dev["DEBUG"], "true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSON(t *testing.T) {
|
||||
content := `{
|
||||
"services": {
|
||||
"users": {
|
||||
"path": "./users",
|
||||
"port": 8081
|
||||
},
|
||||
"posts": {
|
||||
"path": "./posts",
|
||||
"port": 8082,
|
||||
"depends": ["users"]
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"development": {
|
||||
"STORE_ADDRESS": "file://./data"
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
jsonPath := filepath.Join(tmpDir, "micro.json")
|
||||
if err := os.WriteFile(jsonPath, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := ParseJSON(jsonPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseJSON failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.Services) != 2 {
|
||||
t.Errorf("expected 2 services, got %d", len(cfg.Services))
|
||||
}
|
||||
|
||||
users := cfg.Services["users"]
|
||||
if users == nil {
|
||||
t.Fatal("users service not found")
|
||||
}
|
||||
if users.Port != 8081 {
|
||||
t.Errorf("users.Port = %d, want %d", users.Port, 8081)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopologicalSort(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Services: map[string]*Service{
|
||||
"web": {Name: "web", Depends: []string{"users", "posts"}},
|
||||
"posts": {Name: "posts", Depends: []string{"users"}},
|
||||
"users": {Name: "users"},
|
||||
},
|
||||
}
|
||||
|
||||
sorted, err := cfg.TopologicalSort()
|
||||
if err != nil {
|
||||
t.Fatalf("TopologicalSort failed: %v", err)
|
||||
}
|
||||
|
||||
if len(sorted) != 3 {
|
||||
t.Fatalf("expected 3 services, got %d", len(sorted))
|
||||
}
|
||||
|
||||
// users must come before posts and web
|
||||
// posts must come before web
|
||||
positions := make(map[string]int)
|
||||
for i, svc := range sorted {
|
||||
positions[svc.Name] = i
|
||||
}
|
||||
|
||||
if positions["users"] > positions["posts"] {
|
||||
t.Error("users should come before posts")
|
||||
}
|
||||
if positions["users"] > positions["web"] {
|
||||
t.Error("users should come before web")
|
||||
}
|
||||
if positions["posts"] > positions["web"] {
|
||||
t.Error("posts should come before web")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircularDependency(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Services: map[string]*Service{
|
||||
"a": {Name: "a", Depends: []string{"b"}},
|
||||
"b": {Name: "b", Depends: []string{"a"}},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := cfg.TopologicalSort()
|
||||
if err == nil {
|
||||
t.Error("expected circular dependency error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
// Test with no config file
|
||||
tmpDir := t.TempDir()
|
||||
cfg, err := Load(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Error("expected nil config when no file exists")
|
||||
}
|
||||
|
||||
// Test with micro.mu
|
||||
muContent := `service test
|
||||
path ./test
|
||||
port 8080
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "micro.mu"), []byte(muContent), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err = Load(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("expected config to be loaded")
|
||||
}
|
||||
if cfg.Services["test"] == nil {
|
||||
t.Error("test service not found")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v6/ai"
|
||||
clt "go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/cmd"
|
||||
"go-micro.dev/v6/cmd/micro/cli/generate"
|
||||
"go-micro.dev/v6/cmd/micro/run/config"
|
||||
"go-micro.dev/v6/cmd/micro/run/watcher"
|
||||
"go-micro.dev/v6/cmd/micro/server"
|
||||
"go-micro.dev/v6/registry"
|
||||
|
||||
_ "go-micro.dev/v6/ai/anthropic"
|
||||
_ "go-micro.dev/v6/ai/atlascloud"
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
_ "go-micro.dev/v6/ai/together"
|
||||
)
|
||||
|
||||
// Color codes for log output
|
||||
var colors = []string{
|
||||
"\033[31m", // red
|
||||
"\033[32m", // green
|
||||
"\033[33m", // yellow
|
||||
"\033[34m", // blue
|
||||
"\033[35m", // magenta
|
||||
"\033[36m", // cyan
|
||||
}
|
||||
|
||||
const colorReset = "\033[0m"
|
||||
|
||||
func colorFor(idx int) string {
|
||||
return colors[idx%len(colors)]
|
||||
}
|
||||
|
||||
// serviceProcess tracks a running service
|
||||
type serviceProcess struct {
|
||||
name string
|
||||
dir string
|
||||
binPath string
|
||||
pidFile string
|
||||
logFile string
|
||||
cmd *exec.Cmd
|
||||
pipeWriter *io.PipeWriter
|
||||
color string
|
||||
port int
|
||||
env []string
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
}
|
||||
|
||||
func (s *serviceProcess) start(logDir string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build
|
||||
buildCmd := exec.Command("go", "build", "-o", s.binPath, ".")
|
||||
buildCmd.Dir = s.dir
|
||||
buildOut, buildErr := buildCmd.CombinedOutput()
|
||||
if buildErr != nil {
|
||||
return fmt.Errorf("build failed: %s\n%s", buildErr, string(buildOut))
|
||||
}
|
||||
|
||||
// Open log file
|
||||
logFile, err := os.OpenFile(s.logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open log file: %w", err)
|
||||
}
|
||||
|
||||
// Start process
|
||||
s.cmd = exec.Command(s.binPath)
|
||||
s.cmd.Dir = s.dir
|
||||
s.cmd.Env = append(os.Environ(), s.env...)
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
s.pipeWriter = pw
|
||||
s.cmd.Stdout = pw
|
||||
s.cmd.Stderr = pw
|
||||
|
||||
// Stream output
|
||||
go func(name string, color string, pr *io.PipeReader, logFile *os.File) {
|
||||
defer logFile.Close()
|
||||
scanner := bufio.NewScanner(pr)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fmt.Printf("%s[%s]%s %s\n", color, name, colorReset, line)
|
||||
_, _ = logFile.WriteString("[" + name + "] " + line + "\n")
|
||||
}
|
||||
}(s.name, s.color, pr, logFile)
|
||||
|
||||
if err := s.cmd.Start(); err != nil {
|
||||
pw.Close()
|
||||
return fmt.Errorf("failed to start: %w", err)
|
||||
}
|
||||
|
||||
// Write PID file
|
||||
_ = os.WriteFile(s.pidFile, []byte(fmt.Sprintf("%d\n%s\n%s\n%s\n",
|
||||
s.cmd.Process.Pid, s.dir, s.name, time.Now().Format(time.RFC3339))), 0644)
|
||||
|
||||
s.running = true
|
||||
fmt.Printf("%s[%s]%s started (pid %d)\n", s.color, s.name, colorReset, s.cmd.Process.Pid)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serviceProcess) stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.running || s.cmd == nil || s.cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%s[%s]%s stopping...\n", s.color, s.name, colorReset)
|
||||
|
||||
// Graceful shutdown
|
||||
_ = s.cmd.Process.Signal(syscall.SIGTERM)
|
||||
|
||||
// Wait with timeout
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- s.cmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
_ = s.cmd.Process.Kill()
|
||||
<-done
|
||||
}
|
||||
|
||||
if s.pipeWriter != nil {
|
||||
s.pipeWriter.Close()
|
||||
}
|
||||
|
||||
os.Remove(s.pidFile)
|
||||
s.running = false
|
||||
}
|
||||
|
||||
func (s *serviceProcess) restart(logDir string) error {
|
||||
s.stop()
|
||||
return s.start(logDir)
|
||||
}
|
||||
|
||||
// waitForHealth waits for a service's health endpoint to respond
|
||||
func waitForHealth(port int, timeout time.Duration) bool {
|
||||
if port == 0 {
|
||||
return true // No port configured, assume ready
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/health", port))
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return true
|
||||
}
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func Run(c *cli.Context) error {
|
||||
// Handle --prompt: generate services first, then run them
|
||||
if prompt := c.String("prompt"); prompt != "" {
|
||||
return runWithPrompt(c, prompt)
|
||||
}
|
||||
|
||||
dir := c.Args().Get(0)
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
|
||||
// Handle git URLs
|
||||
if strings.HasPrefix(dir, "github.com/") || strings.HasPrefix(dir, "https://github.com/") {
|
||||
repo := strings.TrimPrefix(dir, "https://")
|
||||
tmp, err := os.MkdirTemp("", "micro-run-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
cloneURL := "https://" + repo
|
||||
cloneCmd := exec.Command("git", "clone", "--depth", "1", cloneURL, tmp)
|
||||
cloneCmd.Stdout = os.Stdout
|
||||
cloneCmd.Stderr = os.Stderr
|
||||
if err := cloneCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to clone %s: %w", cloneURL, err)
|
||||
}
|
||||
dir = tmp
|
||||
}
|
||||
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get absolute path: %w", err)
|
||||
}
|
||||
|
||||
// Setup directories
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home dir: %w", err)
|
||||
}
|
||||
logsDir := filepath.Join(homeDir, "micro", "logs")
|
||||
runDir := filepath.Join(homeDir, "micro", "run")
|
||||
binDir := filepath.Join(homeDir, "micro", "bin")
|
||||
|
||||
for _, d := range []string{logsDir, runDir, binDir} {
|
||||
if err := os.MkdirAll(d, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load(absDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
// Get environment
|
||||
envName := c.String("env")
|
||||
if envName == "" {
|
||||
envName = os.Getenv("MICRO_ENV")
|
||||
}
|
||||
if envName == "" {
|
||||
envName = "development"
|
||||
}
|
||||
|
||||
var envVars []string
|
||||
if cfg != nil {
|
||||
if envMap := cfg.GetEnv(envName); envMap != nil {
|
||||
for k, v := range envMap {
|
||||
envVars = append(envVars, k+"="+v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discover services
|
||||
var services []*serviceProcess
|
||||
servicesByDir := make(map[string]*serviceProcess)
|
||||
|
||||
if cfg != nil && len(cfg.Services) > 0 {
|
||||
// Use configured services in dependency order
|
||||
sorted, err := cfg.TopologicalSort()
|
||||
if err != nil {
|
||||
return fmt.Errorf("dependency error: %w", err)
|
||||
}
|
||||
|
||||
for i, svc := range sorted {
|
||||
svcDir := filepath.Join(absDir, svc.Path)
|
||||
absSvcDir, _ := filepath.Abs(svcDir)
|
||||
hash := fmt.Sprintf("%x", md5.Sum([]byte(absSvcDir)))[:8]
|
||||
|
||||
sp := &serviceProcess{
|
||||
name: svc.Name,
|
||||
dir: absSvcDir,
|
||||
binPath: filepath.Join(binDir, svc.Name+"-"+hash),
|
||||
pidFile: filepath.Join(runDir, svc.Name+"-"+hash+".pid"),
|
||||
logFile: filepath.Join(logsDir, svc.Name+"-"+hash+".log"),
|
||||
color: colorFor(i),
|
||||
port: svc.Port,
|
||||
env: envVars,
|
||||
}
|
||||
services = append(services, sp)
|
||||
servicesByDir[absSvcDir] = sp
|
||||
}
|
||||
} else {
|
||||
// Auto-discover from main.go files
|
||||
var mainFiles []string
|
||||
_ = filepath.Walk(absDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if info.Name() == "main.go" {
|
||||
mainFiles = append(mainFiles, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if len(mainFiles) == 0 {
|
||||
return fmt.Errorf("no main.go files found in %s", absDir)
|
||||
}
|
||||
|
||||
for i, mainFile := range mainFiles {
|
||||
svcDir := filepath.Dir(mainFile)
|
||||
absSvcDir, _ := filepath.Abs(svcDir)
|
||||
|
||||
var name string
|
||||
if absSvcDir == absDir {
|
||||
name = filepath.Base(absDir)
|
||||
} else {
|
||||
name = filepath.Base(svcDir)
|
||||
}
|
||||
|
||||
hash := fmt.Sprintf("%x", md5.Sum([]byte(absSvcDir)))[:8]
|
||||
|
||||
sp := &serviceProcess{
|
||||
name: name,
|
||||
dir: absSvcDir,
|
||||
binPath: filepath.Join(binDir, name+"-"+hash),
|
||||
pidFile: filepath.Join(runDir, name+"-"+hash+".pid"),
|
||||
logFile: filepath.Join(logsDir, name+"-"+hash+".log"),
|
||||
color: colorFor(i),
|
||||
env: envVars,
|
||||
}
|
||||
services = append(services, sp)
|
||||
servicesByDir[absSvcDir] = sp
|
||||
}
|
||||
}
|
||||
|
||||
if len(services) == 0 {
|
||||
return fmt.Errorf("no services found")
|
||||
}
|
||||
|
||||
// Start gateway unless disabled
|
||||
var gw *server.Gateway
|
||||
gatewayAddr := c.String("address")
|
||||
if gatewayAddr == "" {
|
||||
gatewayAddr = ":8080"
|
||||
}
|
||||
|
||||
if !c.Bool("no-gateway") {
|
||||
var err error
|
||||
mcpAddr := c.String("mcp-address")
|
||||
gw, err = server.StartGateway(server.GatewayOptions{
|
||||
Address: gatewayAddr,
|
||||
AuthEnabled: true, // Auth enabled with default admin/micro user
|
||||
Context: context.Background(),
|
||||
MCPEnabled: mcpAddr != "",
|
||||
MCPAddress: mcpAddr,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start gateway: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start services
|
||||
for _, svc := range services {
|
||||
if err := svc.start(logsDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[%s] %v\n", svc.name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Wait for health if port configured
|
||||
if svc.port > 0 {
|
||||
if !waitForHealth(svc.port, 10*time.Second) {
|
||||
fmt.Fprintf(os.Stderr, "[%s] health check timeout\n", svc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print startup banner
|
||||
printBanner(services, gw, !c.Bool("no-watch"), c.String("mcp-address"))
|
||||
|
||||
// Setup signal handling
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// Watch mode
|
||||
watchEnabled := !c.Bool("no-watch")
|
||||
var watch *watcher.Watcher
|
||||
|
||||
if watchEnabled {
|
||||
var dirs []string
|
||||
for _, svc := range services {
|
||||
dirs = append(dirs, svc.dir)
|
||||
}
|
||||
|
||||
watch = watcher.New(dirs)
|
||||
watch.Start()
|
||||
|
||||
go func() {
|
||||
for event := range watch.Events() {
|
||||
if svc, ok := servicesByDir[event.Dir]; ok {
|
||||
fmt.Printf("%s[%s]%s rebuilding...\n", svc.color, svc.name, colorReset)
|
||||
if err := svc.restart(logsDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s[%s]%s restart failed: %v\n", svc.color, svc.name, colorReset, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Scan for new services added by micro chat or micro new
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-sigCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
newSvcs := discoverNewServices(absDir, servicesByDir, binDir, runDir, logsDir, envVars, len(services))
|
||||
for _, sp := range newSvcs {
|
||||
services = append(services, sp)
|
||||
servicesByDir[sp.dir] = sp
|
||||
watch.AddDir(sp.dir)
|
||||
if err := sp.start(logsDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[%s] %v\n", sp.name, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("\n \033[32m●\033[0m %s \033[2m(new)\033[0m\n", sp.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Interactive console or wait for signal
|
||||
if c.Bool("detach") {
|
||||
<-sigCh
|
||||
} else {
|
||||
runConsole(sigCh)
|
||||
}
|
||||
fmt.Println("\nShutting down...")
|
||||
|
||||
if watch != nil {
|
||||
watch.Stop()
|
||||
}
|
||||
|
||||
if gw != nil {
|
||||
_ = gw.Stop()
|
||||
}
|
||||
|
||||
// Stop services in reverse order
|
||||
for i := len(services) - 1; i >= 0; i-- {
|
||||
services[i].stop()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func discoverNewServices(baseDir string, known map[string]*serviceProcess, binDir, runDir, logsDir string, envVars []string, colorOffset int) []*serviceProcess {
|
||||
var newSvcs []*serviceProcess
|
||||
entries, err := os.ReadDir(baseDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
svcDir := filepath.Join(baseDir, e.Name())
|
||||
absSvcDir, _ := filepath.Abs(svcDir)
|
||||
if _, exists := known[absSvcDir]; exists {
|
||||
continue
|
||||
}
|
||||
mainFile := filepath.Join(svcDir, "main.go")
|
||||
if _, err := os.Stat(mainFile); err != nil {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
hash := fmt.Sprintf("%x", md5.Sum([]byte(absSvcDir)))[:8]
|
||||
sp := &serviceProcess{
|
||||
name: name,
|
||||
dir: absSvcDir,
|
||||
binPath: filepath.Join(binDir, name+"-"+hash),
|
||||
pidFile: filepath.Join(runDir, name+"-"+hash+".pid"),
|
||||
logFile: filepath.Join(logsDir, name+"-"+hash+".log"),
|
||||
color: colorFor(colorOffset + len(newSvcs)),
|
||||
env: envVars,
|
||||
}
|
||||
newSvcs = append(newSvcs, sp)
|
||||
}
|
||||
return newSvcs
|
||||
}
|
||||
|
||||
func printBanner(services []*serviceProcess, gw *server.Gateway, watching bool, mcpAddr string) {
|
||||
fmt.Println()
|
||||
fmt.Println(" \033[1mMicro\033[0m")
|
||||
fmt.Println()
|
||||
|
||||
if gw != nil {
|
||||
fmt.Printf(" Dashboard \033[36mhttp://localhost%s\033[0m\n", gw.Addr())
|
||||
fmt.Printf(" API \033[36mhttp://localhost%s/api/{service}/{method}\033[0m\n", gw.Addr())
|
||||
fmt.Printf(" Agent \033[36mhttp://localhost%s/agent\033[0m\n", gw.Addr())
|
||||
// MCP tools are served on the gateway by default — every endpoint is an
|
||||
// AI-callable tool, so surface it rather than hiding it behind a flag.
|
||||
fmt.Printf(" MCP Tools \033[36mhttp://localhost%s/mcp/tools\033[0m\n", gw.Addr())
|
||||
fmt.Printf(" Health \033[36mhttp://localhost%s/health\033[0m\n", gw.Addr())
|
||||
if mcpAddr != "" {
|
||||
// Optional standalone MCP protocol server (e.g. for MCP clients).
|
||||
fmt.Printf(" MCP Server \033[36mhttp://localhost%s\033[0m (full MCP protocol)\n", mcpAddr)
|
||||
fmt.Printf(" WebSocket \033[36mws://localhost%s/mcp/ws\033[0m\n", mcpAddr)
|
||||
}
|
||||
}
|
||||
|
||||
var agents, svcs []*serviceProcess
|
||||
for _, s := range services {
|
||||
if s.name == "agent" {
|
||||
agents = append(agents, s)
|
||||
} else {
|
||||
svcs = append(svcs, s)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" Services:")
|
||||
for _, svc := range svcs {
|
||||
status := "\033[32m●\033[0m"
|
||||
if !svc.running {
|
||||
status = "\033[31m●\033[0m"
|
||||
}
|
||||
fmt.Printf(" %s %s\n", status, svc.name)
|
||||
}
|
||||
|
||||
if len(agents) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println(" Agents:")
|
||||
for _, a := range agents {
|
||||
status := "\033[35m◆\033[0m"
|
||||
if !a.running {
|
||||
status = "\033[31m◆\033[0m"
|
||||
}
|
||||
fmt.Printf(" %s %s\n", status, a.name)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" Auth: \033[32menabled\033[0m (admin / micro)")
|
||||
|
||||
if watching {
|
||||
fmt.Println(" \033[33mWatching for changes...\033[0m")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func runConsole(sigCh chan os.Signal) {
|
||||
// Detect provider and API key from environment
|
||||
provider := os.Getenv("MICRO_AI_PROVIDER")
|
||||
apiKey := os.Getenv("MICRO_AI_API_KEY")
|
||||
if apiKey == "" {
|
||||
for _, env := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY",
|
||||
"ATLASCLOUD_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY", "TOGETHER_API_KEY"} {
|
||||
if v := os.Getenv(env); v != "" {
|
||||
apiKey = v
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if provider == "" {
|
||||
provider = ai.AutoDetectProvider("")
|
||||
}
|
||||
|
||||
if apiKey == "" {
|
||||
fmt.Println(" \033[2mSet MICRO_AI_API_KEY to enable the interactive console.\033[0m")
|
||||
fmt.Println(" \033[2mCtrl-C to stop.\033[0m")
|
||||
fmt.Println()
|
||||
<-sigCh
|
||||
return
|
||||
}
|
||||
|
||||
// Wait a moment for services to register
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Set up tools and model
|
||||
reg := registry.DefaultRegistry
|
||||
cl := clt.DefaultClient
|
||||
tools := ai.NewTools(reg, ai.ToolClient(cl))
|
||||
|
||||
var modelOpts []ai.Option
|
||||
modelOpts = append(modelOpts, ai.WithAPIKey(apiKey))
|
||||
modelOpts = append(modelOpts, ai.WithToolHandler(tools.Handler()))
|
||||
m := ai.New(provider, modelOpts...)
|
||||
|
||||
hist := ai.NewHistory(50)
|
||||
|
||||
// Build system prompt with service list
|
||||
discovered, _ := tools.Discover()
|
||||
serviceNames := make(map[string]bool)
|
||||
for _, t := range discovered {
|
||||
parts := strings.SplitN(t.OriginalName, ".", 2)
|
||||
if len(parts) == 2 {
|
||||
serviceNames[parts[0]] = true
|
||||
}
|
||||
}
|
||||
var svcList []string
|
||||
for name := range serviceNames {
|
||||
svcList = append(svcList, name)
|
||||
}
|
||||
|
||||
sysPrompt := fmt.Sprintf("You are an agent that orchestrates microservices. Available services: %s. "+
|
||||
"Use the available tools to fulfill requests. When you call a tool, explain what you are doing. "+
|
||||
"If a capability doesn't exist, say so.",
|
||||
strings.Join(svcList, ", "))
|
||||
|
||||
fmt.Printf(" \033[2m%d tools from %d services. Type a message or Ctrl-C to stop.\033[0m\n\n",
|
||||
len(discovered), len(serviceNames))
|
||||
|
||||
// Interactive REPL
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Buffer(make([]byte, 0, 4096), 1024*1024)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
fmt.Print("\033[1;36m>\033[0m ")
|
||||
if !scanner.Scan() {
|
||||
close(done)
|
||||
return
|
||||
}
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
hist.Add("user", line)
|
||||
resp, err := m.Generate(context.Background(), &ai.Request{
|
||||
Prompt: line,
|
||||
SystemPrompt: sysPrompt,
|
||||
Tools: discovered,
|
||||
Messages: hist.Messages(),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("\033[31merror:\033[0m %v\n\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.Reply != "" {
|
||||
hist.Add("assistant", resp.Reply)
|
||||
fmt.Println(resp.Reply)
|
||||
}
|
||||
for _, tc := range resp.ToolCalls {
|
||||
args, _ := json.Marshal(tc.Input)
|
||||
fmt.Printf(" \033[33m→\033[0m \033[2m%s\033[0m(%s)\n", tc.Name, args)
|
||||
if tc.Result != "" {
|
||||
result := tc.Result
|
||||
if len(result) > 200 {
|
||||
result = result[:200] + "..."
|
||||
}
|
||||
fmt.Printf(" \033[32m←\033[0m \033[2m%s\033[0m\n", result)
|
||||
}
|
||||
}
|
||||
if resp.Answer != "" {
|
||||
hist.Add("assistant", resp.Answer)
|
||||
fmt.Println()
|
||||
fmt.Println(resp.Answer)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-sigCh:
|
||||
case <-done:
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "run",
|
||||
Usage: "Development mode: run services with hot reload and API gateway",
|
||||
Description: `Run discovers and runs services in a directory (development mode).
|
||||
|
||||
Starts an HTTP gateway on :8080 providing:
|
||||
- Web dashboard at /
|
||||
- Agent playground at /agent (AI chat with MCP tools)
|
||||
- API explorer at /api
|
||||
- API proxy at /api/{service}/{endpoint}
|
||||
- MCP tools at /mcp/tools
|
||||
- Health checks at /health
|
||||
|
||||
With a micro.mu or micro.json config file, services start in dependency order.
|
||||
Without config, all main.go files are discovered and run.
|
||||
|
||||
Examples:
|
||||
micro run # Run with gateway on :8080
|
||||
micro run --address :3000 # Gateway on custom port
|
||||
micro run --no-gateway # Services only, no HTTP gateway
|
||||
micro run --no-watch # Disable hot reload
|
||||
micro run --env production # Use production environment
|
||||
micro run --mcp-address :3000 # Enable MCP protocol gateway
|
||||
micro run --prompt "an order system for dropshipping" # Generate and run`,
|
||||
Action: Run,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "address",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Gateway address (default :8080)",
|
||||
Value: ":8080",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-gateway",
|
||||
Usage: "Disable HTTP gateway",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-watch",
|
||||
Usage: "Disable hot reload (file watching)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "detach",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Run without interactive console (background mode)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "env",
|
||||
Aliases: []string{"e"},
|
||||
Usage: "Environment to use (default: development)",
|
||||
EnvVars: []string{"MICRO_ENV"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mcp-address",
|
||||
Usage: "MCP gateway address (e.g., :3000). Enables MCP protocol for AI tools.",
|
||||
EnvVars: []string{"MICRO_MCP_ADDRESS"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "prompt",
|
||||
Usage: "Describe a system to generate and run (AI designs, builds, and starts services)",
|
||||
EnvVars: []string{"MICRO_RUN_PROMPT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "provider",
|
||||
Usage: "AI provider for --prompt (anthropic, openai, gemini, atlascloud, groq, mistral, together)",
|
||||
EnvVars: []string{"MICRO_AI_PROVIDER"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "api_key",
|
||||
Usage: "API key for --prompt (or set ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)",
|
||||
EnvVars: []string{"MICRO_AI_API_KEY"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runWithPrompt(c *cli.Context, prompt string) error {
|
||||
provider := c.String("provider")
|
||||
apiKey := c.String("api_key")
|
||||
if apiKey == "" {
|
||||
for _, env := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY",
|
||||
"ATLASCLOUD_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY", "TOGETHER_API_KEY", "MICRO_AI_API_KEY"} {
|
||||
if v := os.Getenv(env); v != "" {
|
||||
apiKey = v
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("--api_key or a provider API key env var is required for --prompt")
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" \033[1mmicro run --prompt\033[0m")
|
||||
fmt.Println()
|
||||
fmt.Printf(" \033[2mDesigning services for:\033[0m %s\n\n", prompt)
|
||||
|
||||
design, err := generate.Design(ctx, provider, apiKey, "", ".", prompt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("design failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(" Services:")
|
||||
for _, svc := range design.Services {
|
||||
fmt.Printf(" \033[32m●\033[0m \033[36m%s\033[0m — %s\n", svc.Name, svc.Description)
|
||||
for _, ep := range svc.Endpoints {
|
||||
fmt.Printf(" %s: %s\n", ep.Name, ep.Description)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if !confirmGenerate() {
|
||||
fmt.Println(" Canceled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println(" Generating code...")
|
||||
if err := generate.Generate(ctx, ".", design, provider, apiKey, ""); err != nil {
|
||||
return fmt.Errorf("generate failed: %w", err)
|
||||
}
|
||||
for _, svc := range design.Services {
|
||||
fmt.Printf(" \033[32m✓\033[0m %s/\n", svc.Name)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Set env vars so the agent process can pick them up
|
||||
if provider != "" {
|
||||
os.Setenv("MICRO_AI_PROVIDER", provider)
|
||||
}
|
||||
os.Setenv("MICRO_AI_API_KEY", apiKey)
|
||||
|
||||
// Now run normally — micro run discovers the generated services + agent
|
||||
fmt.Println(" Starting services...")
|
||||
fmt.Println()
|
||||
|
||||
cancel()
|
||||
_ = c.Set("prompt", "")
|
||||
return Run(c)
|
||||
}
|
||||
|
||||
func confirmGenerate() bool {
|
||||
fmt.Print(" Generate? [Y/n] ")
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
if !scanner.Scan() {
|
||||
return false
|
||||
}
|
||||
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
|
||||
return answer == "" || answer == "y" || answer == "yes"
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Package watcher provides file watching for hot reload
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Event represents a file change event
|
||||
type Event struct {
|
||||
Path string
|
||||
Dir string // The service directory that was affected
|
||||
}
|
||||
|
||||
// Watcher watches directories for file changes
|
||||
type Watcher struct {
|
||||
dirs []string
|
||||
events chan Event
|
||||
done chan struct{}
|
||||
interval time.Duration
|
||||
debounce time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
modTimes map[string]time.Time
|
||||
}
|
||||
|
||||
// Option configures the watcher
|
||||
type Option func(*Watcher)
|
||||
|
||||
// WithInterval sets the polling interval
|
||||
func WithInterval(d time.Duration) Option {
|
||||
return func(w *Watcher) {
|
||||
w.interval = d
|
||||
}
|
||||
}
|
||||
|
||||
// WithDebounce sets the debounce duration for rapid changes
|
||||
func WithDebounce(d time.Duration) Option {
|
||||
return func(w *Watcher) {
|
||||
w.debounce = d
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a new file watcher for the given directories
|
||||
func New(dirs []string, opts ...Option) *Watcher {
|
||||
w := &Watcher{
|
||||
dirs: dirs,
|
||||
events: make(chan Event, 100),
|
||||
done: make(chan struct{}),
|
||||
interval: 500 * time.Millisecond,
|
||||
debounce: 300 * time.Millisecond,
|
||||
modTimes: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(w)
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
// Events returns the channel of file change events
|
||||
func (w *Watcher) Events() <-chan Event {
|
||||
return w.events
|
||||
}
|
||||
|
||||
// Start begins watching for file changes
|
||||
func (w *Watcher) Start() {
|
||||
// Initial scan to populate mod times
|
||||
w.scan(false)
|
||||
|
||||
go w.watch()
|
||||
}
|
||||
|
||||
// AddDir adds a new directory to watch
|
||||
func (w *Watcher) AddDir(dir string) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
for _, d := range w.dirs {
|
||||
if d == dir {
|
||||
return
|
||||
}
|
||||
}
|
||||
w.dirs = append(w.dirs, dir)
|
||||
}
|
||||
|
||||
// Dirs returns the currently watched directories
|
||||
func (w *Watcher) Dirs() []string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return append([]string{}, w.dirs...)
|
||||
}
|
||||
|
||||
// Stop stops the watcher
|
||||
func (w *Watcher) Stop() {
|
||||
close(w.done)
|
||||
}
|
||||
|
||||
func (w *Watcher) watch() {
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Track pending events per directory for debouncing
|
||||
pending := make(map[string]time.Time)
|
||||
var pendingMu sync.Mutex
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
changed := w.scan(true)
|
||||
now := time.Now()
|
||||
|
||||
pendingMu.Lock()
|
||||
for _, dir := range changed {
|
||||
pending[dir] = now
|
||||
}
|
||||
|
||||
// Emit events for directories that have been stable
|
||||
for dir, t := range pending {
|
||||
if now.Sub(t) >= w.debounce {
|
||||
select {
|
||||
case w.events <- Event{Dir: dir}:
|
||||
default:
|
||||
// Channel full, skip
|
||||
}
|
||||
delete(pending, dir)
|
||||
}
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) scan(notify bool) []string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
var changed []string
|
||||
changedDirs := make(map[string]bool)
|
||||
|
||||
for _, dir := range w.dirs {
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
_ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip hidden directories and vendor
|
||||
if info.IsDir() {
|
||||
name := info.Name()
|
||||
if strings.HasPrefix(name, ".") || name == "vendor" || name == "node_modules" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only watch .go files
|
||||
if !strings.HasSuffix(path, ".go") {
|
||||
return nil
|
||||
}
|
||||
|
||||
modTime := info.ModTime()
|
||||
if oldTime, exists := w.modTimes[path]; exists {
|
||||
if modTime.After(oldTime) && notify {
|
||||
if !changedDirs[absDir] {
|
||||
changedDirs[absDir] = true
|
||||
changed = append(changed, absDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
w.modTimes[path] = modTime
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
Reference in New Issue
Block a user