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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:40:33 +08:00
commit e071084ebe
982 changed files with 160368 additions and 0 deletions
+225
View File
@@ -0,0 +1,225 @@
# Micro [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
A Go microservices toolkit
## Overview
Micro is a toolkit for Go microservices development. It provides the foundation for building services in the cloud.
The core of Micro is the [Go Micro](https://github.com/micro/go-micro) framework, which developers import and use in their code to
write services. Surrounding this we introduce a number of tools to make it easy to serve and consume services.
## Install the CLI
Install `micro` via `go install`
```
go install go-micro.dev/v5/cmd/micro@v5.16.0
```
> **Note:** Use a specific version instead of `@latest` to avoid module path conflicts. See [releases](https://github.com/micro/go-micro/releases) for the latest version.
Or via install script
```
wget -q https://raw.githubusercontent.com/micro/micro/master/scripts/install.sh -O - | /bin/bash
```
For releases see the [latest](https://go-micro.dev/releases/latest) tag
## Create a service
Create your service (all setup is now automatic!):
```
micro new helloworld
```
This will:
- Create a new service in the `helloworld` directory
- Automatically run `go mod tidy` and `make proto` for you
- Show the updated project tree including generated files
- Warn you if `protoc` is not installed, with install instructions
## Run the service
Run the service
```
micro run
```
List services to see it's running and registered itself
```
micro services
```
## Describe the service
Describe the service to see available endpoints
```
micro describe helloworld
```
Output
```
{
"name": "helloworld",
"version": "latest",
"metadata": null,
"endpoints": [
{
"request": {
"name": "Request",
"type": "Request",
"values": [
{
"name": "name",
"type": "string",
"values": null
}
]
},
"response": {
"name": "Response",
"type": "Response",
"values": [
{
"name": "msg",
"type": "string",
"values": null
}
]
},
"metadata": {},
"name": "Helloworld.Call"
},
{
"request": {
"name": "Context",
"type": "Context",
"values": null
},
"response": {
"name": "Stream",
"type": "Stream",
"values": null
},
"metadata": {
"stream": "true"
},
"name": "Helloworld.Stream"
}
],
"nodes": [
{
"metadata": {
"broker": "http",
"protocol": "mucp",
"registry": "mdns",
"server": "mucp",
"transport": "http"
},
"id": "helloworld-31e55be7-ac83-4810-89c8-a6192fb3ae83",
"address": "127.0.0.1:39963"
}
]
}
```
## Call the service
Call via RPC endpoint
```
micro call helloworld Helloworld.Call '{"name": "Asim"}'
```
## Create a client
Create a client to call the service
```go
package main
import (
"context"
"fmt"
"go-micro.dev/v5"
)
type Request struct {
Name string
}
type Response struct {
Message string
}
func main() {
client := micro.NewService("helloworld").Client()
req := client.NewRequest("helloworld", "Helloworld.Call", &Request{Name: "John"})
var rsp Response
err := client.Call(context.TODO(), req, &rsp)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(rsp.Message)
}
```
## Protobuf
Use protobuf for code generation with [protoc-gen-micro](https://go-micro.dev/tree/master/cmd/protoc-gen-micro)
## Server
The micro server is an api and web dashboard that provide a fixed entrypoint for seeing and querying services.
Run it like so
```
micro server
```
Then browse to [localhost:8080](http://localhost:8080)
### API Endpoints
The API provides a fixed HTTP entrypoint for calling services
```
curl http://localhost:8080/api/helloworld/Helloworld/Call -d '{"name": "John"}'
```
See /api for more details and documentation for each service
### Web Dashboard
The web dashboard provides a modern, secure UI for managing and exploring your Micro services. Major features include:
- **Dynamic Service & Endpoint Forms**: Browse all registered services and endpoints. For each endpoint, a dynamic form is generated for easy testing and exploration.
- **API Documentation**: The `/api` page lists all available services and endpoints, with request/response schemas and a sidebar for quick navigation. A documentation banner explains authentication requirements.
- **JWT Authentication**: All login and token management uses a custom JWT utility. Passwords are securely stored with bcrypt. All `/api/x` endpoints and authenticated pages require an `Authorization: Bearer <token>` header (or `micro_token` cookie as fallback).
- **Token Management**: The `/auth/tokens` page allows you to generate, view (obfuscated), and copy JWT tokens. Tokens are stored and can be revoked. When a user is deleted, all their tokens are revoked immediately.
- **User Management**: The `/auth/users` page allows you to create, list, and delete users. Passwords are never shown or stored in plaintext.
- **Token Revocation**: JWT tokens are stored and checked for revocation on every request. Revoked or deleted tokens are immediately invalidated.
- **Security**: All protected endpoints use consistent authentication logic. Unauthorized or revoked tokens receive a 401 error. All sensitive actions require authentication.
- **Logs & Status**: View service logs and status (PID, uptime, etc) directly from the dashboard.
To get started, run:
```
micro server
```
Then browse to [localhost:8080](http://localhost:8080) and log in with the default admin account (`admin`/`micro`).
> **Note:** See the `/api` page for details on API authentication and how to generate tokens for use with the HTTP API
+467
View File
@@ -0,0 +1,467 @@
// Package agent registers the 'micro agent' CLI commands.
package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"time"
"github.com/urfave/cli/v2"
goagent "go-micro.dev/v6/agent"
"go-micro.dev/v6/cmd"
aiflow "go-micro.dev/v6/flow"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
const firstAgentQuickChecksHelp = `First-agent failure-mode quick checks
Use this when scaffold -> run -> chat -> inspect stalls and you want the
smallest provider-free recovery loop before reading the full docs.
1. Confirm prerequisites before starting the gateway:
micro agent preflight
2. Start the project and keep it running in a separate terminal:
micro run
3. Check the agent is registered and the chat gateway is reachable:
micro agent doctor
4. If chat returns an answer or an error, inspect the latest run state:
micro inspect agent <name>
micro runs <name>
5. If provider chat is not configured yet, prove the no-secret path still works:
micro agent demo
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1
Recovery docs:
https://go-micro.dev/docs/guides/debugging-agents.html
https://go-micro.dev/docs/guides/no-secret-first-agent.html`
const noSecretDemoHelp = `No-secret first-agent demo
Use this when you want the fastest provider-free agent success path before
configuring API keys. It runs the maintained support/first-agent transcript with
the deterministic mock model used by CI:
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
What this proves:
- service tools can be called by an agent
- chat behavior is exercised without contacting a live provider
- run history can be inspected after the prompt
- the debug smoke seeds a stalled-first-agent recovery transcript
After it passes:
- Build your own service-backed agent: https://go-micro.dev/docs/guides/your-first-agent.html
- Diagnose provider-backed chat: https://go-micro.dev/docs/guides/debugging-agents.html
- Walk the full 0→hero lifecycle: https://go-micro.dev/docs/guides/zero-to-hero.html
Use live-provider chat when you are ready for real model behavior:
micro agent preflight # before micro run: prerequisites
micro run
micro chat
micro agent doctor # after micro run: chat/gateway/inspect recovery
micro inspect agent <name>
Debug transcript smoke:
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1`
func init() {
cmd.Register(&cli.Command{
Name: "runs",
Usage: "Show recorded agent runs",
ArgsUsage: "[agent] [run-id]",
Flags: runFlags(),
Action: func(c *cli.Context) error {
name := c.Args().First()
if name == "" {
return fmt.Errorf("usage: micro runs [agent] [run-id]")
}
if runID := c.Args().Get(1); runID != "" {
return printRunHistory(name, runID, c.Bool("json"))
}
return printRunIndex(name, runOptions(c), c.Bool("json"))
},
})
cmd.Register(&cli.Command{
Name: "agent",
Usage: "Manage AI agents (try: micro agent demo)",
Subcommands: []*cli.Command{
{
Name: "demo",
Usage: "Show the no-secret first-agent demo command",
Description: `Print the provider-free first-agent path for new developers:
the deterministic mock-model transcript, when to use it, and where to go next
for live-provider chat and inspect/debugging.`,
Action: func(c *cli.Context) error {
fmt.Fprintln(c.App.Writer, noSecretDemoHelp)
return nil
},
},
{
Name: "quickcheck",
Aliases: []string{"debug"},
Usage: "Print first-agent failure-mode quick checks",
Description: `Print provider-free recovery breadcrumbs for the scaffold -> run ->
chat -> inspect loop, including exact commands for registration, gateway, run
history, and no-secret fallback checks.`,
Action: func(c *cli.Context) error {
fmt.Fprintln(c.App.Writer, firstAgentQuickChecksHelp)
return nil
},
},
{
Name: "preflight",
Usage: "Check local prerequisites before the first provider-backed agent",
Action: func(c *cli.Context) error {
return runAgentPreflight(os.Stdout, defaultPreflightDeps())
},
},
{
Name: "doctor",
Usage: "Diagnose chat, gateway, registration, provider, and inspect recovery after micro run",
Flags: []cli.Flag{
&cli.StringFlag{Name: "gateway", Value: "http://localhost:8080", Usage: "Gateway URL started by micro run"},
},
Action: func(c *cli.Context) error {
return runAgentDoctor(os.Stdout, defaultDoctorDeps(), c.String("gateway"))
},
},
{
Name: "list",
Usage: "List registered agents",
Action: func(c *cli.Context) error {
svcs, err := registry.ListServices()
if err != nil {
return err
}
found := false
for _, svc := range svcs {
records, err := registry.GetService(svc.Name)
if err != nil || len(records) == 0 {
continue
}
meta := records[0].Metadata
if meta == nil || meta["type"] != "agent" {
if len(records[0].Nodes) > 0 {
meta = records[0].Nodes[0].Metadata
}
if meta == nil || meta["type"] != "agent" {
continue
}
}
found = true
services := meta["services"]
if services == "" {
services = "(all)"
}
fmt.Printf(" \033[35m◆\033[0m %-20s manages: %s\n", svc.Name, services)
}
if !found {
fmt.Println(" No agents registered.")
fmt.Println()
fmt.Println(" Start an agent with:")
fmt.Println(" micro run (if agents are part of your project)")
}
return nil
},
},
{
Name: "describe",
Usage: "Describe an agent",
ArgsUsage: "[name]",
Action: func(c *cli.Context) error {
name := c.Args().First()
if name == "" {
return fmt.Errorf("usage: micro agent describe [name]")
}
records, err := registry.GetService(name)
if err != nil {
return err
}
if len(records) == 0 {
return fmt.Errorf("agent %s not found", name)
}
b, _ := json.MarshalIndent(records[0], "", " ")
fmt.Println(string(b))
return nil
},
},
{
Name: "resume-input",
Usage: "Continue an input-required agent run with human input",
ArgsUsage: "[name] [run-id]",
Flags: []cli.Flag{
&cli.StringFlag{Name: "input", Usage: "Human input to provide to the paused run", Required: true},
},
Action: func(c *cli.Context) error {
name := c.Args().First()
runID := c.Args().Get(1)
if name == "" || runID == "" {
return fmt.Errorf("usage: micro agent resume-input [name] [run-id] --input <text>")
}
return resumeInputRun(context.Background(), c.App.Writer, name, runID, c.String("input"))
},
},
{
Name: "history",
Usage: "Show an agent's stored conversation and run history",
ArgsUsage: "[name] [run-id]",
Flags: runFlags(),
Action: func(c *cli.Context) error {
name := c.Args().First()
if name == "" {
return fmt.Errorf("usage: micro agent history [name] [run-id]")
}
if runID := c.Args().Get(1); runID != "" {
return printRunHistory(name, runID, c.Bool("json"))
}
if c.Bool("json") {
return printRunIndex(name, runOptions(c), true)
}
// Read from the agent's scoped state store (database
// "agent", table = name) — available whether or not the
// agent is currently running.
mem := goagent.NewMemory(store.Scope(store.DefaultStore, "agent", name), "history", 1000)
msgs := mem.Messages()
if len(msgs) == 0 {
fmt.Printf(" No history for agent %q.\n", name)
} else {
for _, m := range msgs {
fmt.Printf(" \033[2m%s:\033[0m %v\n", m.Role, m.Content)
}
}
return printRunIndex(name, runOptions(c), c.Bool("json"))
},
},
},
})
}
func runFlags() []cli.Flag {
return []cli.Flag{
&cli.BoolFlag{Name: "json", Usage: "Print run data as JSON for automation"},
&cli.StringFlag{Name: "status", Usage: "Only show runs with this status (running, done, error, refused)"},
&cli.StringFlag{Name: "trace", Usage: "Only show runs whose trace id matches this full id or prefix"},
&cli.IntFlag{Name: "limit", Usage: "Show the most recently updated N runs"},
}
}
func runOptions(c *cli.Context) goagent.RunListOptions {
return goagent.RunListOptions{Status: c.String("status"), TraceID: c.String("trace"), Limit: c.Int("limit")}
}
func printRunIndex(name string, opts goagent.RunListOptions, asJSON bool) error {
runs, err := goagent.ListRunSummariesWithOptions(store.DefaultStore, name, opts)
if err != nil {
return err
}
return writeRunIndex(os.Stdout, name, runs, asJSON)
}
func writeRunIndex(w io.Writer, name string, runs []goagent.RunSummary, asJSON bool) error {
if asJSON {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(runs)
}
if len(runs) == 0 {
fmt.Fprintf(w, " No runs recorded for agent %q.\n", name)
return nil
}
fmt.Fprintln(w, " Runs:")
for _, run := range runs {
line := fmt.Sprintf(" %s status=%s events=%d duration=%s last=%s updated=%s", run.RunID, run.Status, run.Events, formatDurationMS(run.DurationMS), run.LastKind, run.UpdatedAt.Format("2006-01-02 15:04:05"))
if run.ParentID != "" {
line += " parent=" + run.ParentID
}
if run.TraceID != "" {
line += " trace=" + shortTraceID(run.TraceID)
}
if run.Checkpoint != "" {
line += " checkpoint=" + run.Checkpoint
}
if run.Stage != "" {
line += " stage=" + run.Stage
}
if run.LastError != "" {
line += " error=" + run.LastError
}
fmt.Fprintln(w, line)
writeRunIndexBreadcrumbs(w, name, run)
}
return nil
}
func writeRunIndexBreadcrumbs(w io.Writer, name string, run goagent.RunSummary) {
if run.Stage == "input-required" {
fmt.Fprintf(w, " inspect: micro agent history %s %s\n", name, run.RunID)
fmt.Fprintf(w, " input: micro agent resume-input %s %s --input <text>\n", name, run.RunID)
return
}
if !isResumableRunSummary(run) {
return
}
fmt.Fprintf(w, " inspect: micro agent history %s %s\n", name, run.RunID)
fmt.Fprintf(w, " resume: call micro.AgentResume(ctx, agent, %q) after recreating the agent with the same checkpoint store\n", run.RunID)
fmt.Fprintf(w, " stream: call micro.ResumeStreamAsk(ctx, agent, %q) to resume with streaming events\n", run.RunID)
}
func isResumableRunSummary(run goagent.RunSummary) bool {
switch run.Status {
case "running", "error", "failed", "refused":
return run.Checkpoint != "done" || run.Stage != ""
default:
return false
}
}
func printRunHistory(name, runID string, asJSON bool) error {
events, err := goagent.LoadRunEvents(store.DefaultStore, name, runID)
if err != nil {
return err
}
return writeRunHistory(os.Stdout, name, runID, events, asJSON)
}
func writeRunHistory(w io.Writer, name, runID string, events []goagent.RunEvent, asJSON bool) error {
if asJSON {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(events)
}
if len(events) == 0 {
fmt.Fprintf(w, " No run %q for agent %q.\n", runID, name)
return nil
}
for _, e := range events {
line := fmt.Sprintf(" %s %-5s", e.Time.Format("15:04:05.000"), e.Kind)
if e.Name != "" {
line += " " + e.Name
}
if e.Provider != "" || e.Model != "" {
line += fmt.Sprintf(" %s/%s", e.Provider, e.Model)
}
if e.LatencyMS > 0 {
line += fmt.Sprintf(" %dms", e.LatencyMS)
}
if e.Tokens.TotalTokens > 0 {
line += fmt.Sprintf(" tokens=%d", e.Tokens.TotalTokens)
}
if e.ParentID != "" {
line += " parent=" + e.ParentID
}
if e.TraceID != "" {
line += " trace=" + shortTraceID(e.TraceID)
}
if e.Refused != "" {
line += " refused=" + e.Refused
}
if e.Error != "" {
line += " error=" + e.Error
}
fmt.Fprintln(w, line)
}
return nil
}
func formatDurationMS(ms int64) string {
if ms <= 0 {
return "0ms"
}
if ms < 1000 {
return fmt.Sprintf("%dms", ms)
}
return fmt.Sprintf("%.1fs", float64(ms)/1000)
}
func shortTraceID(id string) string {
if len(id) <= 12 {
return id
}
return id[:12]
}
type cliInputPause struct {
OriginalMessage string `json:"original_message"`
Prompt string `json:"prompt"`
}
func resumeInputRun(ctx context.Context, w io.Writer, name, runID, input string) error {
if input == "" {
return fmt.Errorf("input required: pass --input <text>")
}
cp := aiflow.StoreCheckpoint(store.DefaultStore, name)
run, ok, err := cp.Load(ctx, runID)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("agent run %s not found for %q", runID, name)
}
if run.Status != "paused" || run.State.Stage != "input-required" {
return fmt.Errorf("agent run %s is not waiting for human input", runID)
}
var pause cliInputPause
_ = run.State.Scan(&pause)
reply := "Human input recorded; recreate the agent with the same checkpoint store and call micro.AgentResumeInput to continue model execution."
resp := goagent.Response{Reply: reply, Agent: name, RunID: runID, ParentID: run.ParentID}
data, err := json.Marshal(resp)
if err != nil {
return err
}
run.Status = "done"
run.State.Stage = "done"
run.State.Data = data
for i := range run.Steps {
if run.Steps[i].Status == "paused" || run.Steps[i].Name == "ask" {
run.Steps[i].Status = "done"
run.Steps[i].Error = ""
run.Steps[i].Result = "human input: " + input
}
}
if len(run.Steps) == 0 {
run.Steps = []aiflow.StepRecord{{Name: "ask", Status: "done", Result: "human input: " + input}}
}
if err := cp.Save(ctx, run); err != nil {
return err
}
if err := recordCLIResumeEvents(name, runID, run.ParentID); err != nil {
return err
}
if pause.Prompt != "" {
fmt.Fprintf(w, " Prompt: %s\n", pause.Prompt)
}
fmt.Fprintf(w, " Recorded input for agent %q run %s.\n", name, runID)
fmt.Fprintf(w, " Inspect: micro inspect agent %s --limit 1\n", name)
return nil
}
func recordCLIResumeEvents(name, runID, parentID string) error {
now := time.Now()
scoped := store.Scope(store.DefaultStore, "agent", name)
events := []goagent.RunEvent{
{Time: now, RunID: runID, ParentID: parentID, Agent: name, Kind: "checkpoint", Name: "done", Status: "done"},
{Time: now.Add(time.Nanosecond), RunID: runID, ParentID: parentID, Agent: name, Kind: "done"},
}
for _, e := range events {
b, err := json.Marshal(e)
if err != nil {
return err
}
key := fmt.Sprintf("runs/%s/%020d-%s", runID, e.Time.UnixNano(), e.Kind)
if err := scoped.Write(&store.Record{Key: key, Value: b}); err != nil {
return err
}
}
return nil
}
+180
View File
@@ -0,0 +1,180 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"time"
goagent "go-micro.dev/v6/agent"
"go-micro.dev/v6/ai"
aiflow "go-micro.dev/v6/flow"
"go-micro.dev/v6/store"
)
func TestWriteRunIndexJSON(t *testing.T) {
runs := []goagent.RunSummary{{
RunID: "run-1",
Agent: "runner",
StartedAt: time.Unix(0, 1),
UpdatedAt: time.Unix(0, 2),
DurationMS: 1234,
Events: 2,
Status: "done",
LastKind: "tool",
TraceID: "1234567890abcdef",
}}
var out bytes.Buffer
if err := writeRunIndex(&out, "runner", runs, true); err != nil {
t.Fatal(err)
}
var got []goagent.RunSummary
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, out.String())
}
if len(got) != 1 || got[0].RunID != "run-1" || got[0].LastKind != "tool" {
t.Fatalf("decoded summaries = %#v", got)
}
}
func TestWriteRunIndexHumanIncludesStatusAndDuration(t *testing.T) {
runs := []goagent.RunSummary{{
RunID: "run-1",
Agent: "runner",
UpdatedAt: time.Date(2026, 6, 25, 12, 34, 56, 0, time.UTC),
DurationMS: 1234,
Events: 2,
Status: "done",
LastKind: "tool",
ParentID: "parent-run",
}}
var out bytes.Buffer
if err := writeRunIndex(&out, "runner", runs, false); err != nil {
t.Fatal(err)
}
line := out.String()
for _, want := range []string{"run-1", "status=done", "events=2", "duration=1.2s", "last=tool", "parent=parent-run"} {
if !strings.Contains(line, want) {
t.Fatalf("human output %q missing %q", line, want)
}
}
}
func TestWriteRunIndexIncludesResumeBreadcrumbs(t *testing.T) {
runs := []goagent.RunSummary{{
RunID: "run-failed",
Agent: "runner",
UpdatedAt: time.Date(2026, 6, 25, 12, 34, 56, 0, time.UTC),
Events: 3,
Status: "error",
LastKind: "tool",
Checkpoint: "failed",
Stage: "ask",
}}
var out bytes.Buffer
if err := writeRunIndex(&out, "runner", runs, false); err != nil {
t.Fatal(err)
}
got := out.String()
for _, want := range []string{"checkpoint=failed", "stage=ask", `micro agent history runner run-failed`, `micro.AgentResume(ctx, agent, "run-failed")`, `micro.ResumeStreamAsk(ctx, agent, "run-failed")`} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestWriteRunIndexInputRequiredUsesResumeInput(t *testing.T) {
runs := []goagent.RunSummary{{RunID: "run-input", Agent: "runner", Status: "running", LastKind: "checkpoint", Checkpoint: "paused", Stage: "input-required"}}
var out bytes.Buffer
if err := writeRunIndex(&out, "runner", runs, false); err != nil {
t.Fatal(err)
}
got := out.String()
for _, want := range []string{`micro agent history runner run-input`, `micro agent resume-input runner run-input --input <text>`} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
if strings.Contains(got, `micro.AgentResume(ctx, agent, "run-input")`) || strings.Contains(got, "ResumeStreamAsk") {
t.Fatalf("input-required run should point at ResumeInput only, got:\n%s", got)
}
}
func TestWriteRunHistoryHumanAndJSON(t *testing.T) {
events := []goagent.RunEvent{{
Time: time.Date(2026, 6, 25, 12, 34, 56, 7_000_000, time.UTC),
RunID: "run-1",
Agent: "runner",
Kind: "tool",
Name: "probe",
Provider: "oteltest",
Model: "unit-model",
LatencyMS: 42,
Tokens: ai.Usage{TotalTokens: 5},
TraceID: "1234567890abcdef",
ParentID: "parent-run",
}}
var human bytes.Buffer
if err := writeRunHistory(&human, "runner", "run-1", events, false); err != nil {
t.Fatal(err)
}
line := human.String()
for _, want := range []string{"12:34:56.007 tool", "probe", "oteltest/unit-model", "42ms", "tokens=5", "parent=parent-run", "trace=1234567890ab"} {
if !strings.Contains(line, want) {
t.Fatalf("human output %q missing %q", line, want)
}
}
var js bytes.Buffer
if err := writeRunHistory(&js, "runner", "run-1", events, true); err != nil {
t.Fatal(err)
}
var got []goagent.RunEvent
if err := json.Unmarshal(js.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, js.String())
}
if len(got) != 1 || got[0].Name != "probe" || got[0].Tokens.TotalTokens != 5 {
t.Fatalf("decoded events = %#v", got)
}
}
func TestResumeInputRunCompletesCheckpointAndInspectSummary(t *testing.T) {
oldStore := store.DefaultStore
store.DefaultStore = store.NewMemoryStore()
t.Cleanup(func() { store.DefaultStore = oldStore })
ctx := context.Background()
cp := aiflow.StoreCheckpoint(store.DefaultStore, "runner")
run := aiflow.Run{ID: "run-input", Flow: "runner", Status: "paused", State: aiflow.State{Stage: "input-required"}, Steps: []aiflow.StepRecord{{Name: "ask", Status: "paused", Error: "Which region?"}}}
if err := run.State.Set(cliInputPause{OriginalMessage: "deploy", Prompt: "Which region?"}); err != nil {
t.Fatalf("set pause: %v", err)
}
if err := cp.Save(ctx, run); err != nil {
t.Fatalf("save checkpoint: %v", err)
}
var out bytes.Buffer
if err := resumeInputRun(ctx, &out, "runner", "run-input", "us-east-1"); err != nil {
t.Fatalf("resumeInputRun: %v", err)
}
if got := out.String(); !strings.Contains(got, "Recorded input") || !strings.Contains(got, "micro inspect agent runner --limit 1") {
t.Fatalf("output missing continuation hints:\n%s", got)
}
loaded, ok, err := cp.Load(ctx, "run-input")
if err != nil || !ok {
t.Fatalf("load checkpoint ok=%v err=%v", ok, err)
}
if loaded.Status != "done" || loaded.State.Stage != "done" {
t.Fatalf("loaded run status/stage = %s/%s, want done/done", loaded.Status, loaded.State.Stage)
}
summaries, err := goagent.ListRunSummariesWithOptions(store.DefaultStore, "runner", goagent.RunListOptions{Status: "done"})
if err != nil {
t.Fatalf("summaries: %v", err)
}
if len(summaries) != 1 || summaries[0].RunID != "run-input" || summaries[0].Status != "done" {
t.Fatalf("summaries = %#v, want completed run-input", summaries)
}
}
+179
View File
@@ -0,0 +1,179 @@
package agent
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
goagent "go-micro.dev/v6/agent"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
type doctorDeps struct {
getenv func(string) string
httpGet func(string) (*http.Response, error)
listServices func() ([]*registry.Service, error)
getService func(string) ([]*registry.Service, error)
listRuns func(string) ([]goagent.RunSummary, error)
}
func defaultDoctorDeps() doctorDeps {
client := &http.Client{Timeout: 2 * time.Second}
return doctorDeps{
getenv: defaultPreflightDeps().getenv,
httpGet: client.Get,
listServices: registry.ListServices,
getService: registry.GetService,
listRuns: func(name string) ([]goagent.RunSummary, error) {
return goagent.ListRunSummariesWithOptions(store.DefaultStore, name, goagent.RunListOptions{Limit: 1})
},
}
}
func runAgentDoctor(w io.Writer, deps doctorDeps, gateway string) error {
if gateway == "" {
gateway = "http://localhost:8080"
}
gateway = strings.TrimRight(gateway, "/")
checks := agentDoctorChecks(deps, gateway)
failures := 0
fmt.Fprintln(w, "First-agent recovery doctor")
for _, check := range checks {
mark := "✓"
if !check.OK {
mark = "✗"
failures++
}
fmt.Fprintf(w, " %s %s — %s\n", mark, check.Name, check.Detail)
if !check.OK && check.Fix != "" {
fmt.Fprintf(w, " Fix: %s\n", check.Fix)
}
if !check.OK && check.Next != "" {
fmt.Fprintf(w, " Next: %s\n", check.Next)
}
}
if failures > 0 {
return fmt.Errorf("first-agent doctor found %d recovery boundary issue(s)", failures)
}
fmt.Fprintln(w, "\nReady: gateway, agent registration, chat settings, and inspect history are reachable.")
return nil
}
func agentDoctorChecks(deps doctorDeps, gateway string) []preflightCheck {
if deps.getenv == nil {
deps.getenv = defaultPreflightDeps().getenv
}
if deps.httpGet == nil {
deps.httpGet = http.Get
}
if deps.listServices == nil {
deps.listServices = registry.ListServices
}
if deps.getService == nil {
deps.getService = registry.GetService
}
if deps.listRuns == nil {
deps.listRuns = func(name string) ([]goagent.RunSummary, error) {
return goagent.ListRunSummariesWithOptions(store.DefaultStore, name, goagent.RunListOptions{Limit: 1})
}
}
checks := []preflightCheck{checkGateway(deps, gateway), checkChatSettings(deps, gateway)}
agents, regCheck := checkAgentRegistration(deps)
checks = append(checks, regCheck)
checks = append(checks, checkRunHistory(deps, agents))
checks = append(checks, checkProviderConfig(deps))
return checks
}
func checkGateway(deps doctorDeps, gateway string) preflightCheck {
resp, err := deps.httpGet(gateway + "/agent")
if err != nil {
return preflightCheck{Name: "gateway /agent", Detail: err.Error(), Fix: "Start the local gateway with `micro run`, or pass the matching URL with `micro agent doctor --gateway http://localhost:<port>`.", Next: "Then open " + gateway + "/agent or retry `micro chat`."}
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return preflightCheck{Name: "gateway /agent", Detail: fmt.Sprintf("%s returned %s", gateway+"/agent", resp.Status), Fix: "Confirm `micro run` is serving the web gateway and that auth/proxy settings are not blocking /agent.", Next: "See docs/guides/debugging-agents.html#chat-and-gateway-failures."}
}
return preflightCheck{Name: "gateway /agent", OK: true, Detail: gateway + "/agent is reachable"}
}
func checkChatSettings(deps doctorDeps, gateway string) preflightCheck {
resp, err := deps.httpGet(gateway + "/api/agent/settings")
if err != nil {
return preflightCheck{Name: "chat settings endpoint", Detail: err.Error(), Fix: "Keep `micro run` running and retry; the playground uses /api/agent/settings before chat prompts.", Next: "See docs/guides/debugging-agents.html#chat-and-gateway-failures."}
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return preflightCheck{Name: "chat settings endpoint", Detail: fmt.Sprintf("returned %s", resp.Status), Fix: "Check gateway auth/proxy configuration or use the Agent settings page to confirm chat settings load.", Next: "See docs/guides/debugging-agents.html#provider-failures."}
}
var settings map[string]string
_ = json.NewDecoder(resp.Body).Decode(&settings)
if settings["provider"] != "" || settings["model"] != "" || settings["api_key"] != "" {
return preflightCheck{Name: "chat settings endpoint", OK: true, Detail: "reachable with saved provider settings"}
}
return preflightCheck{Name: "chat settings endpoint", OK: true, Detail: "reachable; no saved provider settings"}
}
func checkAgentRegistration(deps doctorDeps) ([]string, preflightCheck) {
services, err := deps.listServices()
if err != nil {
return nil, preflightCheck{Name: "agent registration", Detail: err.Error(), Fix: "Keep the scaffolded agent process running under `micro run` and retry `micro agent list`.", Next: "See docs/guides/your-first-agent.html#run-your-agent."}
}
var agents []string
for _, svc := range services {
records, err := deps.getService(svc.Name)
if err != nil || len(records) == 0 {
continue
}
if serviceIsAgent(records[0]) {
agents = append(agents, svc.Name)
}
}
if len(agents) == 0 {
return nil, preflightCheck{Name: "agent registration", Detail: "no registered agent services found", Fix: "Start an agent project with `micro run` and confirm `micro agent list` shows it.", Next: "Use docs/guides/no-secret-first-agent.html for a deterministic no-provider agent."}
}
return agents, preflightCheck{Name: "agent registration", OK: true, Detail: "found " + strings.Join(agents, ", ")}
}
func serviceIsAgent(svc *registry.Service) bool {
if svc.Metadata != nil && svc.Metadata["type"] == "agent" {
return true
}
for _, node := range svc.Nodes {
if node.Metadata != nil && node.Metadata["type"] == "agent" {
return true
}
}
return false
}
func checkRunHistory(deps doctorDeps, agents []string) preflightCheck {
if len(agents) == 0 {
return preflightCheck{Name: "inspect run history", Detail: "skipped because no agent is registered", Fix: "Fix agent registration first, then chat once and run `micro inspect agent <name>`.", Next: "See docs/guides/debugging-agents.html#inspect-run-history."}
}
for _, name := range agents {
runs, err := deps.listRuns(name)
if err != nil {
return preflightCheck{Name: "inspect run history", Detail: err.Error(), Fix: "Ensure the local store is writable and retry `micro inspect agent " + name + "`.", Next: "See docs/guides/debugging-agents.html#inspect-run-history."}
}
if len(runs) > 0 {
return preflightCheck{Name: "inspect run history", OK: true, Detail: "recent runs available for " + name}
}
}
return preflightCheck{Name: "inspect run history", Detail: "no recorded agent runs yet", Fix: "Send one prompt with `micro chat` or the /agent playground, then run `micro inspect agent " + agents[0] + "`.", Next: "See docs/guides/your-first-agent.html#inspect-what-happened."}
}
func checkProviderConfig(deps doctorDeps) preflightCheck {
check := checkProviderKey(preflightDeps{getenv: deps.getenv})
check.Name = "provider configuration"
if !check.OK {
check.Detail = "no provider key found for live LLM chat"
check.Fix = "For provider-backed chat, export MICRO_AI_API_KEY or a provider-specific key; for no-secret recovery, use the mock-model walkthrough."
}
return check
}
+97
View File
@@ -0,0 +1,97 @@
package agent
import (
"bytes"
"errors"
"io"
"net/http"
"strings"
"testing"
goagent "go-micro.dev/v6/agent"
"go-micro.dev/v6/registry"
)
func doctorHTTP(status int, body string) func(string) (*http.Response, error) {
return func(string) (*http.Response, error) {
return &http.Response{StatusCode: status, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil
}
}
func TestRunAgentDoctorPassesWhenRecoveryBoundariesReachable(t *testing.T) {
deps := doctorDeps{
getenv: func(key string) string {
if key == "MICRO_AI_API_KEY" {
return "set"
}
return ""
},
httpGet: doctorHTTP(200, `{"provider":"anthropic","model":"claude"}`),
listServices: func() ([]*registry.Service, error) {
return []*registry.Service{{Name: "assistant"}}, nil
},
getService: func(name string) ([]*registry.Service, error) {
return []*registry.Service{{Name: name, Metadata: map[string]string{"type": "agent"}}}, nil
},
listRuns: func(name string) ([]goagent.RunSummary, error) {
return []goagent.RunSummary{{RunID: "run-1", Status: "done"}}, nil
},
}
var out bytes.Buffer
if err := runAgentDoctor(&out, deps, "http://example.test"); err != nil {
t.Fatalf("runAgentDoctor() error = %v\n%s", err, out.String())
}
got := out.String()
for _, want := range []string{"First-agent recovery doctor", "✓ gateway /agent", "✓ chat settings endpoint", "✓ agent registration", "✓ inspect run history", "✓ provider configuration", "Ready:"} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRunAgentDoctorReportsActionableRecoveryFailures(t *testing.T) {
deps := doctorDeps{
getenv: func(string) string { return "" },
httpGet: func(string) (*http.Response, error) { return nil, errors.New("connection refused") },
listServices: func() ([]*registry.Service, error) {
return []*registry.Service{{Name: "greeter"}}, nil
},
getService: func(name string) ([]*registry.Service, error) {
return []*registry.Service{{Name: name}}, nil
},
listRuns: func(name string) ([]goagent.RunSummary, error) { return nil, nil },
}
var out bytes.Buffer
err := runAgentDoctor(&out, deps, "http://localhost:8080")
if err == nil {
t.Fatal("runAgentDoctor() error = nil")
}
got := out.String()
for _, want := range []string{"✗ gateway /agent", "micro run", "✗ chat settings endpoint", "✗ agent registration", "micro agent list", "✗ inspect run history", "micro inspect agent <name>", "✗ provider configuration", "docs/guides/no-secret-first-agent.html"} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestAgentQuickcheckPrintsProviderFreeFailureModeBreadcrumbs(t *testing.T) {
got := firstAgentQuickChecksHelp
for _, want := range []string{
"First-agent failure-mode quick checks",
"scaffold -> run -> chat -> inspect",
"micro agent preflight",
"micro run",
"micro agent doctor",
"micro inspect agent <name>",
"micro runs <name>",
"micro agent demo",
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1",
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1",
"debugging-agents.html",
"no-secret-first-agent.html",
} {
if !strings.Contains(got, want) {
t.Fatalf("quickcheck output missing %q:\n%s", want, got)
}
}
}
+163
View File
@@ -0,0 +1,163 @@
package agent
import (
"fmt"
"io"
"net"
"os"
"os/exec"
"strings"
"go-micro.dev/v6/cmd"
)
type preflightCheck struct {
Name string
OK bool
Detail string
Fix string
Next string
}
type preflightDeps struct {
lookPath func(string) (string, error)
commandOutput func(string, ...string) ([]byte, error)
executable func() (string, error)
version func() string
getenv func(string) string
listen func(string, string) (net.Listener, error)
}
func defaultPreflightDeps() preflightDeps {
return preflightDeps{
lookPath: exec.LookPath,
commandOutput: func(name string, args ...string) ([]byte, error) { return exec.Command(name, args...).CombinedOutput() },
executable: os.Executable,
version: func() string { return cmd.App().Version },
getenv: os.Getenv,
listen: net.Listen,
}
}
func runAgentPreflight(w io.Writer, deps preflightDeps) error {
checks := agentPreflightChecks(deps)
failures := 0
fmt.Fprintln(w, "First-agent preflight")
for _, check := range checks {
mark := "✓"
if !check.OK {
mark = "✗"
failures++
}
fmt.Fprintf(w, " %s %s — %s\n", mark, check.Name, check.Detail)
if !check.OK && check.Fix != "" {
fmt.Fprintf(w, " Fix: %s\n", check.Fix)
}
if !check.OK && check.Next != "" {
fmt.Fprintf(w, " Next: %s\n", check.Next)
}
}
if failures > 0 {
return fmt.Errorf("first-agent preflight failed: %d check(s) need attention", failures)
}
fmt.Fprintln(w, "\nReady for the first-agent walkthrough: micro run, then open http://localhost:8080/agent or use micro chat.")
return nil
}
func agentPreflightChecks(deps preflightDeps) []preflightCheck {
if deps.lookPath == nil {
deps.lookPath = exec.LookPath
}
if deps.commandOutput == nil {
deps.commandOutput = func(name string, args ...string) ([]byte, error) { return exec.Command(name, args...).CombinedOutput() }
}
if deps.executable == nil {
deps.executable = os.Executable
}
if deps.version == nil {
deps.version = func() string { return cmd.App().Version }
}
if deps.getenv == nil {
deps.getenv = os.Getenv
}
if deps.listen == nil {
deps.listen = net.Listen
}
checks := []preflightCheck{checkGoToolchain(deps), checkMicroBinary(deps), checkProviderKey(deps), checkPortAvailable(deps, ":8080", "micro run gateway and /agent playground")}
return checks
}
func checkGoToolchain(deps preflightDeps) preflightCheck {
path, err := deps.lookPath("go")
if err != nil {
return preflightCheck{Name: "Go toolchain", Detail: "go was not found on PATH", Fix: "Install Go 1.24 or newer from https://go.dev/doc/install and ensure go is on PATH.", Next: "After installing Go, rerun micro agent preflight, then continue with docs/guides/your-first-agent.html."}
}
out, err := deps.commandOutput("go", "version")
if err != nil {
return preflightCheck{Name: "Go toolchain", Detail: strings.TrimSpace(string(out)), Fix: "Ensure the go command runs successfully (try `go version`) before starting the agent walkthrough.", Next: "Use docs/guides/debugging-agents.html after the toolchain check passes if an agent run still fails."}
}
version := firstLine(out)
if !goVersionAtLeast(version, 1, 24) {
return preflightCheck{Name: "Go toolchain", Detail: fmt.Sprintf("%s (%s)", version, path), Fix: "Upgrade to Go 1.24 or newer before running generated services.", Next: "Rerun micro agent preflight, then continue with docs/guides/your-first-agent.html."}
}
return preflightCheck{Name: "Go toolchain", OK: true, Detail: fmt.Sprintf("%s (%s)", version, path)}
}
func checkMicroBinary(deps preflightDeps) preflightCheck {
exe, err := deps.executable()
if err != nil || exe == "" {
return preflightCheck{Name: "micro binary", Detail: "micro executable path is unavailable", Fix: "Install the micro CLI or run this check through `go run ./cmd/micro agent preflight` from the repository.", Next: "Then follow docs/getting-started.html for the scaffold -> run path."}
}
version := deps.version()
if version == "" {
version = "version unavailable"
}
return preflightCheck{Name: "micro binary", OK: true, Detail: fmt.Sprintf("%s (%s)", version, exe)}
}
func checkProviderKey(deps preflightDeps) preflightCheck {
keys := []string{"MICRO_AI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY", "TOGETHER_API_KEY", "ATLASCLOUD_API_KEY"}
var found []string
for _, k := range keys {
if deps.getenv(k) != "" {
found = append(found, k)
}
}
if len(found) == 0 {
return preflightCheck{Name: "provider API key", Detail: "no supported provider key found", Fix: "Export MICRO_AI_API_KEY or a provider key such as ANTHROPIC_API_KEY before running provider-backed agents.", Next: "For a no-secret path, run the mock-model walkthrough in docs/guides/no-secret-first-agent.html; for real providers, see docs/guides/debugging-agents.html#provider-failures."}
}
return preflightCheck{Name: "provider API key", OK: true, Detail: "found " + strings.Join(found, ", ")}
}
func checkPortAvailable(deps preflightDeps, addr, use string) preflightCheck {
ln, err := deps.listen("tcp", addr)
if err != nil {
return preflightCheck{Name: "local port " + addr, Detail: "busy or unavailable for " + use, Fix: "Stop the process using " + addr + " (for example, `lsof -i :8080`) or run `micro run --address` with a free port.", Next: "Once the gateway starts, open http://localhost:8080/agent or continue with docs/guides/your-first-agent.html#chat-with-your-agent."}
}
_ = ln.Close()
return preflightCheck{Name: "local port " + addr, OK: true, Detail: "available for " + use}
}
func firstLine(b []byte) string {
s := strings.TrimSpace(string(b))
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func goVersionAtLeast(line string, wantMajor, wantMinor int) bool {
idx := strings.Index(line, "go1.")
if idx < 0 {
return false
}
var major, minor int
if _, err := fmt.Sscanf(line[idx:], "go%d.%d", &major, &minor); err != nil {
return false
}
if major != wantMajor {
return major > wantMajor
}
return minor >= wantMinor
}
+124
View File
@@ -0,0 +1,124 @@
package agent
import (
"bytes"
"errors"
"net"
"strings"
"testing"
)
type stubListener struct{}
func (stubListener) Accept() (net.Conn, error) { return nil, errors.New("closed") }
func (stubListener) Close() error { return nil }
func (stubListener) Addr() net.Addr { return stubAddr(":8080") }
type stubAddr string
func (a stubAddr) Network() string { return "tcp" }
func (a stubAddr) String() string { return string(a) }
func TestRunAgentPreflightPassesWithKeyAndFreePort(t *testing.T) {
deps := preflightDeps{
lookPath: func(name string) (string, error) { return "/usr/bin/" + name, nil },
commandOutput: func(name string, args ...string) ([]byte, error) {
return []byte("go version go1.24.0 linux/amd64\n"), nil
},
executable: func() (string, error) { return "/usr/local/bin/micro", nil },
getenv: func(key string) string {
if key == "ANTHROPIC_API_KEY" {
return "set"
}
return ""
},
listen: func(network, address string) (net.Listener, error) { return stubListener{}, nil },
}
var out bytes.Buffer
if err := runAgentPreflight(&out, deps); err != nil {
t.Fatalf("runAgentPreflight() error = %v", err)
}
got := out.String()
for _, want := range []string{"First-agent preflight", "✓ Go toolchain", "✓ micro binary", "✓ provider API key", "✓ local port :8080", "Ready for the first-agent walkthrough"} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRunAgentPreflightReportsActionableFailures(t *testing.T) {
deps := preflightDeps{
lookPath: func(name string) (string, error) { return "", errors.New("not found") },
executable: func() (string, error) { return "", errors.New("unknown") },
getenv: func(key string) string { return "" },
listen: func(network, address string) (net.Listener, error) { return nil, errors.New("in use") },
}
var out bytes.Buffer
err := runAgentPreflight(&out, deps)
if err == nil {
t.Fatal("runAgentPreflight() error = nil")
}
got := out.String()
for _, want := range []string{"✗ Go toolchain", "go was not found on PATH", "https://go.dev/doc/install", "docs/guides/your-first-agent.html", "✗ micro binary", "go run ./cmd/micro agent preflight", "✗ provider API key", "docs/guides/no-secret-first-agent.html", "docs/guides/debugging-agents.html#provider-failures", "✗ local port :8080", "lsof -i :8080", "micro run --address"} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRunAgentPreflightReportsOldGoVersion(t *testing.T) {
deps := preflightDeps{
lookPath: func(name string) (string, error) { return "/usr/bin/" + name, nil },
commandOutput: func(name string, args ...string) ([]byte, error) {
return []byte("go version go1.23.9 linux/amd64\n"), nil
},
executable: func() (string, error) { return "/usr/local/bin/micro", nil },
getenv: func(key string) string {
if key == "ANTHROPIC_API_KEY" {
return "set"
}
return ""
},
listen: func(network, address string) (net.Listener, error) { return stubListener{}, nil },
}
var out bytes.Buffer
err := runAgentPreflight(&out, deps)
if err == nil {
t.Fatal("runAgentPreflight() error = nil")
}
got := out.String()
for _, want := range []string{"✗ Go toolchain", "go1.23.9", "Upgrade to Go 1.24 or newer", "Rerun micro agent preflight"} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestGoVersionAtLeast(t *testing.T) {
tests := []struct {
line string
want bool
}{
{line: "go version go1.24.0 linux/amd64", want: true},
{line: "go version go1.25.1 linux/amd64", want: true},
{line: "go version go1.23.9 linux/amd64", want: false},
{line: "unexpected", want: false},
}
for _, tt := range tests {
if got := goVersionAtLeast(tt.line, 1, 24); got != tt.want {
t.Fatalf("goVersionAtLeast(%q) = %v, want %v", tt.line, got, tt.want)
}
}
}
func TestFirstLine(t *testing.T) {
if got := firstLine([]byte("one\ntwo")); got != "one" {
t.Fatalf("firstLine() = %q", got)
}
if got := firstLine([]byte(" single ")); got != "single" {
t.Fatalf("firstLine() = %q", got)
}
}
+343
View File
@@ -0,0 +1,343 @@
// Package build provides the micro build command for building service binaries
package build
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/urfave/cli/v2"
"go-micro.dev/v6/cmd"
"go-micro.dev/v6/cmd/micro/run/config"
)
// Build builds Go binaries for services
func Build(c *cli.Context) error {
dir := c.Args().Get(0)
if dir == "" {
dir = "."
}
absDir, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
// Load config
cfg, err := config.Load(absDir)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Output directory
outDir := c.String("output")
if outDir == "" {
outDir = filepath.Join(absDir, "bin")
}
if err := os.MkdirAll(outDir, 0755); err != nil {
return fmt.Errorf("failed to create output dir: %w", err)
}
// Target OS/ARCH
targetOS := c.String("os")
targetArch := c.String("arch")
if targetOS == "" {
targetOS = runtime.GOOS
}
if targetArch == "" {
targetArch = runtime.GOARCH
}
if cfg != nil && len(cfg.Services) > 0 {
// Build each service from config
sorted, err := cfg.TopologicalSort()
if err != nil {
return err
}
for _, svc := range sorted {
svcDir := filepath.Join(absDir, svc.Path)
if err := buildService(svc.Name, svcDir, outDir, targetOS, targetArch); err != nil {
return fmt.Errorf("failed to build %s: %w", svc.Name, err)
}
}
} else {
// Build single service from current directory
name := filepath.Base(absDir)
if err := buildService(name, absDir, outDir, targetOS, targetArch); err != nil {
return err
}
}
fmt.Printf("\n \033[32m✓\033[0m Built to \033[36m%s\033[0m\n", outDir)
return nil
}
func buildService(name, dir, outDir, targetOS, targetArch string) error {
binName := name
if targetOS == "windows" {
binName += ".exe"
}
outPath := filepath.Join(outDir, binName)
fmt.Printf(" Building \033[36m%s (%s/%s)...\n", name, targetOS, targetArch)
// Build command
buildCmd := exec.Command("go", "build", "-o", outPath, ".")
buildCmd.Dir = dir
buildCmd.Env = append(os.Environ(),
"GOOS="+targetOS,
"GOARCH="+targetArch,
"CGO_ENABLED=0",
)
buildCmd.Stdout = os.Stdout
buildCmd.Stderr = os.Stderr
if err := buildCmd.Run(); err != nil {
return fmt.Errorf("go build failed: %w", err)
}
fmt.Printf(" \033[32m✓\033[0m %s\n", outPath)
return nil
}
// Docker builds container images (optional)
func Docker(c *cli.Context) error {
dir := c.Args().Get(0)
if dir == "" {
dir = "."
}
absDir, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
cfg, err := config.Load(absDir)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
tag := c.String("tag")
if tag == "" {
tag = "latest"
}
registry := c.String("registry")
push := c.Bool("push")
if cfg != nil && len(cfg.Services) > 0 {
for name, svc := range cfg.Services {
svcDir := filepath.Join(absDir, svc.Path)
if err := buildDockerImage(name, svcDir, svc.Port, tag, registry, push); err != nil {
return fmt.Errorf("failed to build %s: %w", name, err)
}
}
} else {
name := filepath.Base(absDir)
if err := buildDockerImage(name, absDir, 8080, tag, registry, push); err != nil {
return err
}
}
return nil
}
const dockerfileTemplate = `FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /service .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY --from=builder /service /service
EXPOSE %d
CMD ["/service"]
`
func buildDockerImage(name, dir string, port int, tag, registry string, push bool) error {
if port == 0 {
port = 8080
}
// Generate Dockerfile if not exists
dockerfilePath := filepath.Join(dir, "Dockerfile")
if _, err := os.Stat(dockerfilePath); os.IsNotExist(err) {
fmt.Printf("Generating Dockerfile for %s...\n", name)
dockerfile := fmt.Sprintf(dockerfileTemplate, port)
if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0644); err != nil {
return fmt.Errorf("failed to write Dockerfile: %w", err)
}
}
imageName := name + ":" + tag
if registry != "" {
imageName = registry + "/" + imageName
}
fmt.Printf(" Building \033[36m%s...\n", imageName)
buildCmd := exec.Command("docker", "build", "-t", imageName, dir)
buildCmd.Stdout = os.Stdout
buildCmd.Stderr = os.Stderr
if err := buildCmd.Run(); err != nil {
return fmt.Errorf("docker build failed: %w", err)
}
fmt.Printf(" \033[32m✓\033[0m Built %s\n", imageName)
if push {
fmt.Printf("Pushing %s...\n", imageName)
pushCmd := exec.Command("docker", "push", imageName)
pushCmd.Stdout = os.Stdout
pushCmd.Stderr = os.Stderr
if err := pushCmd.Run(); err != nil {
return fmt.Errorf("docker push failed: %w", err)
}
fmt.Printf(" \033[32m✓\033[0m Pushed %s\n", imageName)
}
return nil
}
// Compose generates docker-compose.yml (optional)
func Compose(c *cli.Context) error {
dir := c.Args().Get(0)
if dir == "" {
dir = "."
}
absDir, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
cfg, err := config.Load(absDir)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if cfg == nil || len(cfg.Services) == 0 {
return fmt.Errorf("no services found in micro.mu or micro.json")
}
registry := c.String("registry")
tag := c.String("tag")
if tag == "" {
tag = "latest"
}
var sb strings.Builder
sb.WriteString("# Generated by micro build --compose\n")
sb.WriteString("version: '3.8'\n\nservices:\n")
sorted, err := cfg.TopologicalSort()
if err != nil {
return err
}
for _, svc := range sorted {
imageName := svc.Name + ":" + tag
if registry != "" {
imageName = registry + "/" + imageName
}
fmt.Fprintf(&sb, " %s:\n", svc.Name)
fmt.Fprintf(&sb, " image: %s\n", imageName)
if svc.Port > 0 {
fmt.Fprintf(&sb, " ports:\n - \"%d:%d\"\n", svc.Port, svc.Port)
}
if len(svc.Depends) > 0 {
sb.WriteString(" depends_on:\n")
for _, dep := range svc.Depends {
fmt.Fprintf(&sb, " - %s\n", dep)
}
}
sb.WriteString(" environment:\n - MICRO_REGISTRY=mdns\n\n")
}
output := filepath.Join(absDir, "docker-compose.yml")
if err := os.WriteFile(output, []byte(sb.String()), 0644); err != nil {
return fmt.Errorf("failed to write docker-compose.yml: %w", err)
}
fmt.Printf(" \033[32m✓\033[0m Generated %s\n", output)
return nil
}
func init() {
cmd.Register(&cli.Command{
Name: "build",
Usage: "Build Go binaries for services",
Description: `Build compiles Go binaries for your services.
With a micro.mu config, builds all services. Without, builds the current directory.
Output goes to ./bin/ by default.
Examples:
micro build # Build for current OS/arch
micro build --os linux # Cross-compile for Linux
micro build --os linux --arch arm64 # For ARM64
micro build --output ./dist # Custom output directory
Docker (optional):
micro build --docker # Build container images
micro build --docker --push # Build and push
micro build --compose # Generate docker-compose.yml`,
Action: func(c *cli.Context) error {
if c.Bool("docker") {
return Docker(c)
}
if c.Bool("compose") {
return Compose(c)
}
return Build(c)
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Usage: "Output directory (default: ./bin)",
},
&cli.StringFlag{
Name: "os",
Usage: "Target OS (linux, darwin, windows)",
},
&cli.StringFlag{
Name: "arch",
Usage: "Target architecture (amd64, arm64)",
},
// Docker options (optional)
&cli.BoolFlag{
Name: "docker",
Usage: "Build Docker container images instead",
},
&cli.StringFlag{
Name: "tag",
Aliases: []string{"t"},
Usage: "Docker image tag (default: latest)",
Value: "latest",
},
&cli.StringFlag{
Name: "registry",
Aliases: []string{"r"},
Usage: "Docker registry (e.g., docker.io/myuser)",
},
&cli.BoolFlag{
Name: "push",
Usage: "Push Docker images after building",
},
&cli.BoolFlag{
Name: "compose",
Usage: "Generate docker-compose.yml",
},
},
})
}
+322
View File
@@ -0,0 +1,322 @@
package microcli
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"github.com/urfave/cli/v2"
"go-micro.dev/v6/client"
"go-micro.dev/v6/cmd"
"go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/cmd/micro/cli/new"
"go-micro.dev/v6/cmd/micro/cli/util"
// Import packages that register commands via init()
_ "go-micro.dev/v6/cmd/micro/cli/agent"
_ "go-micro.dev/v6/cmd/micro/cli/build"
_ "go-micro.dev/v6/cmd/micro/cli/deploy"
_ "go-micro.dev/v6/cmd/micro/cli/init"
_ "go-micro.dev/v6/cmd/micro/cli/remote"
)
const zeroToHeroHelp = `0→hero no-secret lifecycle demo
Run this from a go-micro repository checkout when you want one command that
proves the maintained services → agents → workflows path without provider keys:
./internal/harness/zero-to-hero-ci/run.sh
That script runs the same deterministic path CI uses:
- CLI discovery for scaffold, run, chat, inspect, flow runs, and deploy dry-run
- the smallest first-agent example
- the support-desk reference app with services, an agent, a flow, and an approval gate
- plan/delegate and universe harnesses with only the model mocked
If you only want the runnable examples first:
go run ./examples/first-agent
go run ./examples/support
Full local contract:
make harness
Guide: https://go-micro.dev/docs/guides/zero-to-hero.html`
const examplesWayfinding = `First-agent examples (no provider key required)
Run these from a go-micro repository checkout in this order. For the complete
examples map, open examples/INDEX.md:
1. Smallest service-backed agent
go run ./examples/first-agent
Proves an agent can call a service tool with the deterministic mock model.
2. No-secret support-agent transcript
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
Exercises service tools, mock-model chat, and inspectable run history.
3. Full services → agents → workflows reference app
go run ./examples/support
Shows the support desk service, agent, workflow, and approval gate together.
Then continue the same path with the installed CLI:
micro agent demo
micro docs
micro zero-to-hero
Guides:
https://go-micro.dev/docs/guides/no-secret-first-agent.html
https://go-micro.dev/docs/guides/your-first-agent.html
https://go-micro.dev/docs/guides/debugging-agents.html
https://go-micro.dev/docs/guides/zero-to-hero.html`
const docsWayfinding = `First-agent and 0→hero docs:
1. Start with the no-secret CLI demo
micro agent demo
This prints the maintained support-agent transcript command so you can
prove service tools, mock-model chat, and inspectable run history without
configuring a provider key.
If scaffold → run → chat → inspect stalls, print the short recovery map:
micro agent quickcheck
2. No-secret first-agent transcript
https://go-micro.dev/docs/guides/no-secret-first-agent.html
Run the maintained support agent without a provider key:
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
3. Your First Agent
https://go-micro.dev/docs/guides/your-first-agent.html
Build a service-backed agent, then use:
micro agent preflight # before micro run: prerequisites
micro run
micro chat
micro agent doctor # after micro run: chat/gateway/inspect recovery
4. Debugging your agent
https://go-micro.dev/docs/guides/debugging-agents.html
Inspect agent runs and memory with:
micro agent doctor
micro inspect agent <name>
micro agent history <name>
5. 0→hero Reference
https://go-micro.dev/docs/guides/zero-to-hero.html
Walk the scaffold → run → chat → inspect → deploy dry-run lifecycle.`
func genProtoHandler(c *cli.Context) error {
cmd := exec.Command("find", ".", "-name", "*.proto", "-exec", "protoc", "--proto_path=.", "--micro_out=.", "--go_out=.", `{}`, `;`)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func init() {
cmd.Register([]*cli.Command{
{
Name: "new",
Usage: "Create a new service",
ArgsUsage: "[name]",
UsageText: ` micro new helloworld # scaffold a single service
micro new --prompt "a todo list with tasks" # AI-design multiple services
micro new --prompt "add tags to the task service" # extend existing services`,
Action: new.Run,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "no-mcp",
Usage: "Disable MCP gateway integration in generated code",
},
&cli.BoolFlag{
Name: "proto",
Usage: "Use Protocol Buffers (requires protoc); default is reflection-based, no protoc needed",
},
&cli.StringFlag{
Name: "template",
Usage: "Service template: default, crud, pubsub, api",
},
&cli.StringFlag{
Name: "prompt",
Usage: "Describe the system to generate (uses AI to design & build services with real business logic)",
EnvVars: []string{"MICRO_NEW_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"},
},
},
},
{
Name: "gen",
Usage: "Generate various things",
Subcommands: []*cli.Command{
{
Name: "proto",
Usage: "Generate proto requires protoc and protoc-gen-micro",
Action: genProtoHandler,
},
},
},
{
Name: "services",
Usage: "List available services",
Action: func(ctx *cli.Context) error {
services, err := registry.ListServices()
if err != nil {
return err
}
for _, service := range services {
fmt.Println(service.Name)
}
return nil
},
},
{
Name: "examples",
Usage: "Show provider-free first-agent example paths",
Description: `Print the maintained no-secret examples for the services → agents →
workflows on-ramp: first-agent, transcript, support app, and matching guides.`,
Action: func(ctx *cli.Context) error {
fmt.Fprintln(ctx.App.Writer, examplesWayfinding)
return nil
},
},
{
Name: "zero-to-hero",
Usage: "Show the no-secret 0→hero lifecycle demo command",
Description: `Print the maintained provider-free services → agents → workflows
lifecycle command and the smaller runnable examples it covers.`,
Aliases: []string{"hero"},
Action: func(ctx *cli.Context) error {
fmt.Fprintln(ctx.App.Writer, zeroToHeroHelp)
return nil
},
},
{
Name: "docs",
Usage: "Show the first-agent and 0→hero documentation path",
Description: `Print the maintained adoption on-ramp for new Go Micro developers:
the no-secret first-agent transcript, Your First Agent, debugging guide, and
0→hero lifecycle reference.`,
Action: func(ctx *cli.Context) error {
fmt.Fprintln(ctx.App.Writer, docsWayfinding)
return nil
},
},
{
Name: "call",
Usage: "Call a service",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "header",
Aliases: []string{"H"},
Usage: "Set request headers (can be used multiple times): --header 'Key:Value'",
},
&cli.StringSliceFlag{
Name: "metadata",
Aliases: []string{"m"},
Usage: "Set request metadata (can be used multiple times): --metadata 'Key:Value'",
},
},
Action: func(ctx *cli.Context) error {
args := ctx.Args()
if args.Len() < 2 {
return fmt.Errorf("usage: [service] [endpoint] [request]")
}
service := args.Get(0)
endpoint := args.Get(1)
request := `{}`
if args.Len() == 3 {
request = args.Get(2)
}
// Create context with metadata if provided
// Note: This is for the direct 'micro call' command.
// Dynamic service calls (e.g., 'micro helloworld call') are handled in CallService.
callCtx := context.TODO()
callCtx = util.AddMetadataToContext(callCtx, ctx.StringSlice("metadata"))
callCtx = util.AddMetadataToContext(callCtx, ctx.StringSlice("header"))
req := client.NewRequest(service, endpoint, &bytes.Frame{Data: []byte(request)})
var rsp bytes.Frame
err := client.Call(callCtx, req, &rsp)
if err != nil {
return err
}
fmt.Print(string(rsp.Data))
return nil
},
},
{
Name: "describe",
Usage: "Describe a service",
Action: func(ctx *cli.Context) error {
args := ctx.Args()
if args.Len() != 1 {
return fmt.Errorf("usage: [service]")
}
service := args.Get(0)
services, err := registry.GetService(service)
if err != nil {
return err
}
if len(services) == 0 {
return nil
}
b, _ := json.MarshalIndent(services[0], "", " ")
fmt.Println(string(b))
return nil
},
},
// Note: The following commands are registered in their respective packages:
// - status, logs, stop: remote/remote.go
// - build: build/build.go
// - deploy: deploy/deploy.go
// - init: init/init.go
}...)
cmd.App().Action = func(c *cli.Context) error {
if c.Args().Len() == 0 {
return nil
}
v, err := exec.LookPath("micro-" + c.Args().First())
if err == nil {
ce := exec.Command(v, c.Args().Slice()[1:]...)
ce.Stdout = os.Stdout
ce.Stderr = os.Stderr
return ce.Run()
}
command := c.Args().Get(0)
args := c.Args().Slice()
if srv, err := util.LookupService(command); err != nil {
return util.CliError(err)
} else if srv != nil && util.ShouldRenderHelp(args) {
return cli.Exit(util.FormatServiceUsage(srv, c), 0)
} else if srv != nil {
err := util.CallService(srv, args)
return util.CliError(err)
}
return nil
}
}
+549
View File
@@ -0,0 +1,549 @@
// Package deploy provides the micro deploy command for deploying services
package deploy
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/urfave/cli/v2"
"go-micro.dev/v6/cmd"
"go-micro.dev/v6/cmd/micro/run/config"
)
const (
defaultRemotePath = "/opt/micro"
)
// Deploy deploys services to a target
func Deploy(c *cli.Context) error {
// Get target from args or flag
target := c.Args().First()
if target == "" {
target = c.String("ssh")
}
// Load config to check for deploy targets
dir := "."
absDir, _ := filepath.Abs(dir)
cfg, _ := config.Load(absDir)
// If still no target, check config for named targets
if target == "" && cfg != nil && len(cfg.Deploy) > 0 {
// Show available targets
return showDeployTargets(cfg)
}
if target == "" {
return showDeployHelp()
}
target, remotePath := resolveDeployTarget(c, target, cfg)
if c.Bool("dry-run") {
return printDeployPlan(c, target, cfg, remotePath)
}
return deploySSH(c, target, cfg, remotePath)
}
func resolveDeployTarget(c *cli.Context, target string, cfg *config.Config) (string, string) {
remotePath := c.String("path")
if remotePath == "" {
remotePath = defaultRemotePath
}
// Check if target is a named target from config
if cfg != nil {
if dt, ok := cfg.Deploy[target]; ok {
target = dt.SSH
if dt.Path != "" && !c.IsSet("path") {
remotePath = dt.Path
}
}
}
return target, remotePath
}
func showDeployHelp() error {
return fmt.Errorf(`no deployment target specified.
To deploy, you need a server running micro. Quick setup:
1. On your server (Ubuntu/Debian):
ssh user@your-server
curl -fsSL https://go-micro.dev/install.sh | sh
sudo micro init --server
2. Then deploy from here:
micro deploy user@your-server
Or add to micro.mu:
deploy prod
ssh user@your-server
Run 'micro deploy --help' for more options`)
}
func showDeployTargets(cfg *config.Config) error {
var sb strings.Builder
sb.WriteString("Available deploy targets:\n\n")
for name, dt := range cfg.Deploy {
fmt.Fprintf(&sb, " %s -> %s\n", name, dt.SSH)
}
sb.WriteString("\nDeploy with: micro deploy <target>")
return fmt.Errorf("%s", sb.String())
}
func printDeployPlan(c *cli.Context, target string, cfg *config.Config, remotePath string) error {
dir := c.Args().Get(1)
if dir == "" {
dir = "."
}
absDir, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
if cfg == nil {
cfg, _ = config.Load(absDir)
}
if remotePath == "" {
remotePath = defaultRemotePath
}
services, err := deployServices(absDir, cfg, c.String("service"))
if err != nil {
return err
}
fmt.Println()
fmt.Println(" \033[1mmicro deploy --dry-run\033[0m")
fmt.Println()
fmt.Printf(" Target \033[36m%s\033[0m\n", target)
fmt.Printf(" Remote path %s\n", remotePath)
fmt.Printf(" Services %s\n", strings.Join(services, ", "))
fmt.Println()
fmt.Println(" Plan:")
fmt.Println(" 1. Build linux/amd64 service binaries")
fmt.Printf(" 2. Copy binaries to %s/bin/\n", remotePath)
fmt.Println(" 3. Enable and restart micro@<service> systemd units")
fmt.Println(" 4. Check service health")
fmt.Println()
fmt.Println(" No SSH, rsync, systemd, or remote deployment was performed.")
return nil
}
func deployServices(absDir string, cfg *config.Config, filterService string) ([]string, error) {
if filterService != "" && cfg != nil {
found := false
for _, svc := range cfg.Services {
if svc.Name == filterService {
found = true
break
}
}
if !found && len(cfg.Services) > 0 {
return nil, fmt.Errorf("service '%s' not found in configuration", filterService)
}
}
if cfg != nil && len(cfg.Services) > 0 {
sorted, err := cfg.TopologicalSort()
if err != nil {
return nil, err
}
services := make([]string, 0, len(sorted))
for _, svc := range sorted {
if filterService == "" || svc.Name == filterService {
services = append(services, svc.Name)
}
}
return services, nil
}
services := []string{filepath.Base(absDir)}
if filterService != "" && filterService != services[0] {
return nil, fmt.Errorf("service '%s' not found (only '%s' available)", filterService, services[0])
}
return services, nil
}
func deploySSH(c *cli.Context, target string, cfg *config.Config, remotePath string) error {
dir := c.Args().Get(1)
if dir == "" {
dir = "."
}
absDir, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
// Load config if not passed
if cfg == nil {
cfg, _ = config.Load(absDir)
}
if remotePath == "" {
remotePath = defaultRemotePath
}
fmt.Println()
fmt.Println(" \033[1mmicro deploy\033[0m")
fmt.Println()
fmt.Printf(" Target \033[36m%s\033[0m\n\n", target)
// Early validation: resolve services before SSH checks.
services, err := deployServices(absDir, cfg, c.String("service"))
if err != nil {
return err
}
// Step 1: Check SSH connectivity
fmt.Print(" Checking SSH connection... ")
if err := checkSSH(target); err != nil {
fmt.Println("\u2717")
return err
}
fmt.Println("\u2713")
// Step 2: Check server is initialized
fmt.Print(" Checking server setup... ")
if err := checkServerInit(target, remotePath); err != nil {
fmt.Println("\u2717")
return err
}
fmt.Println("\u2713")
// Step 3: Build binaries
fmt.Printf(" Building binaries... ")
if err := buildBinaries(absDir, cfg, c.Bool("build"), services); err != nil {
fmt.Println("\u2717")
return err
}
fmt.Printf("\u2713 %s\n", strings.Join(services, ", "))
// Step 4: Copy binaries
fmt.Printf(" Copying binaries... ")
if err := copyBinaries(target, filepath.Join(absDir, "bin"), remotePath); err != nil {
fmt.Println("\u2717")
return err
}
fmt.Printf("\u2713 %d services\n", len(services))
// Step 5: Setup and restart services via systemd
fmt.Printf(" Updating systemd... ")
if err := setupSystemdServices(target, remotePath, services); err != nil {
fmt.Println("\u2717")
return err
}
fmt.Printf("\u2713 %s\n", strings.Join(prefixServices(services), ", "))
// Step 6: Restart services
fmt.Printf(" Restarting services... ")
if err := restartServices(target, services); err != nil {
fmt.Println("\u2717")
return err
}
fmt.Println("\u2713")
// Step 7: Check health
fmt.Printf(" Checking health... ")
time.Sleep(2 * time.Second) // Give services time to start
healthy, unhealthy := checkServicesHealth(target, services)
if len(unhealthy) > 0 {
fmt.Printf("\u26a0 %d/%d healthy\n", len(healthy), len(services))
} else {
fmt.Println("\u2713 all healthy")
}
fmt.Println()
fmt.Printf("\u2713 Deployed to %s\n", target)
fmt.Println()
fmt.Printf(" Status: micro status --remote %s\n", target)
fmt.Printf(" Logs: micro logs --remote %s\n", target)
if len(unhealthy) > 0 {
fmt.Println()
fmt.Printf("\u26a0 Some services may have issues: %s\n", strings.Join(unhealthy, ", "))
fmt.Printf(" Check logs: micro logs %s --remote %s\n", unhealthy[0], target)
}
return nil
}
func prefixServices(services []string) []string {
result := make([]string, len(services))
for i, s := range services {
result[i] = "micro@" + s
}
return result
}
func checkSSH(host string) error {
testCmd := exec.Command("ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", host, "echo ok")
output, err := testCmd.CombinedOutput()
if err != nil {
return fmt.Errorf(`
\u2717 Cannot connect to %s
SSH connection failed. Check that:
\u2022 The server is reachable: ping %s
\u2022 SSH is configured: ssh %s
\u2022 Your key is added: ssh-add -l
Common fixes:
\u2022 Add SSH key: ssh-copy-id %s
\u2022 Check hostname in ~/.ssh/config
Error: %s`, host, host, host, host, strings.TrimSpace(string(output)))
}
return nil
}
func checkServerInit(host, remotePath string) error {
checkCmd := fmt.Sprintf("test -f %s/.micro-initialized", remotePath)
sshCmd := exec.Command("ssh", host, checkCmd)
if err := sshCmd.Run(); err != nil {
return fmt.Errorf(`
\u2717 Server not initialized
micro is not set up on %s.
Run this on the server:
ssh %s
curl -fsSL https://go-micro.dev/install.sh | sh
sudo micro init --server
Or initialize remotely (requires sudo):
micro init --server --remote %s`, host, host, host)
}
return nil
}
func buildBinaries(absDir string, cfg *config.Config, forceBuild bool, servicesToBuild []string) error {
binDir := filepath.Join(absDir, "bin")
// Always build for linux/amd64
targetOS := "linux"
targetArch := "amd64"
if err := os.MkdirAll(binDir, 0755); err != nil {
return err
}
if cfg != nil && len(cfg.Services) > 0 {
sorted, err := cfg.TopologicalSort()
if err != nil {
return err
}
// Create a map for quick lookup of services to build
// This provides O(1) lookup time and makes the code more maintainable
shouldBuild := make(map[string]bool)
for _, svcName := range servicesToBuild {
shouldBuild[svcName] = true
}
for _, svc := range sorted {
// Only build services in the servicesToBuild list
if !shouldBuild[svc.Name] {
continue
}
svcDir := filepath.Join(absDir, svc.Path)
outPath := filepath.Join(binDir, svc.Name)
buildCmd := exec.Command("go", "build", "-o", outPath, ".")
buildCmd.Dir = svcDir
buildCmd.Env = append(os.Environ(),
"GOOS="+targetOS,
"GOARCH="+targetArch,
"CGO_ENABLED=0",
)
if output, err := buildCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to build %s:\n%s", svc.Name, string(output))
}
}
} else {
name := filepath.Base(absDir)
outPath := filepath.Join(binDir, name)
buildCmd := exec.Command("go", "build", "-o", outPath, ".")
buildCmd.Dir = absDir
buildCmd.Env = append(os.Environ(),
"GOOS="+targetOS,
"GOARCH="+targetArch,
"CGO_ENABLED=0",
)
if output, err := buildCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to build:\n%s", string(output))
}
}
return nil
}
func copyBinaries(target, binDir, remotePath string) error {
// Ensure remote bin directory exists
mkdirCmd := exec.Command("ssh", target, fmt.Sprintf("mkdir -p %s/bin", remotePath))
if err := mkdirCmd.Run(); err != nil {
return fmt.Errorf("failed to create remote directory: %w", err)
}
// Use rsync for efficient copy
// --omit-dir-times avoids permission errors on directory timestamps
rsyncArgs := []string{
"-avz", "--delete", "--omit-dir-times",
binDir + "/",
fmt.Sprintf("%s:%s/bin/", target, remotePath),
}
rsyncCmd := exec.Command("rsync", rsyncArgs...)
output, err := rsyncCmd.CombinedOutput()
if err != nil {
outputStr := string(output)
// Fall back to scp if rsync not available
if strings.Contains(outputStr, "command not found") {
scpCmd := exec.Command("scp", "-r", binDir+"/", fmt.Sprintf("%s:%s/bin/", target, remotePath))
if scpOutput, scpErr := scpCmd.CombinedOutput(); scpErr != nil {
return fmt.Errorf("copy failed: %s", string(scpOutput))
}
return nil
}
// rsync exit code 23 means some files failed to transfer, but if we see our files listed, it's ok
// rsync exit code 24 means some files vanished during transfer (harmless)
exitErr, ok := err.(*exec.ExitError)
if ok && (exitErr.ExitCode() == 23 || exitErr.ExitCode() == 24) {
// Check if it's just permission warnings on metadata, not actual file transfer failures
if !strings.Contains(outputStr, "Permission denied (13)") ||
strings.Contains(outputStr, "failed to set times") ||
strings.Contains(outputStr, "chgrp") {
// These are acceptable warnings
return nil
}
}
return fmt.Errorf("copy failed: %s", outputStr)
}
return nil
}
func setupSystemdServices(target, remotePath string, services []string) error {
for _, svc := range services {
// Enable the service using the template
enableCmd := fmt.Sprintf("sudo systemctl enable micro@%s 2>/dev/null || true", svc)
sshCmd := exec.Command("ssh", target, enableCmd)
_ = sshCmd.Run() // Ignore errors, service might already be enabled
}
// Reload systemd
reloadCmd := exec.Command("ssh", target, "sudo systemctl daemon-reload")
if err := reloadCmd.Run(); err != nil {
return fmt.Errorf("failed to reload systemd: %w", err)
}
return nil
}
func restartServices(target string, services []string) error {
for _, svc := range services {
restartCmd := fmt.Sprintf("sudo systemctl restart micro@%s", svc)
sshCmd := exec.Command("ssh", target, restartCmd)
if output, err := sshCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to restart %s: %s", svc, string(output))
}
}
return nil
}
func checkServicesHealth(target string, services []string) (healthy, unhealthy []string) {
for _, svc := range services {
checkCmd := fmt.Sprintf("systemctl is-active micro@%s", svc)
sshCmd := exec.Command("ssh", target, checkCmd)
if err := sshCmd.Run(); err != nil {
unhealthy = append(unhealthy, svc)
} else {
healthy = append(healthy, svc)
}
}
return
}
// Ensure we're not on Windows for deploy
func checkPlatform() error {
if runtime.GOOS == "windows" {
return fmt.Errorf("micro deploy requires SSH and rsync, which work best on Linux/macOS.\nConsider using WSL on Windows")
}
return nil
}
func init() {
cmd.Register(&cli.Command{
Name: "deploy",
Usage: "Deploy services to a remote server",
Description: `Deploy copies binaries to a remote server and manages them with systemd.
Before deploying, initialize the server:
ssh user@server 'curl -fsSL https://go-micro.dev/install.sh | sh && sudo micro init --server'
Then deploy:
micro deploy user@server
Deploy a specific service (multi-service projects):
micro deploy user@server --service users
With a micro.mu config, you can define named targets:
deploy prod
ssh user@prod.example.com
deploy staging
ssh user@staging.example.com
Then: micro deploy prod
The deploy process:
1. Builds binaries for linux/amd64
2. Copies to /opt/micro/bin/ via rsync
3. Enables and restarts systemd services
4. Verifies services are healthy`,
Action: func(c *cli.Context) error {
if err := checkPlatform(); err != nil {
return err
}
return Deploy(c)
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "ssh",
Usage: "Deploy target as user@host (can also be positional arg)",
},
&cli.StringFlag{
Name: "path",
Usage: "Remote path (default: /opt/micro)",
Value: "/opt/micro",
},
&cli.BoolFlag{
Name: "build",
Usage: "Force rebuild of binaries",
},
&cli.StringFlag{
Name: "service",
Usage: "Deploy only a specific service (for multi-service projects)",
},
&cli.BoolFlag{
Name: "dry-run",
Usage: "Print the deployment plan without building, connecting, copying, or restarting services",
},
},
})
}
+167
View File
@@ -0,0 +1,167 @@
package deploy
import (
"flag"
"os"
"strings"
"testing"
"github.com/urfave/cli/v2"
"go-micro.dev/v6/cmd/micro/run/config"
)
func newDeployTestContext(t *testing.T, args ...string) *cli.Context {
t.Helper()
set := flag.NewFlagSet("deploy", flag.ContinueOnError)
set.String("path", defaultRemotePath, "")
set.String("ssh", "", "")
set.String("service", "", "")
set.Bool("build", false, "")
set.Bool("dry-run", false, "")
if err := set.Parse(args); err != nil {
t.Fatalf("parse flags: %v", err)
}
return cli.NewContext(cli.NewApp(), set, nil)
}
func TestDeployNoTargetExplainsInitAndDeployHandoff(t *testing.T) {
err := showDeployHelp()
if err == nil {
t.Fatal("expected missing target guidance")
}
msg := err.Error()
for _, want := range []string{
"no deployment target specified",
"sudo micro init --server",
"micro deploy user@your-server",
"deploy prod",
} {
if !strings.Contains(msg, want) {
t.Fatalf("missing %q in guidance:\n%s", want, msg)
}
}
}
func TestDeployListsConfiguredTargetsWhenNoTargetProvided(t *testing.T) {
err := showDeployTargets(&config.Config{Deploy: map[string]*config.DeployTarget{
"prod": {Name: "prod", SSH: "deploy@prod.example.com"},
"staging": {Name: "staging", SSH: "deploy@staging.example.com"},
}})
if err == nil {
t.Fatal("expected configured target guidance")
}
msg := err.Error()
for _, want := range []string{
"Available deploy targets:",
"prod -> deploy@prod.example.com",
"staging -> deploy@staging.example.com",
"micro deploy <target>",
} {
if !strings.Contains(msg, want) {
t.Fatalf("missing %q in configured target guidance:\n%s", want, msg)
}
}
}
func TestResolveDeployTargetUsesConfigTargetAndPath(t *testing.T) {
ctx := newDeployTestContext(t, "prod")
cfg := &config.Config{Deploy: map[string]*config.DeployTarget{
"prod": {Name: "prod", SSH: "deploy@prod.example.com", Path: "/srv/micro"},
}}
target, remotePath := resolveDeployTarget(ctx, ctx.Args().First(), cfg)
if target != "deploy@prod.example.com" {
t.Fatalf("target = %q, want configured SSH", target)
}
if remotePath != "/srv/micro" {
t.Fatalf("remotePath = %q, want configured path", remotePath)
}
}
func TestResolveDeployTargetAllowsCLIPathOverride(t *testing.T) {
ctx := newDeployTestContext(t, "--path", "/tmp/micro", "prod")
cfg := &config.Config{Deploy: map[string]*config.DeployTarget{
"prod": {Name: "prod", SSH: "deploy@prod.example.com", Path: "/srv/micro"},
}}
target, remotePath := resolveDeployTarget(ctx, ctx.Args().First(), cfg)
if target != "deploy@prod.example.com" {
t.Fatalf("target = %q, want configured SSH", target)
}
if remotePath != "/tmp/micro" {
t.Fatalf("remotePath = %q, want CLI override", remotePath)
}
}
func TestDeployConfigParserSupportsDeployTargets(t *testing.T) {
dir := t.TempDir()
path := dir + "/micro.mu"
content := `service api
path ./api
deploy prod
ssh deploy@prod.example.com
path /srv/micro
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := config.ParseMu(path)
if err != nil {
t.Fatalf("parse config: %v", err)
}
prod := cfg.Deploy["prod"]
if prod == nil {
t.Fatal("missing prod deploy target")
}
if prod.SSH != "deploy@prod.example.com" || prod.Path != "/srv/micro" {
t.Fatalf("deploy target = %#v", prod)
}
}
func TestDeployDryRunPlansConfiguredTargetWithoutRemoteSideEffects(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(dir+"/micro.mu", []byte(`service api
path ./api
deploy prod
ssh deploy@prod.example.com
path /srv/micro
`), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
oldwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() {
if err := os.Chdir(oldwd); err != nil {
t.Errorf("restore cwd: %v", err)
}
})
ctx := newDeployTestContext(t, "--dry-run", "prod")
if err := Deploy(ctx); err != nil {
t.Fatalf("dry-run deploy: %v", err)
}
}
func TestDeployDryRunValidatesRequestedService(t *testing.T) {
ctx := newDeployTestContext(t, "--dry-run", "--service", "missing", "prod")
cfg := &config.Config{Services: map[string]*config.Service{
"api": {Name: "api", Path: "./api"},
}}
err := printDeployPlan(ctx, "deploy@prod.example.com", cfg, defaultRemotePath)
if err == nil {
t.Fatal("expected dry-run to validate service names")
}
if !strings.Contains(err.Error(), "service 'missing' not found in configuration") {
t.Fatalf("unexpected error: %v", err)
}
}
+272
View File
@@ -0,0 +1,272 @@
// Package generate provides code generation commands for micro
package gen
import (
"fmt"
"os"
"path/filepath"
"strings"
"text/template"
"github.com/urfave/cli/v2"
"go-micro.dev/v6/cmd"
)
var handlerTemplate = `package handler
import (
"context"
log "go-micro.dev/v6/logger"
)
type {{.Name}} struct{}
func New{{.Name}}() *{{.Name}} {
return &{{.Name}}{}
}
{{range .Methods}}
// {{.Name}} handles {{.Name}} requests
func (h *{{$.Name}}) {{.Name}}(ctx context.Context, req *{{.RequestType}}, rsp *{{.ResponseType}}) error {
log.Infof("Received {{$.Name}}.{{.Name}} request")
// TODO: implement
return nil
}
{{end}}
`
var endpointTemplate = `package handler
import (
"context"
"encoding/json"
"net/http"
log "go-micro.dev/v6/logger"
)
// {{.Name}}Request is the request for {{.Name}}
type {{.Name}}Request struct {
// Add request fields here
}
// {{.Name}}Response is the response for {{.Name}}
type {{.Name}}Response struct {
// Add response fields here
}
// {{.Name}} handles HTTP {{.Method}} requests to /{{.Path}}
func {{.Name}}(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
log.Infof("Received {{.Name}} request")
var req {{.Name}}Request
if r.Method != http.MethodGet {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
// TODO: implement handler logic
_ = ctx
_ = req
rsp := {{.Name}}Response{}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(rsp)
}
`
var modelTemplate = `package model
import (
"context"
"time"
)
// {{.Name}} represents a {{lower .Name}} in the system
type {{.Name}} struct {
ID string ` + "`json:\"id\"`" + `
CreatedAt time.Time ` + "`json:\"created_at\"`" + `
UpdatedAt time.Time ` + "`json:\"updated_at\"`" + `
// Add your fields here
}
// {{.Name}}Repository defines the interface for {{lower .Name}} storage
type {{.Name}}Repository interface {
Create(ctx context.Context, m *{{.Name}}) error
Get(ctx context.Context, id string) (*{{.Name}}, error)
Update(ctx context.Context, m *{{.Name}}) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, offset, limit int) ([]*{{.Name}}, error)
}
`
type handlerData struct {
Name string
Methods []methodData
}
type methodData struct {
Name string
RequestType string
ResponseType string
}
type endpointData struct {
Name string
Method string
Path string
}
type modelData struct {
Name string
}
func generateHandler(c *cli.Context) error {
name := c.Args().First()
if name == "" {
return fmt.Errorf("handler name required: micro generate handler <name>")
}
name = strings.Title(strings.ToLower(name))
// Parse methods if provided
methods := []methodData{}
for _, m := range c.StringSlice("method") {
methods = append(methods, methodData{
Name: strings.Title(m),
RequestType: strings.Title(m) + "Request",
ResponseType: strings.Title(m) + "Response",
})
}
if len(methods) == 0 {
methods = []methodData{
{Name: "Handle", RequestType: "Request", ResponseType: "Response"},
}
}
data := handlerData{
Name: name,
Methods: methods,
}
return generateFile("handler", strings.ToLower(name)+".go", handlerTemplate, data)
}
func generateEndpoint(c *cli.Context) error {
name := c.Args().First()
if name == "" {
return fmt.Errorf("endpoint name required: micro generate endpoint <name>")
}
data := endpointData{
Name: strings.Title(strings.ToLower(name)),
Method: strings.ToUpper(c.String("method")),
Path: c.String("path"),
}
if data.Path == "" {
data.Path = strings.ToLower(name)
}
return generateFile("handler", strings.ToLower(name)+"_endpoint.go", endpointTemplate, data)
}
func generateModel(c *cli.Context) error {
name := c.Args().First()
if name == "" {
return fmt.Errorf("model name required: micro generate model <name>")
}
data := modelData{
Name: strings.Title(strings.ToLower(name)),
}
return generateFile("model", strings.ToLower(name)+".go", modelTemplate, data)
}
func generateFile(dir, filename, tmplStr string, data interface{}) error {
// Create directory if it doesn't exist
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
filepath := filepath.Join(dir, filename)
// Check if file exists
if _, err := os.Stat(filepath); err == nil {
return fmt.Errorf("file %s already exists", filepath)
}
fn := template.FuncMap{
"title": strings.Title,
"lower": strings.ToLower,
}
tmpl, err := template.New("gen").Funcs(fn).Parse(tmplStr)
if err != nil {
return fmt.Errorf("failed to parse template: %w", err)
}
f, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer f.Close()
if err := tmpl.Execute(f, data); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
fmt.Printf("Created %s\n", filepath)
return nil
}
func init() {
cmd.Register(&cli.Command{
Name: "generate",
Usage: "Generate code scaffolding (like Rails generators)",
Aliases: []string{"gen"},
Subcommands: []*cli.Command{
{
Name: "handler",
Usage: "Generate a handler: micro g handler <name>",
Action: generateHandler,
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "method",
Aliases: []string{"m"},
Usage: "Methods to generate (can be repeated)",
},
},
},
{
Name: "endpoint",
Usage: "Generate an HTTP endpoint: micro g endpoint <name>",
Action: generateEndpoint,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "method",
Aliases: []string{"m"},
Usage: "HTTP method (GET, POST, etc.)",
Value: "POST",
},
&cli.StringFlag{
Name: "path",
Aliases: []string{"p"},
Usage: "URL path for the endpoint",
},
},
},
{
Name: "model",
Usage: "Generate a model: micro g model <name>",
Action: generateModel,
},
},
})
}
+862
View File
@@ -0,0 +1,862 @@
// Package generate implements AI-powered service generation for go-micro.
// It uses an LLM to design service architecture and generate handler code
// with real business logic, then compiles and fixes errors iteratively.
package generate
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"go-micro.dev/v6/ai"
_ "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"
)
// goMicroVersion is the go-micro.dev/v6 release pinned into the go.mod of
// every scaffolded project. This is the single source of truth — bump it
// here when cutting a release so generated services and agents stay in
// sync with the framework.
const goMicroVersion = "v6.0.0"
const designPrompt = `You are a Go microservices architect using the go-micro framework.
Given a system description, design the services needed.
Return ONLY valid JSON:
{
"services": [
{
"name": "service-name",
"description": "What this service does",
"fields": [
{"name": "field_name", "type": "string", "description": "What this field is"}
],
"endpoints": [
{"name": "EndpointName", "description": "What this endpoint does", "example": "{\"key\": \"value\"}"}
]
}
]
}
Rules:
- Service names are lowercase, hyphenated, WITHOUT a "-service" suffix (e.g. "task" not "task-service", "shipping" not "shipping-service")
- Each service MUST have CRUD endpoints: Create, Read, Update, Delete, List
- Add 1-3 custom endpoints for real business logic (e.g. PlaceOrder, CheckInventory)
- Field types: string, int64, bool, float64
- Every service needs id (string), created (int64), updated (int64) fields
- Endpoint names are PascalCase
- Examples should be realistic JSON
- 2-4 services max, focused on the domain
- Keep services small and focused — one concern per service, max 5-8 fields
- Services don't call each other; an AI agent orchestrates across them`
const designPromptWithExisting = `You are a Go microservices architect using the go-micro framework.
The user has an EXISTING system with services already running. They want to extend or modify it.
Existing services:
%s
Given the user's request, return the COMPLETE set of services (existing + new/modified).
For existing services the user hasn't asked to change, return them as-is.
For new or modified services, include the full specification.
Return ONLY valid JSON:
{
"services": [
{
"name": "service-name",
"description": "What this service does",
"fields": [
{"name": "field_name", "type": "string", "description": "What this field is"}
],
"endpoints": [
{"name": "EndpointName", "description": "What this endpoint does", "example": "{\"key\": \"value\"}"}
]
}
]
}
Rules:
- Service names are lowercase, hyphenated, WITHOUT a "-service" suffix (e.g. "task" not "task-service", "shipping" not "shipping-service")
- Each service MUST have CRUD endpoints: Create, Read, Update, Delete, List
- Add custom endpoints for real business logic
- Field types: string, int64, bool, float64
- Every service needs id (string), created (int64), updated (int64) fields
- Endpoint names are PascalCase
- Examples should be realistic JSON
- Keep existing services unless the user explicitly asks to change them`
const handlerPrompt = `You are a Go developer writing a handler for a go-micro service.
Generate a COMPLETE, COMPILABLE Go handler file.
The handler must:
1. Use package "handler"
2. Import the proto package as: pb "%s/proto"
3. Import go-micro logger as: log "go-micro.dev/v6/logger"
4. Import "github.com/google/uuid" for ID generation
5. Use "go-micro.dev/v6/store" for persistent storage (NOT in-memory maps)
6. Include REAL business logic — not just CRUD store operations
7. Every exported method must have a doc comment explaining what it does
8. Every method must have an @example tag with realistic JSON input
9. Handle edge cases, validation, and return meaningful errors
10. Keep the file under 200 lines — be concise, no boilerplate
For storage, use the go-micro store package:
import "go-micro.dev/v6/store"
import "encoding/json"
// In the struct:
store store.Store
// In the constructor:
func New() *%s { return &%s{store: store.DefaultStore} }
// Write a record:
data, _ := json.Marshal(record)
store.Write(&store.Record{Key: "prefix/" + id, Value: data})
// Read a record:
recs, err := store.Read("prefix/" + id)
json.Unmarshal(recs[0].Value, &record)
// List keys:
keys, _ := store.List(store.ListPrefix("prefix/"))
// Delete:
store.Delete("prefix/" + id)
Do NOT use sync.Mutex or in-memory maps. Use store for all data.
The struct name is %s.
The constructor is func New() *%s.
Here is the proto definition:
%s
Here is what each endpoint should do:
%s
Return ONLY the Go code. No markdown, no explanation. Just the .go file content starting with "package handler".`
// ServiceDesign is the LLM's output.
type ServiceDesign struct {
Services []ServiceSpec `json:"services"`
}
type ServiceSpec struct {
Name string `json:"name"`
Description string `json:"description"`
Fields []FieldSpec `json:"fields"`
Endpoints []EndpointSpec `json:"endpoints"`
}
type FieldSpec struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
}
type EndpointSpec struct {
Name string `json:"name"`
Description string `json:"description"`
Example string `json:"example"`
}
// Design calls an LLM to design services from a prompt.
// If baseDir contains existing services, they are included as context
// so the LLM extends the system rather than redesigning from scratch.
func Design(ctx context.Context, provider, apiKey, model, baseDir, prompt string) (*ServiceDesign, error) {
m := newModel(provider, apiKey, model)
if m == nil {
return nil, fmt.Errorf("unknown provider: %s", provider)
}
existing := discoverExisting(baseDir)
var sysPrompt, userPrompt string
if len(existing) > 0 {
sysPrompt = fmt.Sprintf(designPromptWithExisting, existing)
userPrompt = fmt.Sprintf("Extend or modify the system: %s", prompt)
} else {
sysPrompt = designPrompt
userPrompt = fmt.Sprintf("Design a microservices system for: %s", prompt)
}
sp := startSpinner("designing services...")
designCtx, designCancel := context.WithTimeout(ctx, 60*time.Second)
defer designCancel()
resp, err := m.Generate(designCtx, &ai.Request{
Prompt: userPrompt,
SystemPrompt: sysPrompt,
})
sp.Stop()
if err != nil {
return nil, fmt.Errorf("design failed: %w", err)
}
reply := firstNonEmpty(resp.Answer, resp.Reply)
reply = extractJSON(reply)
var design ServiceDesign
if err := json.Unmarshal([]byte(reply), &design); err != nil {
return nil, fmt.Errorf("failed to parse design: %w\nResponse: %s", err, reply)
}
if len(design.Services) == 0 {
return nil, fmt.Errorf("no services designed")
}
return &design, nil
}
// discoverExisting scans a directory for existing go-micro services
// and returns a summary string for inclusion in the design prompt.
func discoverExisting(baseDir string) string {
entries, err := os.ReadDir(baseDir)
if err != nil {
return ""
}
var summaries []string
for _, e := range entries {
if !e.IsDir() {
continue
}
svcDir := filepath.Join(baseDir, e.Name())
// Look for proto files as indicator of a go-micro service
protoDir := filepath.Join(svcDir, "proto")
protos, err := filepath.Glob(filepath.Join(protoDir, "*.proto"))
if err != nil || len(protos) == 0 {
continue
}
proto := readFile(protos[0])
if proto == "" {
continue
}
summaries = append(summaries, fmt.Sprintf("### %s\nProto:\n```\n%s\n```", e.Name(), proto))
}
return strings.Join(summaries, "\n\n")
}
// Generate creates go-micro service directories from a design.
// If a service directory already exists, it skips structure generation
// but regenerates the handler (allowing iterative improvement).
func Generate(ctx context.Context, baseDir string, design *ServiceDesign, provider, apiKey, model string) error {
m := newModel(provider, apiKey, model)
for i, svc := range design.Services {
if ctx.Err() != nil {
return ctx.Err()
}
svcDir := filepath.Join(baseDir, svc.Name)
handlerFile := filepath.Join(svcDir, "handler", svc.Name+".go")
protoFile := filepath.Join(svcDir, "proto", svc.Name+".proto")
// Snapshot proto hash before structure generation
protoBefore := fileHash(protoFile)
fmt.Printf(" \033[2m[%d/%d]\033[0m generating \033[36m%s\033[0m...\n", i+1, len(design.Services), svc.Name)
// Step 1: Generate proto (deterministic — from design spec)
if err := generateStructure(svcDir, svc); err != nil {
return fmt.Errorf("structure %s: %w", svc.Name, err)
}
protoAfter := fileHash(protoFile)
protoChanged := protoBefore != protoAfter
// If proto unchanged and handler unmodified, nothing to do
if !protoChanged && protoBefore != "" && !handlerModified(svcDir, handlerFile) {
fmt.Printf(" \033[32m✓\033[0m %s \033[2m(unchanged)\033[0m\n", svc.Name)
continue
}
// Step 2: Run go mod tidy + make proto to get compiled proto
_ = runIn(svcDir, "go", "mod", "tidy")
_ = runIn(svcDir, "make", "proto")
// Step 3: Generate handler with business logic (LLM)
proto := readFile(protoFile)
if err := generateHandler(ctx, m, svcDir, svc, proto); err != nil {
return fmt.Errorf("handler %s: %w", svc.Name, err)
}
// Step 4: Compile-fix loop
if err := compileFix(ctx, m, svcDir, svc.Name, 3); err != nil {
fmt.Printf(" \033[33m⚠\033[0m %s has compile errors (may need manual fix)\n", svc.Name)
} else {
fmt.Printf(" \033[32m✓\033[0m %s\n", svc.Name)
}
// Record final handler hash (after any compile fixes)
recordHandlerHash(svcDir, handlerFile)
}
// Generate an agent that manages all the services
var svcNames []string
for _, svc := range design.Services {
svcNames = append(svcNames, svc.Name)
}
if err := generateAgent(baseDir, design, svcNames); err != nil {
fmt.Printf(" \033[33m⚠\033[0m agent generation failed: %v\n", err)
}
return nil
}
// generateStructure creates the proto, main.go, go.mod, Makefile.
// If the directory already exists, only regenerates the proto
// (handler will be regenerated separately by the LLM).
func generateStructure(dir string, svc ServiceSpec) error {
exists := false
if _, err := os.Stat(dir); err == nil {
exists = true
}
_ = os.MkdirAll(filepath.Join(dir, "handler"), 0755)
_ = os.MkdirAll(filepath.Join(dir, "proto"), 0755)
name := svc.Name
titleName := toTitle(name)
dehyphen := strings.ReplaceAll(name, "-", "")
// Regenerate proto unless user has modified it
protoPath := filepath.Join(dir, "proto", name+".proto")
if !fileModified(dir, "proto_hash", protoPath) {
writeFile(protoPath, buildProto(dehyphen, titleName, svc))
recordFileHash(dir, "proto_hash", protoPath)
} else {
fmt.Printf(" \033[2mkeeping %s proto (modified)\033[0m\n", name)
}
// Only write structural files if directory is new
if !exists {
writeFile(filepath.Join(dir, "main.go"), buildMain(name, titleName))
writeFile(filepath.Join(dir, "Makefile"),
"GOPATH:=$(shell go env GOPATH)\n\n.PHONY: proto\nproto:\n\tprotoc --proto_path=. --micro_out=. --go_out=. proto/*.proto\n")
writeFile(filepath.Join(dir, "go.mod"),
fmt.Sprintf("module %s\n\ngo 1.24\n\nrequire go-micro.dev/v6 %s\n", name, goMicroVersion))
writeFile(filepath.Join(dir, ".gitignore"),
fmt.Sprintf("%s\n.micro\n", name))
}
// Placeholder handler so go mod tidy works (will be overwritten by LLM)
handlerPath := filepath.Join(dir, "handler", name+".go")
if _, err := os.Stat(handlerPath); os.IsNotExist(err) {
writeFile(handlerPath,
fmt.Sprintf("package handler\n\ntype %s struct{}\n\nfunc New() *%s { return &%s{} }\n", titleName, titleName, titleName))
recordHandlerHash(dir, handlerPath)
}
return nil
}
// generateHandler asks the LLM to write the handler with business logic.
// If the handler exists and the user has modified it since generation,
// it is left untouched.
func generateHandler(ctx context.Context, m ai.Model, dir string, svc ServiceSpec, proto string) error {
if m == nil {
return nil // no LLM — keep the placeholder
}
handlerFile := filepath.Join(dir, "handler", svc.Name+".go")
if handlerModified(dir, handlerFile) {
fmt.Printf(" \033[2mkeeping %s handler (modified)\033[0m\n", svc.Name)
return nil
}
titleName := toTitle(svc.Name)
// Build endpoint descriptions
var epDescs []string
for _, ep := range svc.Endpoints {
epDescs = append(epDescs, fmt.Sprintf("- %s: %s (example input: %s)", ep.Name, ep.Description, ep.Example))
}
prompt := fmt.Sprintf(handlerPrompt,
svc.Name, titleName, titleName, titleName, titleName, proto, strings.Join(epDescs, "\n"))
sp := startSpinner(fmt.Sprintf("writing %s handler...", svc.Name))
genCtx, genCancel := context.WithTimeout(ctx, 90*time.Second)
defer genCancel()
resp, err := m.Generate(genCtx, &ai.Request{
Prompt: fmt.Sprintf("Generate the handler for the %s service with real business logic.", svc.Name),
SystemPrompt: prompt,
})
sp.Stop()
if err != nil {
return err
}
code := firstNonEmpty(resp.Answer, resp.Reply)
code = extractCode(code)
if !strings.HasPrefix(strings.TrimSpace(code), "package") {
return fmt.Errorf("LLM did not return valid Go code")
}
if isTruncated(code) {
fmt.Printf(" \033[33m→\033[0m response truncated, retrying...\n")
sp = startSpinner(fmt.Sprintf("rewriting %s handler...", svc.Name))
retryCtx, retryCancel := context.WithTimeout(ctx, 90*time.Second)
defer retryCancel()
resp, err = m.Generate(retryCtx, &ai.Request{
Prompt: fmt.Sprintf("Generate the handler for the %s service with real business logic. Keep it concise — no more than 200 lines.", svc.Name),
SystemPrompt: prompt,
})
sp.Stop()
if err != nil {
return err
}
code = firstNonEmpty(resp.Answer, resp.Reply)
code = extractCode(code)
}
if !strings.HasPrefix(strings.TrimSpace(code), "package") {
return fmt.Errorf("LLM did not return valid Go code")
}
writeFile(handlerFile, code)
recordHandlerHash(dir, handlerFile)
return nil
}
// compileFix tries to compile, and if it fails, sends the error to
// the LLM to fix. Up to maxAttempts iterations.
func compileFix(ctx context.Context, m ai.Model, dir, name string, maxAttempts int) error {
for attempt := 0; attempt < maxAttempts; attempt++ {
cmd := exec.Command("go", "build", "./...")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err == nil {
return nil // compiles!
}
if m == nil {
return fmt.Errorf("compile failed: %s", string(out))
}
// Read current handler
handlerPath := filepath.Join(dir, "handler", name+".go")
currentCode := readFile(handlerPath)
sp := startSpinner(fmt.Sprintf("fixing compile errors (attempt %d/%d)...", attempt+1, maxAttempts))
fixCtx, fixCancel := context.WithTimeout(ctx, 60*time.Second)
resp, fixErr := m.Generate(fixCtx, &ai.Request{
Prompt: fmt.Sprintf("This Go code has compile errors. Fix ALL of them and return the COMPLETE corrected file.\n\nErrors:\n%s\n\nCode:\n%s",
string(out), currentCode),
SystemPrompt: "You are a Go expert. Return ONLY the corrected Go code. No markdown, no explanation. Start with 'package handler'.",
})
fixCancel()
sp.Stop()
if fixErr != nil {
return fmt.Errorf("fix attempt failed: %w", fixErr)
}
fixed := firstNonEmpty(resp.Answer, resp.Reply)
fixed = extractCode(fixed)
if strings.HasPrefix(strings.TrimSpace(fixed), "package") && !isTruncated(fixed) {
writeFile(handlerPath, fixed)
}
}
// Final check
cmd := exec.Command("go", "build", "./...")
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("still fails after %d attempts: %s", maxAttempts, string(out))
}
return nil
}
func newModel(provider, apiKey, model string) ai.Model {
if provider == "" {
provider = ai.AutoDetectProvider("")
}
var opts []ai.Option
opts = append(opts, ai.WithAPIKey(apiKey))
if model != "" {
opts = append(opts, ai.WithModel(model))
}
return ai.New(provider, opts...)
}
func buildProto(dehyphen, titleName string, svc ServiceSpec) string {
var b strings.Builder
fmt.Fprintf(&b, "syntax = \"proto3\";\n\npackage %s;\n\noption go_package = \"./proto;%s\";\n\n", dehyphen, dehyphen)
fmt.Fprintf(&b, "service %s {\n", titleName)
for _, ep := range svc.Endpoints {
fmt.Fprintf(&b, "\trpc %s(%sRequest) returns (%sResponse) {}\n", ep.Name, ep.Name, ep.Name)
}
b.WriteString("}\n\n")
// Record message
fmt.Fprintf(&b, "message %sRecord {\n", titleName)
for i, f := range svc.Fields {
fmt.Fprintf(&b, "\t%s %s = %d; // %s\n", protoType(f.Type), f.Name, i+1, f.Description)
}
b.WriteString("}\n\n")
// Request/response for each endpoint
for _, ep := range svc.Endpoints {
switch ep.Name {
case "Create":
b.WriteString("message CreateRequest {\n")
n := 1
for _, f := range svc.Fields {
if f.Name == "id" || f.Name == "created" || f.Name == "updated" {
continue
}
fmt.Fprintf(&b, "\t%s %s = %d;\n", protoType(f.Type), f.Name, n)
n++
}
fmt.Fprintf(&b, "}\n\nmessage CreateResponse {\n\t%sRecord record = 1;\n}\n\n", titleName)
case "Read":
fmt.Fprintf(&b, "message ReadRequest {\n\tstring id = 1;\n}\n\nmessage ReadResponse {\n\t%sRecord record = 1;\n}\n\n", titleName)
case "Update":
b.WriteString("message UpdateRequest {\n\tstring id = 1;\n")
n := 2
for _, f := range svc.Fields {
if f.Name == "id" || f.Name == "created" || f.Name == "updated" {
continue
}
fmt.Fprintf(&b, "\t%s %s = %d;\n", protoType(f.Type), f.Name, n)
n++
}
fmt.Fprintf(&b, "}\n\nmessage UpdateResponse {\n\t%sRecord record = 1;\n}\n\n", titleName)
case "Delete":
b.WriteString("message DeleteRequest {\n\tstring id = 1;\n}\n\nmessage DeleteResponse {\n\tbool deleted = 1;\n}\n\n")
case "List":
fmt.Fprintf(&b, "message ListRequest {\n\tint64 limit = 1;\n\tint64 offset = 2;\n\tstring query = 3;\n}\n\nmessage ListResponse {\n\trepeated %sRecord records = 1;\n\tint64 total = 2;\n}\n\n", titleName)
default:
// Custom endpoint — use all fields as input, record as output
fmt.Fprintf(&b, "message %sRequest {\n", ep.Name)
n := 1
for _, f := range svc.Fields {
if f.Name == "created" || f.Name == "updated" {
continue
}
fmt.Fprintf(&b, "\t%s %s = %d;\n", protoType(f.Type), f.Name, n)
n++
}
fmt.Fprintf(&b, "}\n\nmessage %sResponse {\n\t%sRecord record = 1;\n\tstring message = 2;\n\tbool success = 3;\n}\n\n", ep.Name, titleName)
}
}
return b.String()
}
func buildMain(name, titleName string) string {
svcName := strings.TrimSuffix(name, "-service")
return fmt.Sprintf(`package main
import (
"%s/handler"
pb "%s/proto"
"go-micro.dev/v6"
"go-micro.dev/v6/gateway/mcp"
)
func main() {
service := micro.NewService("%s",
mcp.WithMCP(":0"),
)
service.Init()
pb.Register%sHandler(service.Server(), handler.New())
service.Run()
}
`, name, name, svcName, titleName)
}
func generateAgent(baseDir string, design *ServiceDesign, svcNames []string) error {
agentName := "agent"
agentDir := filepath.Join(baseDir, agentName)
if _, err := os.Stat(agentDir); err == nil {
return nil // already exists
}
_ = os.MkdirAll(agentDir, 0755)
// Build a description of all services for the agent prompt
var svcDescs []string
for _, svc := range design.Services {
var eps []string
for _, ep := range svc.Endpoints {
eps = append(eps, ep.Name)
}
svcDescs = append(svcDescs, fmt.Sprintf("- %s: %s (%s)", svc.Name, svc.Description, strings.Join(eps, ", ")))
}
prompt := fmt.Sprintf("You manage these services:\\n%s\\nUse the available tools to fulfill requests. Be helpful and concise.", strings.Join(svcDescs, "\\n"))
quoted := strings.Join(svcNames, `", "`)
writeFile(filepath.Join(agentDir, "main.go"), fmt.Sprintf(`package main
import (
"os"
"go-micro.dev/v6"
)
func main() {
agent := micro.NewAgent("agent",
micro.AgentServices("%s"),
micro.AgentPrompt("%s"),
micro.AgentProvider(os.Getenv("MICRO_AI_PROVIDER")),
micro.AgentAPIKey(os.Getenv("MICRO_AI_API_KEY")),
)
agent.Init()
agent.Run()
}
`, quoted, prompt))
writeFile(filepath.Join(agentDir, "go.mod"),
fmt.Sprintf("module %s\n\ngo 1.24\n\nrequire go-micro.dev/v6 %s\n", agentName, goMicroVersion))
_ = runIn(agentDir, "go", "mod", "tidy")
fmt.Printf(" \033[35m◆\033[0m agent \033[2m(manages: %s)\033[0m\n", strings.Join(svcNames, ", "))
return nil
}
func extractJSON(s string) string {
if i := strings.Index(s, "```json"); i >= 0 {
s = s[i+7:]
if j := strings.Index(s, "```"); j >= 0 {
return strings.TrimSpace(s[:j])
}
}
if i := strings.Index(s, "```"); i >= 0 {
s = s[i+3:]
if j := strings.Index(s, "```"); j >= 0 {
return strings.TrimSpace(s[:j])
}
}
if i := strings.Index(s, "{"); i >= 0 {
depth := 0
for j := i; j < len(s); j++ {
switch s[j] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return s[i : j+1]
}
}
}
}
return s
}
func extractCode(s string) string {
if i := strings.Index(s, "```go"); i >= 0 {
s = s[i+5:]
if j := strings.Index(s, "```"); j >= 0 {
return strings.TrimSpace(s[:j])
}
}
if i := strings.Index(s, "```"); i >= 0 {
s = s[i+3:]
if j := strings.Index(s, "```"); j >= 0 {
return strings.TrimSpace(s[:j])
}
}
// Try to find raw package declaration
if i := strings.Index(s, "package "); i >= 0 {
return strings.TrimSpace(s[i:])
}
return strings.TrimSpace(s)
}
func isTruncated(code string) bool {
trimmed := strings.TrimSpace(code)
if len(trimmed) == 0 {
return true
}
// Valid Go files end with a closing brace
if trimmed[len(trimmed)-1] != '}' {
return true
}
// Check balanced braces
depth := 0
for _, c := range trimmed {
switch c {
case '{':
depth++
case '}':
depth--
}
}
return depth != 0
}
func protoType(t string) string {
switch t {
case "int64":
return "int64"
case "int32":
return "int32"
case "bool":
return "bool"
case "float64":
return "double"
default:
return "string"
}
}
func toTitle(s string) string {
words := strings.FieldsFunc(s, func(r rune) bool { return r == '-' || r == '_' || r == ' ' })
for i, w := range words {
if len(w) > 0 {
words[i] = strings.ToUpper(w[:1]) + w[1:]
}
}
return strings.Join(words, "")
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func readFile(path string) string {
b, _ := os.ReadFile(path)
return string(b)
}
func writeFile(path, content string) {
_ = os.WriteFile(path, []byte(content), 0644)
}
func runIn(dir string, name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "PATH="+os.Getenv("PATH")+":"+os.Getenv("GOPATH")+"/bin:"+os.Getenv("HOME")+"/go/bin")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
type spinner struct {
msg string
stop chan struct{}
done sync.WaitGroup
}
func isTTY() bool {
fi, err := os.Stdout.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}
func startSpinner(msg string) *spinner {
s := &spinner{msg: msg, stop: make(chan struct{})}
if !isTTY() {
fmt.Printf(" %s\n", msg)
return s
}
s.done.Add(1)
go func() {
defer s.done.Done()
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
i := 0
t := time.NewTicker(100 * time.Millisecond)
defer t.Stop()
for {
select {
case <-s.stop:
fmt.Printf("\r\033[K")
return
case <-t.C:
fmt.Printf("\r %s %s", frames[i%len(frames)], msg)
i++
}
}
}()
return s
}
func (s *spinner) Stop() {
close(s.stop)
s.done.Wait()
}
func fileHash(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func metaPath(svcDir string) string {
return filepath.Join(svcDir, ".micro")
}
func readMeta(svcDir string) map[string]string {
m := make(map[string]string)
b, err := os.ReadFile(metaPath(svcDir))
if err != nil {
return m
}
_ = json.Unmarshal(b, &m)
return m
}
func writeMeta(svcDir string, m map[string]string) {
b, _ := json.MarshalIndent(m, "", " ")
_ = os.WriteFile(metaPath(svcDir), b, 0644)
}
func fileModified(svcDir, key, path string) bool {
meta := readMeta(svcDir)
savedHash, ok := meta[key]
if !ok {
return false
}
return fileHash(path) != savedHash
}
func recordFileHash(svcDir, key, path string) {
meta := readMeta(svcDir)
meta[key] = fileHash(path)
writeMeta(svcDir, meta)
}
func handlerModified(svcDir, handlerFile string) bool {
return fileModified(svcDir, "handler_hash", handlerFile)
}
func recordHandlerHash(svcDir, handlerFile string) {
recordFileHash(svcDir, "handler_hash", handlerFile)
}
+418
View File
@@ -0,0 +1,418 @@
package generate
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestToTitle(t *testing.T) {
tests := []struct {
in, want string
}{
{"order-service", "OrderService"},
{"task", "Task"},
{"inventory_item", "InventoryItem"},
{"hello world", "HelloWorld"},
{"a-b-c", "ABC"},
{"already", "Already"},
}
for _, tt := range tests {
if got := toTitle(tt.in); got != tt.want {
t.Errorf("toTitle(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestProtoType(t *testing.T) {
tests := []struct {
in, want string
}{
{"string", "string"},
{"int64", "int64"},
{"int32", "int32"},
{"bool", "bool"},
{"float64", "double"},
{"unknown", "string"},
{"", "string"},
}
for _, tt := range tests {
if got := protoType(tt.in); got != tt.want {
t.Errorf("protoType(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestFirstNonEmpty(t *testing.T) {
if got := firstNonEmpty("", "", "c"); got != "c" {
t.Errorf("got %q, want %q", got, "c")
}
if got := firstNonEmpty("a", "b"); got != "a" {
t.Errorf("got %q, want %q", got, "a")
}
if got := firstNonEmpty("", ""); got != "" {
t.Errorf("got %q, want %q", got, "")
}
}
func TestExtractJSON(t *testing.T) {
tests := []struct {
name, in, want string
}{
{
"fenced json",
"Here's the design:\n```json\n{\"services\": []}\n```\nDone.",
`{"services": []}`,
},
{
"fenced no lang",
"```\n{\"a\": 1}\n```",
`{"a": 1}`,
},
{
"raw json",
`some text {"key": "val"} trailing`,
`{"key": "val"}`,
},
{
"nested braces",
`{"a": {"b": 1}}`,
`{"a": {"b": 1}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractJSON(tt.in)
if got != tt.want {
t.Errorf("extractJSON() = %q, want %q", got, tt.want)
}
})
}
}
func TestExtractCode(t *testing.T) {
tests := []struct {
name, in string
wantPrefix string
}{
{
"go fence",
"Here:\n```go\npackage handler\n\nfunc Foo() {}\n```\nDone.",
"package handler",
},
{
"generic fence",
"```\npackage main\n```",
"package main",
},
{
"raw code",
"Sure, here's the code:\npackage handler\n\ntype X struct{}",
"package handler",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractCode(tt.in)
if !strings.HasPrefix(got, tt.wantPrefix) {
t.Errorf("extractCode() = %q, want prefix %q", got, tt.wantPrefix)
}
})
}
}
func TestBuildProto(t *testing.T) {
svc := ServiceSpec{
Name: "task-service",
Description: "Manages tasks",
Fields: []FieldSpec{
{Name: "id", Type: "string", Description: "Task ID"},
{Name: "title", Type: "string", Description: "Task title"},
{Name: "done", Type: "bool", Description: "Completion status"},
{Name: "created", Type: "int64", Description: "Created timestamp"},
{Name: "updated", Type: "int64", Description: "Updated timestamp"},
},
Endpoints: []EndpointSpec{
{Name: "Create", Description: "Create a task"},
{Name: "Read", Description: "Get a task"},
{Name: "Update", Description: "Update a task"},
{Name: "Delete", Description: "Delete a task"},
{Name: "List", Description: "List tasks"},
{Name: "ToggleComplete", Description: "Toggle completion"},
},
}
proto := buildProto("taskservice", "TaskService", svc)
checks := []string{
`syntax = "proto3"`,
`package taskservice`,
`service TaskService`,
`rpc Create(CreateRequest) returns (CreateResponse)`,
`rpc ToggleComplete(ToggleCompleteRequest) returns (ToggleCompleteResponse)`,
`message TaskServiceRecord`,
`string title = 2`,
`bool done = 3`,
`message CreateRequest`,
`message ReadRequest`,
`message DeleteRequest`,
`message ListRequest`,
`message ToggleCompleteRequest`,
}
for _, c := range checks {
if !strings.Contains(proto, c) {
t.Errorf("buildProto() missing %q", c)
}
}
// Create should not include id, created, updated
createIdx := strings.Index(proto, "message CreateRequest")
createEnd := strings.Index(proto[createIdx:], "}")
createBlock := proto[createIdx : createIdx+createEnd]
for _, skip := range []string{"string id", "int64 created", "int64 updated"} {
if strings.Contains(createBlock, skip) {
t.Errorf("CreateRequest should not contain %q", skip)
}
}
}
func TestBuildMain(t *testing.T) {
// New naming: no -service suffix
main := buildMain("order", "Order")
checks := []string{
`"order/handler"`,
`pb "order/proto"`,
`micro.NewService("order"`,
`pb.RegisterOrderHandler`,
`handler.New()`,
}
for _, c := range checks {
if !strings.Contains(main, c) {
t.Errorf("buildMain(order) missing %q", c)
}
}
// Legacy naming: -service suffix stripped
main = buildMain("order-service", "OrderService")
checks = []string{
`"order-service/handler"`,
`pb "order-service/proto"`,
`micro.NewService("order"`,
`pb.RegisterOrderServiceHandler`,
`handler.New()`,
}
for _, c := range checks {
if !strings.Contains(main, c) {
t.Errorf("buildMain() missing %q", c)
}
}
}
func TestHandlerModifiedTracking(t *testing.T) {
dir := t.TempDir()
handlerDir := filepath.Join(dir, "handler")
os.MkdirAll(handlerDir, 0755)
handlerFile := filepath.Join(handlerDir, "test.go")
// No .micro file → not modified
os.WriteFile(handlerFile, []byte("package handler\n"), 0644)
if handlerModified(dir, handlerFile) {
t.Error("expected not modified when no .micro exists")
}
// Record hash → not modified
recordHandlerHash(dir, handlerFile)
if handlerModified(dir, handlerFile) {
t.Error("expected not modified after recording hash")
}
// Edit the file → modified
os.WriteFile(handlerFile, []byte("package handler\n\nfunc Foo() {}\n"), 0644)
if !handlerModified(dir, handlerFile) {
t.Error("expected modified after editing file")
}
// Re-record → not modified again
recordHandlerHash(dir, handlerFile)
if handlerModified(dir, handlerFile) {
t.Error("expected not modified after re-recording hash")
}
}
func TestMetaReadWrite(t *testing.T) {
dir := t.TempDir()
m := readMeta(dir)
if len(m) != 0 {
t.Error("expected empty meta for new dir")
}
m["handler_hash"] = "abc123"
m["version"] = "1"
writeMeta(dir, m)
m2 := readMeta(dir)
if m2["handler_hash"] != "abc123" || m2["version"] != "1" {
t.Errorf("readMeta() = %v, want handler_hash=abc123, version=1", m2)
}
}
func TestGenerateStructure(t *testing.T) {
dir := t.TempDir()
svcDir := filepath.Join(dir, "test-svc")
svc := ServiceSpec{
Name: "test-svc",
Description: "Test service",
Fields: []FieldSpec{
{Name: "id", Type: "string"},
{Name: "name", Type: "string"},
},
Endpoints: []EndpointSpec{
{Name: "Create"},
{Name: "Read"},
},
}
if err := generateStructure(svcDir, svc); err != nil {
t.Fatal(err)
}
// Check files exist
for _, f := range []string{
"proto/test-svc.proto",
"handler/test-svc.go",
"main.go",
"go.mod",
"Makefile",
".gitignore",
} {
if _, err := os.Stat(filepath.Join(svcDir, f)); err != nil {
t.Errorf("missing %s: %v", f, err)
}
}
// Check .micro was created with handler hash
meta := readMeta(svcDir)
if meta["handler_hash"] == "" {
t.Error("expected handler_hash in .micro after generateStructure")
}
// Run again — should not overwrite main.go
mainBefore, _ := os.ReadFile(filepath.Join(svcDir, "main.go"))
os.WriteFile(filepath.Join(svcDir, "main.go"), []byte("// user edited\n"), 0644)
if err := generateStructure(svcDir, svc); err != nil {
t.Fatal(err)
}
mainAfter, _ := os.ReadFile(filepath.Join(svcDir, "main.go"))
if string(mainAfter) == string(mainBefore) {
t.Error("expected main.go to keep user edit on re-run")
}
// Proto should be protected if user modified it
protoFile := filepath.Join(svcDir, "proto", "test-svc.proto")
os.WriteFile(protoFile, []byte("// user-edited proto\n"), 0644)
if err := generateStructure(svcDir, svc); err != nil {
t.Fatal(err)
}
protoAfter, _ := os.ReadFile(protoFile)
if string(protoAfter) != "// user-edited proto\n" {
t.Error("expected proto to be preserved after user edit")
}
// Proto should regenerate if NOT modified
recordFileHash(svcDir, "proto_hash", protoFile)
if err := generateStructure(svcDir, svc); err != nil {
t.Fatal(err)
}
protoAfter2, _ := os.ReadFile(protoFile)
_ = protoAfter2
}
func TestFileModified(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "test.txt")
os.WriteFile(f, []byte("original"), 0644)
// No hash → not modified
if fileModified(dir, "test_hash", f) {
t.Error("expected not modified with no saved hash")
}
recordFileHash(dir, "test_hash", f)
// Same content → not modified
if fileModified(dir, "test_hash", f) {
t.Error("expected not modified with matching hash")
}
// Changed content → modified
os.WriteFile(f, []byte("changed"), 0644)
if !fileModified(dir, "test_hash", f) {
t.Error("expected modified after content change")
}
}
func TestDiscoverExisting(t *testing.T) {
dir := t.TempDir()
// Empty directory → empty string
if got := discoverExisting(dir); got != "" {
t.Errorf("expected empty for empty dir, got %q", got)
}
// Non-service directory (no proto) → empty
os.MkdirAll(filepath.Join(dir, "not-a-service"), 0755)
if got := discoverExisting(dir); got != "" {
t.Errorf("expected empty for dir without proto, got %q", got)
}
// Create a real service directory with proto
svcDir := filepath.Join(dir, "order-service")
os.MkdirAll(filepath.Join(svcDir, "proto"), 0755)
os.WriteFile(filepath.Join(svcDir, "proto", "order-service.proto"),
[]byte("syntax = \"proto3\";\nservice OrderService {}"), 0644)
got := discoverExisting(dir)
if !strings.Contains(got, "order-service") {
t.Errorf("expected to find order-service, got %q", got)
}
if !strings.Contains(got, "OrderService") {
t.Errorf("expected to find proto content, got %q", got)
}
// Add a second service
svc2Dir := filepath.Join(dir, "user-service")
os.MkdirAll(filepath.Join(svc2Dir, "proto"), 0755)
os.WriteFile(filepath.Join(svc2Dir, "proto", "user-service.proto"),
[]byte("syntax = \"proto3\";\nservice UserService {}"), 0644)
got = discoverExisting(dir)
if !strings.Contains(got, "order-service") || !strings.Contains(got, "user-service") {
t.Errorf("expected both services, got %q", got)
}
}
func TestIsTruncated(t *testing.T) {
tests := []struct {
name string
code string
want bool
}{
{"complete", "package handler\n\nfunc New() *H { return &H{} }\n", false},
{"empty", "", true},
{"no closing brace", "package handler\n\nfunc Foo() {", true},
{"unbalanced", "package handler\n\nfunc Foo() {\n\tif true {", true},
{"balanced", "package handler\n\nfunc Foo() {\n\tif true {\n\t}\n}", false},
{"trailing whitespace ok", "package handler\n\ntype X struct{}\n\n", false},
{"mid-expression", "package handler\n\nfunc F() {\n\tx := 1 +", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isTruncated(tt.code); got != tt.want {
t.Errorf("isTruncated() = %v, want %v", got, tt.want)
}
})
}
}
+269
View File
@@ -0,0 +1,269 @@
// Package initcmd provides the micro init command for server setup
package initcmd
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"runtime"
"strings"
"github.com/urfave/cli/v2"
"go-micro.dev/v6/cmd"
)
const systemdTemplate = `[Unit]
Description=Micro service: %%i
After=network.target
[Service]
Type=simple
User=%s
Group=%s
WorkingDirectory=%s
ExecStart=%s/bin/%%i
Restart=on-failure
RestartSec=5
EnvironmentFile=-%s/config/%%i.env
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=micro-%%i
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=%s/data
[Install]
WantedBy=multi-user.target
`
// Init initializes a server to receive micro deployments
func Init(c *cli.Context) error {
if !c.Bool("server") {
return fmt.Errorf("usage: micro init --server\n\nInitialize this machine to receive micro deployments")
}
// Check if we're on Linux
if runtime.GOOS != "linux" {
return fmt.Errorf("micro init --server is only supported on Linux")
}
// Check for remote init
remoteHost := c.String("remote")
if remoteHost != "" {
return initRemote(c, remoteHost)
}
basePath := c.String("path")
userName := c.String("user")
fmt.Println("Initializing micro server...")
fmt.Println()
// Check if running as root (needed for systemd and creating users)
if os.Geteuid() != 0 {
return fmt.Errorf(`micro init --server requires root privileges.
Run with sudo:
sudo micro init --server`)
}
// Create user if needed
if userName == "micro" {
if err := createMicroUser(); err != nil {
return err
}
}
// Create directories
fmt.Println("Creating directories:")
dirs := []string{
filepath.Join(basePath, "bin"),
filepath.Join(basePath, "data"),
filepath.Join(basePath, "config"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create %s: %w", dir, err)
}
fmt.Printf(" ✓ %s\n", dir)
}
// Set ownership
if userName != "root" {
u, err := user.Lookup(userName)
if err != nil {
return fmt.Errorf("user %s not found: %w", userName, err)
}
// chown -R user:user /opt/micro
chownCmd := exec.Command("chown", "-R", fmt.Sprintf("%s:%s", u.Username, u.Username), basePath)
if err := chownCmd.Run(); err != nil {
return fmt.Errorf("failed to set ownership: %w", err)
}
}
fmt.Println()
// Create systemd template
fmt.Println("Creating systemd template:")
unitContent := fmt.Sprintf(systemdTemplate, userName, userName, basePath, basePath, basePath, basePath)
unitPath := "/etc/systemd/system/micro@.service"
if err := os.WriteFile(unitPath, []byte(unitContent), 0644); err != nil {
return fmt.Errorf("failed to write systemd unit: %w", err)
}
fmt.Printf(" ✓ %s\n", unitPath)
// Reload systemd
reloadCmd := exec.Command("systemctl", "daemon-reload")
if err := reloadCmd.Run(); err != nil {
return fmt.Errorf("failed to reload systemd: %w", err)
}
fmt.Println(" ✓ systemd daemon-reload")
// Write marker file so deploy can detect initialization
markerPath := filepath.Join(basePath, ".micro-initialized")
if err := os.WriteFile(markerPath, []byte("1\n"), 0644); err != nil {
return fmt.Errorf("failed to write marker: %w", err)
}
fmt.Println()
fmt.Println("Server ready!")
fmt.Println()
fmt.Println(" Deploy from your machine:")
fmt.Printf(" micro deploy user@%s\n", getHostname())
fmt.Println()
fmt.Println(" Manage services:")
fmt.Println(" sudo systemctl status micro@myservice")
fmt.Println(" sudo journalctl -u micro@myservice -f")
fmt.Println()
return nil
}
func createMicroUser() error {
// Check if user exists
if _, err := user.Lookup("micro"); err == nil {
return nil // user already exists
}
fmt.Println("Creating micro user:")
createCmd := exec.Command("useradd", "--system", "--no-create-home", "--shell", "/bin/false", "micro")
if err := createCmd.Run(); err != nil {
// Check if it's just because user already exists
if _, lookupErr := user.Lookup("micro"); lookupErr == nil {
return nil
}
return fmt.Errorf("failed to create micro user: %w", err)
}
fmt.Println(" ✓ Created user 'micro'")
return nil
}
func initRemote(c *cli.Context, host string) error {
fmt.Printf("Initializing micro on %s...\n\n", host)
// Check SSH connectivity first
if err := checkSSH(host); err != nil {
return err
}
basePath := c.String("path")
userName := c.String("user")
// Run micro init --server on remote
initCmd := fmt.Sprintf("sudo micro init --server --path %s --user %s", basePath, userName)
sshCmd := exec.Command("ssh", host, initCmd)
sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr
if err := sshCmd.Run(); err != nil {
return fmt.Errorf("remote init failed: %w", err)
}
return nil
}
func checkSSH(host string) error {
// Quick SSH test
testCmd := exec.Command("ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", host, "echo ok")
output, err := testCmd.CombinedOutput()
if err != nil {
return fmt.Errorf(`✗ Cannot connect to %s
SSH connection failed. Check that:
• The server is reachable: ping %s
• SSH is configured: ssh %s
• Your key is added: ssh-add -l
Common fixes:
• Add SSH key: ssh-copy-id %s
• Check hostname in ~/.ssh/config
Error: %s`, host, host, host, host, strings.TrimSpace(string(output)))
}
return nil
}
func getHostname() string {
name, err := os.Hostname()
if err != nil {
return "this-server"
}
return name
}
func init() {
cmd.Register(&cli.Command{
Name: "init",
Usage: "Initialize micro for development or server deployment",
Description: `Initialize micro on a server to receive deployments.
Server setup:
sudo micro init --server
This creates:
• /opt/micro/bin/ - service binaries
• /opt/micro/data/ - persistent data
• /opt/micro/config/ - environment files
• systemd template for managing services
Remote setup:
micro init --server --remote user@host
After init, deploy with:
micro deploy user@host`,
Action: Init,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "server",
Usage: "Initialize as a deployment server",
},
&cli.StringFlag{
Name: "path",
Usage: "Base path for micro (default: /opt/micro)",
Value: "/opt/micro",
},
&cli.StringFlag{
Name: "user",
Usage: "User to run services as (default: micro)",
Value: "micro",
},
&cli.StringFlag{
Name: "remote",
Usage: "Initialize a remote server via SSH",
},
},
})
}
+240
View File
@@ -0,0 +1,240 @@
package new
import (
"bytes"
"errors"
"flag"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/urfave/cli/v2"
)
// TestZeroToOneContract locks the documented getting-started path:
// `micro new helloworld` must produce an ordinary Go service that the Go
// toolchain can build, run long enough to start, and call through its generated
// handler. The generated module is pointed back at this checkout so the
// contract stays local and deterministic in CI.
//
// It shells out to `micro new` (which runs `go mod tidy`) and `go build`, so
// it needs the Go toolchain and module access; it is skipped under `-short`.
func TestZeroToOneContract(t *testing.T) {
generated := generateService(t, "helloworld")
for _, rel := range []string{"go.mod", "main.go", "handler/helloworld.go", "README.md", "Makefile"} {
if _, err := os.Stat(filepath.Join(generated.dir, rel)); err != nil {
t.Fatalf("generated file %s: %v", rel, err)
}
}
generated.assertLocalModule(t)
generated.build(t)
generated.run(t)
generated.call(t, "Alice", "Hello Alice")
}
// TestZeroToOneNoMCPContract keeps the MCP opt-out path honest. Some services
// intentionally run without the local MCP listener, but that variant must still
// satisfy the same 0→1 contract: scaffold, tidy, and build without additional
// toolchain dependencies.
func TestZeroToOneNoMCPContract(t *testing.T) {
generated := generateService(t, "worker", "--no-mcp")
main, err := os.ReadFile(filepath.Join(generated.dir, "main.go"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(main), "gateway/mcp") || strings.Contains(string(main), "WithMCP") {
t.Fatalf("--no-mcp generated main.go with MCP wiring:\n%s", main)
}
generated.assertLocalModule(t)
generated.build(t)
generated.run(t)
generated.call(t, "Bob", "Hello Bob")
}
func TestPrintNextStepsSurfacesFirstAgentPath(t *testing.T) {
var out bytes.Buffer
printNextSteps(&out, "helloworld", false)
for _, want := range []string{
"cd helloworld",
"micro agent preflight",
"go run .",
"micro chat",
"micro inspect agent <name>",
"micro agent demo",
"micro docs",
"your-first-agent.html",
"zero-to-hero.html",
"http://localhost:3001/mcp/tools",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("next steps missing %q:\n%s", want, out.String())
}
}
}
func TestPrintNextStepsNoMCPSkipsMCPHints(t *testing.T) {
var out bytes.Buffer
printNextSteps(&out, "worker", true)
for _, want := range []string{"micro agent preflight", "micro chat", "micro inspect agent <name>", "micro agent demo", "micro docs"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("--no-mcp next steps missing %q:\n%s", want, out.String())
}
}
for _, notWant := range []string{"http://localhost:3001/mcp/tools", "micro mcp serve"} {
if strings.Contains(out.String(), notWant) {
t.Fatalf("--no-mcp next steps should not include %q:\n%s", notWant, out.String())
}
}
}
type generatedService struct {
dir string
repoRoot string
}
func generateService(t *testing.T, name string, args ...string) generatedService {
t.Helper()
if testing.Short() {
t.Skip("contract test shells out to the Go toolchain; skipped with -short")
}
repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..", ".."))
if err != nil {
t.Fatal(err)
}
t.Setenv("MICRO_NEW_GO_MICRO_REPLACE", repoRoot)
tmp := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(tmp); err != nil {
t.Fatal(err)
}
defer os.Chdir(oldwd)
set := flag.NewFlagSet("micro-new", flag.ContinueOnError)
set.Bool("no-mcp", false, "")
set.Bool("proto", false, "")
set.String("template", "", "")
set.String("prompt", "", "")
set.String("provider", "", "")
set.String("api_key", "", "")
if err := set.Parse(append(args, name)); err != nil {
t.Fatal(err)
}
ctx := cli.NewContext(cli.NewApp(), set, nil)
if err := Run(ctx); err != nil {
t.Fatalf("micro new %s %s: %v", strings.Join(args, " "), name, err)
}
return generatedService{dir: filepath.Join(tmp, name), repoRoot: repoRoot}
}
func (g generatedService) assertLocalModule(t *testing.T) {
t.Helper()
modPath := filepath.Join(g.dir, "go.mod")
mod, err := os.ReadFile(modPath)
if err != nil {
t.Fatal(err)
}
want := "replace go-micro.dev/v6 => " + filepath.ToSlash(g.repoRoot)
if !strings.Contains(string(mod), want) {
t.Fatalf("generated go.mod missing local replace %q:\n%s", want, mod)
}
}
func (g generatedService) build(t *testing.T) {
t.Helper()
cmd := exec.Command("go", "build", "./...")
cmd.Dir = g.dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("generated service go build ./... failed: %v\n%s", err, out)
}
}
func (g generatedService) run(t *testing.T) {
t.Helper()
bin := filepath.Join(g.dir, "service-contract")
build := exec.Command("go", "build", "-o", bin, ".")
build.Dir = g.dir
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("generated service go build -o service-contract . failed: %v\n%s", err, out)
}
cmd := exec.Command(bin)
cmd.Dir = g.dir
var out strings.Builder
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Start(); err != nil {
t.Fatalf("generated service failed to start: %v\n%s", err, out.String())
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
t.Fatalf("generated service exited early: %v\n%s", err, out.String())
case <-time.After(2 * time.Second):
}
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
t.Fatalf("failed to stop generated service: %v\n%s", err, out.String())
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("generated service did not stop after kill\n%s", out.String())
}
}
func (g generatedService) call(t *testing.T, name, want string) {
t.Helper()
testPath := filepath.Join(g.dir, "handler", "contract_test.go")
testSrc := `package handler
import (
"context"
"testing"
)
func TestGeneratedCallContract(t *testing.T) {
rsp := new(Response)
if err := New().Call(context.Background(), &Request{Name: "` + name + `"}, rsp); err != nil {
t.Fatal(err)
}
if rsp.Msg != "` + want + `" {
t.Fatalf("Call response = %q, want %q", rsp.Msg, "` + want + `")
}
}
`
if err := os.WriteFile(testPath, []byte(testSrc), 0644); err != nil {
t.Fatal(err)
}
cmd := exec.Command("go", "test", "./handler", "-run", "TestGeneratedCallContract", "-count=1")
cmd.Dir = g.dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("generated service call contract failed: %v\n%s", err, out)
}
}
+485
View File
@@ -0,0 +1,485 @@
// Package new generates micro service templates
package new
import (
"bufio"
"context"
"fmt"
"go/build"
"io"
"os"
"os/exec"
"os/signal"
"path"
"path/filepath"
"runtime"
"runtime/debug"
"strings"
"syscall"
"go-micro.dev/v6/cmd/micro/cli/generate"
"text/template"
"time"
"github.com/urfave/cli/v2"
"github.com/xlab/treeprint"
tmpl "go-micro.dev/v6/cmd/micro/cli/new/template"
)
type config struct {
// foo
Alias string
// github.com/micro/foo
Dir string
// $GOPATH/src/github.com/micro/foo
GoDir string
// $GOPATH
GoPath string
// UseGoPath
UseGoPath bool
// MicroVersion is the go-micro version to require in go.mod
MicroVersion string
// MicroReplace optionally points generated services at a local go-micro checkout.
MicroReplace string
// Files
Files []file
// Comments
Comments []string
}
// microVersion returns the go-micro version this CLI was built from, so a
// generated service requires the same framework version the user is running.
// Falls back to "latest" for local/dev builds (resolved by 'go mod tidy').
func microVersion() string {
bi, ok := debug.ReadBuildInfo()
if !ok {
return "latest"
}
isRelease := func(v string) bool {
return strings.HasPrefix(v, "v") && !strings.Contains(v, "devel")
}
// cmd/micro is part of the go-micro.dev/v6 module, so for an installed
// binary the main module version is the framework version.
if bi.Main.Path == "go-micro.dev/v6" && isRelease(bi.Main.Version) {
return bi.Main.Version
}
for _, dep := range bi.Deps {
if dep.Path == "go-micro.dev/v6" && isRelease(dep.Version) {
return dep.Version
}
}
return "latest"
}
func microReplace() string {
return filepath.ToSlash(os.Getenv("MICRO_NEW_GO_MICRO_REPLACE"))
}
type file struct {
Path string
Tmpl string
}
func write(c config, file, tmpl string) error {
fn := template.FuncMap{
"title": func(s string) string {
return strings.ReplaceAll(strings.Title(s), "-", "")
},
"dehyphen": func(s string) string {
return strings.ReplaceAll(s, "-", "")
},
"lower": func(s string) string {
return strings.ToLower(s)
},
}
f, err := os.Create(file)
if err != nil {
return err
}
defer f.Close()
t, err := template.New("f").Funcs(fn).Parse(tmpl)
if err != nil {
return err
}
return t.Execute(f, c)
}
func create(c config) error {
// check if dir exists
if _, err := os.Stat(c.Dir); !os.IsNotExist(err) {
return fmt.Errorf("%s already exists", c.Dir)
}
fmt.Println()
fmt.Println(" \033[1mmicro new\033[0m")
fmt.Println()
fmt.Printf(" Creating \033[36m%s\033[0m\n\n", c.Alias)
t := treeprint.New()
// write the files
for _, file := range c.Files {
f := filepath.Join(c.Dir, file.Path)
dir := filepath.Dir(f)
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
}
addFileToTree(t, file.Path)
if err := write(c, f, file.Tmpl); err != nil {
return err
}
}
// print tree
fmt.Println(t.String())
for _, comment := range c.Comments {
fmt.Println(comment)
}
// just wait
<-time.After(time.Millisecond * 250)
return nil
}
func addFileToTree(root treeprint.Tree, file string) {
split := strings.Split(file, "/")
curr := root
for i := 0; i < len(split)-1; i++ {
n := curr.FindByValue(split[i])
if n != nil {
curr = n
} else {
curr = curr.AddBranch(split[i])
}
}
if curr.FindByValue(split[len(split)-1]) == nil {
curr.AddNode(split[len(split)-1])
}
}
func Run(ctx *cli.Context) error {
// Handle --prompt: design services with AI, then generate each one
if prompt := ctx.String("prompt"); prompt != "" {
return runPrompt(ctx, prompt)
}
dir := ctx.Args().First()
if len(dir) == 0 {
fmt.Println("specify service name")
return nil
}
// check if the path is absolute, we don't want this
// we want to a relative path so we can install in GOPATH
if path.IsAbs(dir) {
fmt.Println("require relative path as service will be installed in GOPATH")
return nil
}
var goPath string
var goDir string
goPath = build.Default.GOPATH
// don't know GOPATH, runaway....
if len(goPath) == 0 {
fmt.Println("unknown GOPATH")
return nil
}
// attempt to split path if not windows
if runtime.GOOS == "windows" {
goPath = strings.Split(goPath, ";")[0]
} else {
goPath = strings.Split(goPath, ":")[0]
}
goDir = filepath.Join(goPath, "src", path.Clean(dir))
noMCP := ctx.Bool("no-mcp")
templateName := ctx.String("template")
// The default template is protoless: handlers are registered by
// reflection, so the service builds and runs with no protoc toolchain.
// --proto opts into Protocol Buffers; the named templates (crud, pubsub,
// api) are proto-based and imply it.
useProto := ctx.Bool("proto") || (templateName != "" && templateName != "default")
c := config{
Alias: dir,
Comments: nil,
Dir: dir,
GoDir: goDir,
GoPath: goPath,
UseGoPath: false,
MicroVersion: microVersion(),
MicroReplace: microReplace(),
}
if useProto {
mainTmpl, handlerTmpl, protoTmpl := selectTemplates(templateName, noMCP)
c.Files = []file{
{"main.go", mainTmpl},
{"handler/" + dir + ".go", handlerTmpl},
{"proto/" + dir + ".proto", protoTmpl},
{"Makefile", tmpl.Makefile},
{"README.md", tmpl.Readme},
{".gitignore", tmpl.GitIgnore},
}
} else {
mainTmpl := tmpl.MainNoProto
if noMCP {
mainTmpl = tmpl.MainNoProtoNoMCP
}
c.Files = []file{
{"main.go", mainTmpl},
{"handler/" + dir + ".go", tmpl.HandlerNoProto},
{"Makefile", tmpl.MakefileNoProto},
{"README.md", tmpl.ReadmeNoProto},
{".gitignore", tmpl.GitIgnore},
}
}
// set gomodule
if os.Getenv("GO111MODULE") != "off" {
mod := tmpl.ModuleNoProto
if useProto {
mod = tmpl.Module
}
c.Files = append(c.Files, file{"go.mod", mod})
}
// create the files
if err := create(c); err != nil {
return err
}
// Resolve dependencies.
fmt.Println("\nRunning 'go mod tidy'...")
if err := runInDir(dir, "go mod tidy"); err != nil {
fmt.Printf("Error running 'go mod tidy': %v\n", err)
}
// Generate protobuf code only when the proto workflow is used, and only
// when the toolchain is present. Otherwise print install instructions
// rather than failing with a cryptic error.
if useProto {
if missing := missingProtoTools(); len(missing) > 0 {
printProtoInstall(dir, missing)
} else {
fmt.Println("Running 'make proto'...")
if err := runInDir(dir, "make proto"); err != nil {
fmt.Printf("Error running 'make proto': %v\n", err)
}
}
}
// Print updated tree including generated files
fmt.Println("\nProject structure:")
printTree(dir)
fmt.Println()
fmt.Printf(" \033[32m✓\033[0m Service \033[36m%s\033[0m created\n\n", dir)
printNextSteps(os.Stdout, dir, noMCP)
return nil
}
func printNextSteps(w io.Writer, dir string, noMCP bool) {
fmt.Fprintln(w, " Next steps:")
fmt.Fprintf(w, " cd %s\n", dir)
fmt.Fprintln(w, " micro agent preflight")
fmt.Fprintln(w, " go run .")
fmt.Fprintln(w, " micro chat")
fmt.Fprintln(w, " micro inspect agent <name>")
fmt.Fprintln(w)
fmt.Fprintln(w, " First-agent path:")
fmt.Fprintln(w, " micro agent demo")
fmt.Fprintln(w, " micro docs")
fmt.Fprintln(w, " https://go-micro.dev/docs/guides/your-first-agent.html")
fmt.Fprintln(w, " https://go-micro.dev/docs/guides/zero-to-hero.html")
if !noMCP {
fmt.Fprintln(w)
fmt.Fprintf(w, " MCP tools \033[36mhttp://localhost:3001/mcp/tools\033[0m\n")
fmt.Fprintln(w, " Claude Code \033[2mmicro mcp serve\033[0m")
}
fmt.Fprintln(w)
}
func selectTemplates(name string, noMCP bool) (mainTmpl, handlerTmpl, protoTmpl string) {
switch name {
case "crud":
if noMCP {
mainTmpl = tmpl.MainSRVNoMCP
} else {
mainTmpl = tmpl.MainSRV
}
return mainTmpl, tmpl.CrudHandlerSRV, tmpl.CrudProtoSRV
case "pubsub":
if noMCP {
mainTmpl = tmpl.PubsubMainSRVNoMCP
} else {
mainTmpl = tmpl.PubsubMainSRV
}
return mainTmpl, tmpl.PubsubHandlerSRV, tmpl.PubsubProtoSRV
case "api":
if noMCP {
mainTmpl = tmpl.MainSRVNoMCP
} else {
mainTmpl = tmpl.MainSRV
}
return mainTmpl, tmpl.ApiHandlerSRV, tmpl.ApiProtoSRV
default:
if noMCP {
mainTmpl = tmpl.MainSRVNoMCP
} else {
mainTmpl = tmpl.MainSRV
}
return mainTmpl, tmpl.HandlerSRV, tmpl.ProtoSRV
}
}
// missingProtoTools returns the protobuf tools needed by `make proto` that
// are not on the PATH.
func missingProtoTools() []string {
var missing []string
for _, tool := range []string{"protoc", "protoc-gen-go", "protoc-gen-micro"} {
if _, err := exec.LookPath(tool); err != nil {
missing = append(missing, tool)
}
}
return missing
}
// printProtoInstall tells the user exactly what to install to generate the
// protobuf code, instead of failing with a cryptic plugin error.
func printProtoInstall(dir string, missing []string) {
fmt.Println()
fmt.Printf(" \033[33m!\033[0m This service uses Protocol Buffers, but these tools are missing: %s\n", strings.Join(missing, ", "))
fmt.Println()
fmt.Println(" Install them:")
fmt.Println(" protoc https://github.com/protocolbuffers/protobuf/releases (or via your package manager)")
fmt.Println(" protoc-gen-go go install google.golang.org/protobuf/cmd/protoc-gen-go@latest")
fmt.Println(" protoc-gen-micro go install go-micro.dev/v6/cmd/protoc-gen-micro@latest")
fmt.Println()
fmt.Printf(" Then generate the code:\n cd %s && make proto && go run .\n", dir)
fmt.Println()
}
func runInDir(dir, cmd string) error {
parts := strings.Fields(cmd)
c := exec.Command(parts[0], parts[1:]...)
c.Dir = dir
c.Stdout = os.Stdout
c.Stderr = os.Stderr
return c.Run()
}
func printTree(dir string) {
t := treeprint.New()
walk := func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(dir, path)
if rel == "." {
return nil
}
parts := strings.Split(rel, string(os.PathSeparator))
curr := t
for i := 0; i < len(parts)-1; i++ {
n := curr.FindByValue(parts[i])
if n != nil {
curr = n
} else {
curr = curr.AddBranch(parts[i])
}
}
if !info.IsDir() {
curr.AddNode(parts[len(parts)-1])
}
return nil
}
_ = filepath.Walk(dir, walk)
fmt.Println(t.String())
}
func runPrompt(cliCtx *cli.Context, prompt string) error {
provider := cliCtx.String("provider")
apiKey := cliCtx.String("api_key")
if apiKey == "" {
// Try provider-specific env vars
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 new --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()
fmt.Println(" \033[32m✓\033[0m All services generated")
fmt.Println()
fmt.Println(" Next steps:")
fmt.Println(" micro run \033[2m# start all services\033[0m")
fmt.Println(" micro chat --provider anthropic \033[2m# talk to them\033[0m")
fmt.Println()
return nil
}
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"
}
+122
View File
@@ -0,0 +1,122 @@
package template
var (
ApiProtoSRV = `syntax = "proto3";
package {{dehyphen .Alias}};
option go_package = "./proto;{{dehyphen .Alias}}";
service {{title .Alias}} {
rpc Health(HealthRequest) returns (HealthResponse) {}
rpc Endpoint(EndpointRequest) returns (EndpointResponse) {}
}
message HealthRequest {}
message HealthResponse {
string status = 1;
int64 uptime = 2;
}
message EndpointRequest {
string method = 1;
string path = 2;
string body = 3;
map<string, string> headers = 4;
}
message EndpointResponse {
int32 status_code = 1;
string body = 2;
map<string, string> headers = 3;
}
`
ApiHandlerSRV = `package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
log "go-micro.dev/v6/logger"
pb "{{.Dir}}/proto"
)
type {{title .Alias}} struct {
started time.Time
routes map[string]http.HandlerFunc
}
func New() *{{title .Alias}} {
h := &{{title .Alias}}{
started: time.Now(),
routes: make(map[string]http.HandlerFunc),
}
h.registerRoutes()
return h
}
func (h *{{title .Alias}}) registerRoutes() {
h.routes["GET /hello"] = func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "World"
}
json.NewEncoder(w).Encode(map[string]string{
"message": fmt.Sprintf("Hello %s", name),
})
}
}
// Health returns the service health status and uptime.
//
// @example {}
func (h *{{title .Alias}}) Health(ctx context.Context, req *pb.HealthRequest, rsp *pb.HealthResponse) error {
rsp.Status = "ok"
rsp.Uptime = int64(time.Since(h.started).Seconds())
return nil
}
// Endpoint handles proxied HTTP requests. The method and path fields
// select the route; body and headers are forwarded.
//
// @example {"method": "GET", "path": "/hello", "body": "", "headers": {}}
func (h *{{title .Alias}}) Endpoint(ctx context.Context, req *pb.EndpointRequest, rsp *pb.EndpointResponse) error {
key := fmt.Sprintf("%s %s", req.Method, req.Path)
handler, ok := h.routes[key]
if !ok {
log.Infof("Route not found: %s", key)
rsp.StatusCode = 404
rsp.Body = ` + "`" + `{"error":"not found"}` + "`" + `
return nil
}
rec := &responseRecorder{headers: make(map[string]string), statusCode: 200}
fakeReq, _ := http.NewRequestWithContext(ctx, req.Method, req.Path, nil)
handler(rec, fakeReq)
rsp.StatusCode = int32(rec.statusCode)
rsp.Body = rec.body
rsp.Headers = rec.headers
return nil
}
type responseRecorder struct {
headers map[string]string
body string
statusCode int
}
func (r *responseRecorder) Header() http.Header { return http.Header{} }
func (r *responseRecorder) WriteHeader(statusCode int) { r.statusCode = statusCode }
func (r *responseRecorder) Write(b []byte) (int, error) {
r.body = string(b)
return len(b), nil
}
`
)
+225
View File
@@ -0,0 +1,225 @@
package template
var (
CrudProtoSRV = `syntax = "proto3";
package {{dehyphen .Alias}};
option go_package = "./proto;{{dehyphen .Alias}}";
service {{title .Alias}} {
rpc Create(CreateRequest) returns (CreateResponse) {}
rpc Read(ReadRequest) returns (ReadResponse) {}
rpc Update(UpdateRequest) returns (UpdateResponse) {}
rpc Delete(DeleteRequest) returns (DeleteResponse) {}
rpc List(ListRequest) returns (ListResponse) {}
}
message {{title .Alias}}Record {
string id = 1;
string name = 2;
string email = 3;
string phone = 4;
string company = 5;
int64 created = 6;
int64 updated = 7;
}
message CreateRequest {
string name = 1;
string email = 2;
string phone = 3;
string company = 4;
}
message CreateResponse {
{{title .Alias}}Record record = 1;
}
message ReadRequest {
string id = 1;
}
message ReadResponse {
{{title .Alias}}Record record = 1;
}
message UpdateRequest {
string id = 1;
string name = 2;
string email = 3;
string phone = 4;
string company = 5;
}
message UpdateResponse {
{{title .Alias}}Record record = 1;
}
message DeleteRequest {
string id = 1;
}
message DeleteResponse {
bool deleted = 1;
}
message ListRequest {
int64 limit = 1;
int64 offset = 2;
}
message ListResponse {
repeated {{title .Alias}}Record records = 1;
int64 total = 2;
}
`
CrudHandlerSRV = `package handler
import (
"context"
"fmt"
"sort"
"sync"
"time"
"github.com/google/uuid"
log "go-micro.dev/v6/logger"
pb "{{.Dir}}/proto"
)
type {{title .Alias}} struct {
mu sync.RWMutex
records map[string]*pb.{{title .Alias}}Record
}
func New() *{{title .Alias}} {
return &{{title .Alias}}{
records: make(map[string]*pb.{{title .Alias}}Record),
}
}
// Create adds a new record and returns it with a generated ID.
//
// @example {"name": "Alice Smith", "email": "alice@example.com", "phone": "+1-555-0100", "company": "Acme Inc"}
func (h *{{title .Alias}}) Create(ctx context.Context, req *pb.CreateRequest, rsp *pb.CreateResponse) error {
log.Infof("Creating record: %s", req.Name)
now := time.Now().Unix()
record := &pb.{{title .Alias}}Record{
Id: uuid.New().String(),
Name: req.Name,
Email: req.Email,
Phone: req.Phone,
Company: req.Company,
Created: now,
Updated: now,
}
h.mu.Lock()
h.records[record.Id] = record
h.mu.Unlock()
rsp.Record = record
return nil
}
// Read retrieves a record by ID.
//
// @example {"id": "some-uuid"}
func (h *{{title .Alias}}) Read(ctx context.Context, req *pb.ReadRequest, rsp *pb.ReadResponse) error {
h.mu.RLock()
record, ok := h.records[req.Id]
h.mu.RUnlock()
if !ok {
return fmt.Errorf("record %s not found", req.Id)
}
rsp.Record = record
return nil
}
// Update modifies an existing record. Only non-empty fields are updated.
//
// @example {"id": "some-uuid", "name": "Alice Johnson", "email": "alice.j@example.com"}
func (h *{{title .Alias}}) Update(ctx context.Context, req *pb.UpdateRequest, rsp *pb.UpdateResponse) error {
h.mu.Lock()
defer h.mu.Unlock()
record, ok := h.records[req.Id]
if !ok {
return fmt.Errorf("record %s not found", req.Id)
}
if req.Name != "" {
record.Name = req.Name
}
if req.Email != "" {
record.Email = req.Email
}
if req.Phone != "" {
record.Phone = req.Phone
}
if req.Company != "" {
record.Company = req.Company
}
record.Updated = time.Now().Unix()
rsp.Record = record
return nil
}
// Delete removes a record by ID.
//
// @example {"id": "some-uuid"}
func (h *{{title .Alias}}) Delete(ctx context.Context, req *pb.DeleteRequest, rsp *pb.DeleteResponse) error {
h.mu.Lock()
_, ok := h.records[req.Id]
if ok {
delete(h.records, req.Id)
}
h.mu.Unlock()
rsp.Deleted = ok
return nil
}
// List returns all records with optional pagination.
//
// @example {"limit": 10, "offset": 0}
func (h *{{title .Alias}}) List(ctx context.Context, req *pb.ListRequest, rsp *pb.ListResponse) error {
h.mu.RLock()
defer h.mu.RUnlock()
all := make([]*pb.{{title .Alias}}Record, 0, len(h.records))
for _, r := range h.records {
all = append(all, r)
}
sort.Slice(all, func(i, j int) bool {
return all[i].Created > all[j].Created
})
rsp.Total = int64(len(all))
offset := int(req.Offset)
if offset > len(all) {
offset = len(all)
}
limit := int(req.Limit)
if limit <= 0 {
limit = 20
}
end := offset + limit
if end > len(all) {
end = len(all)
}
rsp.Records = all[offset:end]
return nil
}
`
)
+108
View File
@@ -0,0 +1,108 @@
package template
var (
HandlerSRV = `package handler
import (
"context"
log "go-micro.dev/v6/logger"
pb "{{.Dir}}/proto"
)
type {{title .Alias}} struct{}
// Return a new handler.
func New() *{{title .Alias}} {
return &{{title .Alias}}{}
}
// Call greets a person by name and returns a welcome message.
//
// @example {"name": "Alice"}
func (e *{{title .Alias}}) Call(ctx context.Context, req *pb.Request, rsp *pb.Response) error {
log.Info("Received {{title .Alias}}.Call request")
rsp.Msg = "Hello " + req.Name
return nil
}
// Stream sends a sequence of numbered responses back to the caller.
// Use this for streaming large result sets or real-time updates.
//
// @example {"count": 5}
func (e *{{title .Alias}}) Stream(ctx context.Context, req *pb.StreamingRequest, stream pb.{{title .Alias}}_StreamStream) error {
log.Infof("Received {{title .Alias}}.Stream request with count: %d", req.Count)
for i := 0; i < int(req.Count); i++ {
log.Infof("Responding: %d", i)
if err := stream.Send(&pb.StreamingResponse{
Count: int64(i),
}); err != nil {
return err
}
}
return nil
}
`
// HandlerNoProto is the default handler: plain Go request/response types
// registered by reflection. No generated protobuf code, so no protoc.
HandlerNoProto = `package handler
import (
"context"
log "go-micro.dev/v6/logger"
)
// Request is the input to {{title .Alias}}.Call.
type Request struct {
Name string ` + "`json:\"name\" description:\"Name to greet (required)\"`" + `
}
// Response is the output of {{title .Alias}}.Call.
type Response struct {
Msg string ` + "`json:\"msg\"`" + `
}
type {{title .Alias}} struct{}
// Return a new handler.
func New() *{{title .Alias}} {
return &{{title .Alias}}{}
}
// Call greets a person by name and returns a welcome message.
//
// @example {"name": "Alice"}
func (e *{{title .Alias}}) Call(ctx context.Context, req *Request, rsp *Response) error {
log.Info("Received {{title .Alias}}.Call request")
rsp.Msg = "Hello " + req.Name
return nil
}
`
SubscriberSRV = `package subscriber
import (
"context"
log "go-micro.dev/v6/logger"
pb "{{.Dir}}/proto"
)
type {{title .Alias}} struct{}
func (e *{{title .Alias}}) Handle(ctx context.Context, msg *pb.Message) error {
log.Info("Handler Received message: ", msg.Say)
return nil
}
func Handler(ctx context.Context, msg *pb.Message) error {
log.Info("Function Received message: ", msg.Say)
return nil
}
`
)
+8
View File
@@ -0,0 +1,8 @@
package template
var (
GitIgnore = `
{{.Alias}}
.micro
`
)
+111
View File
@@ -0,0 +1,111 @@
package template
var (
MainSRV = `package main
import (
"{{.Dir}}/handler"
pb "{{.Dir}}/proto"
"go-micro.dev/v6"
"go-micro.dev/v6/gateway/mcp"
)
func main() {
// Create service
service := micro.NewService("{{lower .Alias}}",
mcp.WithMCP(":3001"),
)
// Initialize service
service.Init()
// Register handler
pb.Register{{title .Alias}}Handler(service.Server(), handler.New())
// Run service
service.Run()
}
`
MainSRVNoMCP = `package main
import (
"{{.Dir}}/handler"
pb "{{.Dir}}/proto"
"go-micro.dev/v6"
)
func main() {
// Create service
service := micro.NewService("{{lower .Alias}}")
// Initialize service
service.Init()
// Register handler
pb.Register{{title .Alias}}Handler(service.Server(), handler.New())
// Run service
service.Run()
}
`
// MainNoProto is the default template: handlers are registered by
// reflection, so the service builds and runs with no protoc toolchain.
MainNoProto = `package main
import (
"{{.Dir}}/handler"
"go-micro.dev/v6"
"go-micro.dev/v6/gateway/mcp"
log "go-micro.dev/v6/logger"
)
func main() {
// Create service
service := micro.NewService("{{lower .Alias}}",
mcp.WithMCP(":3001"),
)
// Initialize service
service.Init()
// Register handler (reflection-based — no protoc required)
if err := service.Handle(handler.New()); err != nil {
log.Fatal(err)
}
// Run service
service.Run()
}
`
MainNoProtoNoMCP = `package main
import (
"{{.Dir}}/handler"
"go-micro.dev/v6"
log "go-micro.dev/v6/logger"
)
func main() {
// Create service
service := micro.NewService("{{lower .Alias}}")
// Initialize service
service.Init()
// Register handler (reflection-based — no protoc required)
if err := service.Handle(handler.New()); err != nil {
log.Fatal(err)
}
// Run service
service.Run()
}
`
)
+119
View File
@@ -0,0 +1,119 @@
package template
// MakefileNoProto is the default Makefile: no proto target, nothing to
// generate, so the service builds and runs with no protoc toolchain.
var MakefileNoProto = `.PHONY: build run dev test test-coverage clean docker lint fmt deps
# Build the service
build:
go build -o bin/{{.Alias}} .
# Run the service
run:
go run .
# Run with hot reload (requires air: go install github.com/air-verse/air@latest)
dev:
air
# Run tests
test:
go test -v ./...
# Run tests with coverage
test-coverage:
go test -v -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# List MCP tools exposed by this service
mcp-tools:
micro mcp list
# Start MCP server for Claude Code
mcp-serve:
micro mcp serve
# Clean build artifacts
clean:
rm -rf bin/ coverage.out coverage.html
# Build Docker image
docker:
docker build -t {{.Alias}}:latest .
# Lint code
lint:
golangci-lint run ./...
# Format code
fmt:
go fmt ./...
goimports -w .
# Update dependencies
deps:
go mod tidy
go mod download
`
var Makefile = `.PHONY: proto build run test clean docker
# Generate protobuf files
proto:
protoc --proto_path=. --micro_out=. --go_out=. proto/*.proto
# Build the service
build:
go build -o bin/{{.Alias}} .
# Run the service
run:
go run .
# Run with hot reload (requires air: go install github.com/air-verse/air@latest)
dev:
air
# Run tests
test:
go test -v ./...
# Run tests with coverage
test-coverage:
go test -v -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# List MCP tools exposed by this service
mcp-tools:
micro mcp list
# Test an MCP tool interactively
mcp-test:
micro mcp test
# Start MCP server for Claude Code
mcp-serve:
micro mcp serve
# Clean build artifacts
clean:
rm -rf bin/ coverage.out coverage.html
# Build Docker image
docker:
docker build -t {{.Alias}}:latest .
# Lint code
lint:
golangci-lint run ./...
# Format code
fmt:
go fmt ./...
goimports -w .
# Update dependencies
deps:
go mod tidy
go mod download
`
+28
View File
@@ -0,0 +1,28 @@
package template
var (
Module = `module {{.Dir}}
go 1.22
require (
go-micro.dev/v6 {{.MicroVersion}}
github.com/golang/protobuf latest
google.golang.org/protobuf latest
)
{{if .MicroReplace}}
replace go-micro.dev/v6 => {{.MicroReplace}}
{{end}}`
// ModuleNoProto is the default go.mod: no protobuf dependencies.
// MicroVersion is the version this CLI was built from (or "latest"), so a
// generated service tracks the framework the user is actually running.
ModuleNoProto = `module {{.Dir}}
go 1.23
require go-micro.dev/v6 {{.MicroVersion}}
{{if .MicroReplace}}
replace go-micro.dev/v6 => {{.MicroReplace}}
{{end}}`
)
+39
View File
@@ -0,0 +1,39 @@
package template
var (
ProtoSRV = `syntax = "proto3";
package {{dehyphen .Alias}};
option go_package = "./proto;{{dehyphen .Alias}}";
service {{title .Alias}} {
rpc Call(Request) returns (Response) {}
rpc Stream(StreamingRequest) returns (stream StreamingResponse) {}
}
message Message {
string say = 1;
}
message Request {
// Name of the person to greet
string name = 1;
}
message Response {
// Greeting message
string msg = 1;
}
message StreamingRequest {
// Number of responses to stream back
int64 count = 1;
}
message StreamingResponse {
// Current sequence number in the stream
int64 count = 1;
}
`
)
+184
View File
@@ -0,0 +1,184 @@
package template
var (
PubsubProtoSRV = `syntax = "proto3";
package {{dehyphen .Alias}};
option go_package = "./proto;{{dehyphen .Alias}}";
service {{title .Alias}} {
rpc Publish(PublishRequest) returns (PublishResponse) {}
rpc Stats(StatsRequest) returns (StatsResponse) {}
}
message Event {
string id = 1;
string type = 2;
string source = 3;
string data = 4;
int64 timestamp = 5;
}
message PublishRequest {
string type = 1;
string data = 2;
}
message PublishResponse {
string id = 1;
}
message StatsRequest {}
message StatsResponse {
int64 published = 1;
int64 received = 2;
}
`
PubsubHandlerSRV = `package handler
import (
"context"
"encoding/json"
"sync/atomic"
"time"
"github.com/google/uuid"
"go-micro.dev/v6/broker"
log "go-micro.dev/v6/logger"
pb "{{.Dir}}/proto"
)
const Topic = "{{lower .Alias}}.events"
type {{title .Alias}} struct {
broker broker.Broker
published atomic.Int64
received atomic.Int64
}
func New(b broker.Broker) *{{title .Alias}} {
return &{{title .Alias}}{broker: b}
}
// Publish sends an event to the message broker.
//
// @example {"type": "user.created", "data": "{\"id\": \"123\", \"name\": \"Alice\"}"}
func (h *{{title .Alias}}) Publish(ctx context.Context, req *pb.PublishRequest, rsp *pb.PublishResponse) error {
event := &pb.Event{
Id: uuid.New().String(),
Type: req.Type,
Source: "{{lower .Alias}}",
Data: req.Data,
Timestamp: time.Now().Unix(),
}
body, err := json.Marshal(event)
if err != nil {
return err
}
if err := h.broker.Publish(Topic, &broker.Message{Body: body}); err != nil {
return err
}
h.published.Add(1)
log.Infof("Published event %s type=%s", event.Id, event.Type)
rsp.Id = event.Id
return nil
}
// Stats returns the number of events published and received.
//
// @example {}
func (h *{{title .Alias}}) Stats(ctx context.Context, req *pb.StatsRequest, rsp *pb.StatsResponse) error {
rsp.Published = h.published.Load()
rsp.Received = h.received.Load()
return nil
}
// Subscribe sets up a subscription to the event topic. Call this
// after the service has started.
func (h *{{title .Alias}}) Subscribe() error {
_, err := h.broker.Subscribe(Topic, func(p broker.Event) error {
h.received.Add(1)
var event pb.Event
if err := json.Unmarshal(p.Message().Body, &event); err != nil {
log.Errorf("Failed to unmarshal event: %v", err)
return nil
}
log.Infof("Received event %s type=%s data=%s", event.Id, event.Type, event.Data)
return nil
})
return err
}
`
PubsubMainSRV = `package main
import (
"{{.Dir}}/handler"
pb "{{.Dir}}/proto"
"go-micro.dev/v6"
"go-micro.dev/v6/gateway/mcp"
log "go-micro.dev/v6/logger"
)
func main() {
service := micro.NewService("{{lower .Alias}}",
mcp.WithMCP(":3001"),
)
service.Init()
h := handler.New(service.Options().Broker)
pb.Register{{title .Alias}}Handler(service.Server(), h)
// Subscribe to events after service starts
go func() {
if err := h.Subscribe(); err != nil {
log.Fatalf("Failed to subscribe: %v", err)
}
log.Info("Subscribed to ", handler.Topic)
}()
service.Run()
}
`
PubsubMainSRVNoMCP = `package main
import (
"{{.Dir}}/handler"
pb "{{.Dir}}/proto"
"go-micro.dev/v6"
log "go-micro.dev/v6/logger"
)
func main() {
service := micro.NewService("{{lower .Alias}}")
service.Init()
h := handler.New(service.Options().Broker)
pb.Register{{title .Alias}}Handler(service.Server(), h)
go func() {
if err := h.Subscribe(); err != nil {
log.Fatalf("Failed to subscribe: %v", err)
}
log.Info("Subscribed to ", handler.Topic)
}()
service.Run()
}
`
)
+163
View File
@@ -0,0 +1,163 @@
package template
var (
// ReadmeNoProto is the default README: handlers are reflection-based, so
// there is no proto generation step and no protoc prerequisite.
ReadmeNoProto = `# {{title .Alias}} Service
Generated with
` + "```" + `
micro new {{.Alias}}
` + "```" + `
## Getting Started
Run the service — no code generation, no protoc, nothing else to install:
` + "```bash" + `
go run .
` + "```" + `
Handlers are registered by reflection from plain Go types, so request and
response structs are defined directly in ` + "`handler/`" + `. To use Protocol
Buffers instead, regenerate with ` + "`micro new {{.Alias}} --proto`" + `.
## MCP & AI Agents
This service is MCP-enabled by default. When running, AI agents can discover
and call your service endpoints automatically.
**MCP tools endpoint:** http://localhost:3001/mcp/tools
### Test with curl
` + "```bash" + `
# List available tools
curl http://localhost:3001/mcp/tools | jq
# Call the service via MCP
curl -X POST http://localhost:3001/mcp/call \
-H 'Content-Type: application/json' \
-d '{"tool": "{{lower .Alias}}.{{title .Alias}}.Call", "arguments": {"name": "Alice"}}'
` + "```" + `
### Use with Claude Code
` + "```bash" + `
# Start MCP server for Claude Code
micro mcp serve
` + "```" + `
### Writing Good Tool Descriptions
AI agents work best when your handler methods have clear doc comments:
` + "```go" + `
// CreateUser registers a new user account with the given email and name.
// Returns the created user with their assigned ID.
//
// @example {"email": "alice@example.com", "name": "Alice Smith"}
func (s *Users) CreateUser(ctx context.Context, req *CreateRequest, rsp *CreateResponse) error {
// ...
}
` + "```" + `
See the [tool descriptions guide](https://go-micro.dev/docs/guides/tool-descriptions) for more tips.
## Development
` + "```bash" + `
make build # Build binary
make test # Run tests
make dev # Run with hot reload (requires air)
` + "```" + `
`
Readme = `# {{title .Alias}} Service
Generated with
` + "```" + `
micro new {{.Alias}}
` + "```" + `
## Getting Started
Generate the proto code:
` + "```bash" + `
make proto
` + "```" + `
Run the service:
` + "```bash" + `
go run .
` + "```" + `
## MCP & AI Agents
This service is MCP-enabled by default. When running, AI agents can discover
and call your service endpoints automatically.
**MCP tools endpoint:** http://localhost:3001/mcp/tools
### Test with curl
` + "```bash" + `
# List available tools
curl http://localhost:3001/mcp/tools | jq
# Call the service via MCP
curl -X POST http://localhost:3001/mcp/call \
-H 'Content-Type: application/json' \
-d '{"tool": "{{lower .Alias}}.{{title .Alias}}.Call", "arguments": {"name": "Alice"}}'
` + "```" + `
### Use with Claude Code
` + "```bash" + `
# Start MCP server for Claude Code
micro mcp serve
` + "```" + `
Or add to your Claude Code config:
` + "```json" + `
{
"mcpServers": {
"{{lower .Alias}}": {
"command": "micro",
"args": ["mcp", "serve"]
}
}
}
` + "```" + `
### Writing Good Tool Descriptions
AI agents work best when your handler methods have clear doc comments:
` + "```go" + `
// CreateUser registers a new user account with the given email and name.
// Returns the created user with their assigned ID.
//
// @example {"email": "alice@example.com", "name": "Alice Smith"}
func (s *Users) CreateUser(ctx context.Context, req *CreateRequest, rsp *CreateResponse) error {
// ...
}
` + "```" + `
See the [tool descriptions guide](https://go-micro.dev/docs/guides/tool-descriptions) for more tips.
## Development
` + "```bash" + `
make proto # Regenerate proto code
make build # Build binary
make test # Run tests
make dev # Run with hot reload (requires air)
` + "```" + `
`
)
+365
View File
@@ -0,0 +1,365 @@
// Package remote provides remote server operations for micro
package remote
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"github.com/urfave/cli/v2"
"go-micro.dev/v6/cmd"
)
// Status shows status of services (local or remote)
func Status(c *cli.Context) error {
remoteHost := c.String("remote")
if remoteHost != "" {
return remoteStatus(remoteHost)
}
return localStatus(c)
}
func localStatus(c *cli.Context) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home dir: %w", err)
}
runDir := filepath.Join(homeDir, "micro", "run")
files, err := os.ReadDir(runDir)
if err != nil {
fmt.Println("No services running locally.")
fmt.Println("\nStart services with: micro run")
return nil
}
var hasServices bool
fmt.Printf("%-20s %-10s %-8s %s\n", "SERVICE", "STATUS", "PID", "DIRECTORY")
fmt.Println(strings.Repeat("-", 70))
for _, f := range files {
if f.IsDir() || !strings.HasSuffix(f.Name(), ".pid") {
continue
}
hasServices = true
service := f.Name()[:len(f.Name())-4]
pidFilePath := filepath.Join(runDir, f.Name())
pidFile, err := os.Open(pidFilePath)
if err != nil {
continue
}
var pid int
var dir string
scanner := bufio.NewScanner(pidFile)
if scanner.Scan() {
_, _ = fmt.Sscanf(scanner.Text(), "%d", &pid)
}
if scanner.Scan() {
dir = scanner.Text()
}
pidFile.Close()
status := "\u2717 stopped"
if pid > 0 {
proc, err := os.FindProcess(pid)
if err == nil {
if err := proc.Signal(syscall.Signal(0)); err == nil {
status = "\u25cf running"
}
}
}
fmt.Printf("%-20s %-10s %-8d %s\n", service, status, pid, dir)
}
if !hasServices {
fmt.Println("No services running locally.")
fmt.Println("\nStart services with: micro run")
}
return nil
}
func remoteStatus(host string) error {
// Get list of micro services via systemctl
listCmd := exec.Command("ssh", host, "systemctl list-units 'micro@*' --no-legend --no-pager 2>/dev/null || true")
output, err := listCmd.Output()
if err != nil {
return fmt.Errorf("failed to get status from %s: %w", host, err)
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
fmt.Printf("%s\n", host)
fmt.Println(strings.Repeat("\u2501", 50))
fmt.Println("\nNo services deployed.")
fmt.Println("\nDeploy with: micro deploy " + host)
return nil
}
fmt.Printf("%s\n", host)
fmt.Println(strings.Repeat("\u2501", 50))
fmt.Println()
for _, line := range lines {
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 4 {
continue
}
unit := parts[0]
loadState := parts[1]
activeState := parts[2]
subState := parts[3]
// Extract service name from micro@servicename.service
serviceName := strings.TrimPrefix(unit, "micro@")
serviceName = strings.TrimSuffix(serviceName, ".service")
// Get more details
statusIcon := "\u25cf"
statusText := subState
if activeState != "active" || subState != "running" {
statusIcon = "\u2717"
}
_ = loadState // unused but parsed
fmt.Printf(" %-15s %s %s\n", serviceName, statusIcon, statusText)
}
fmt.Println()
return nil
}
// Logs shows logs for services (local or remote)
func Logs(c *cli.Context) error {
remoteHost := c.String("remote")
service := c.Args().First()
follow := c.Bool("follow") || c.Bool("f")
lines := c.Int("lines")
if remoteHost != "" {
return remoteLogs(remoteHost, service, follow, lines)
}
return localLogs(c, service, follow, lines)
}
func localLogs(c *cli.Context, service string, follow bool, lines int) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home dir: %w", err)
}
logDir := filepath.Join(homeDir, "micro", "logs")
if service == "" {
// List available logs
files, err := os.ReadDir(logDir)
if err != nil {
fmt.Println("No logs available.")
return nil
}
fmt.Println("Available logs:")
for _, f := range files {
if strings.HasSuffix(f.Name(), ".log") {
name := strings.TrimSuffix(f.Name(), ".log")
fmt.Printf(" %s\n", name)
}
}
fmt.Println("\nView logs: micro logs <service>")
return nil
}
logPath := filepath.Join(logDir, service+".log")
if _, err := os.Stat(logPath); os.IsNotExist(err) {
return fmt.Errorf("no logs for service '%s'", service)
}
if follow {
cmd := exec.Command("tail", "-f", logPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
if lines == 0 {
lines = 100
}
cmd := exec.Command("tail", "-n", fmt.Sprintf("%d", lines), logPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func remoteLogs(host, service string, follow bool, lines int) error {
var journalCmd string
if service == "" {
// All micro services
journalCmd = "journalctl -u 'micro@*'"
} else {
journalCmd = fmt.Sprintf("journalctl -u 'micro@%s'", service)
}
if follow {
journalCmd += " -f"
} else {
if lines == 0 {
lines = 100
}
journalCmd += fmt.Sprintf(" -n %d", lines)
}
journalCmd += " --no-pager"
sshCmd := exec.Command("ssh", host, journalCmd)
sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr
return sshCmd.Run()
}
// Stop stops a running service
func Stop(c *cli.Context) error {
if c.Args().Len() != 1 {
return fmt.Errorf("usage: micro stop <service>")
}
service := c.Args().First()
remoteHost := c.String("remote")
if remoteHost != "" {
return remoteStop(remoteHost, service)
}
return localStop(service)
}
func localStop(service string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home dir: %w", err)
}
runDir := filepath.Join(homeDir, "micro", "run")
pidFilePath := filepath.Join(runDir, service+".pid")
pidFile, err := os.Open(pidFilePath)
if err != nil {
return fmt.Errorf("service '%s' is not running", service)
}
var pid int
scanner := bufio.NewScanner(pidFile)
if scanner.Scan() {
_, _ = fmt.Sscanf(scanner.Text(), "%d", &pid)
}
pidFile.Close()
if pid <= 0 {
_ = os.Remove(pidFilePath)
return fmt.Errorf("service '%s' is not running", service)
}
proc, err := os.FindProcess(pid)
if err != nil {
_ = os.Remove(pidFilePath)
return fmt.Errorf("could not find process for '%s'", service)
}
if err := proc.Signal(syscall.SIGTERM); err != nil {
_ = os.Remove(pidFilePath)
return fmt.Errorf("failed to stop service '%s': %v", service, err)
}
_ = os.Remove(pidFilePath)
fmt.Printf("Stopped %s (pid %d)\n", service, pid)
return nil
}
func remoteStop(host, service string) error {
stopCmd := fmt.Sprintf("sudo systemctl stop micro@%s", service)
sshCmd := exec.Command("ssh", host, stopCmd)
if output, err := sshCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to stop %s: %s", service, string(output))
}
fmt.Printf("Stopped %s on %s\n", service, host)
return nil
}
func init() {
cmd.Register(&cli.Command{
Name: "status",
Usage: "Check status of running services",
Description: `Show status of running services.
Local status:
micro status
Remote status:
micro status --remote user@host`,
Action: Status,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "remote",
Usage: "Check status on remote server",
},
},
})
cmd.Register(&cli.Command{
Name: "logs",
Usage: "Show logs for a service",
Description: `View service logs.
Local logs:
micro logs # list available logs
micro logs myservice # show logs for myservice
micro logs myservice -f # follow logs
Remote logs:
micro logs --remote user@host
micro logs myservice --remote user@host -f`,
Action: Logs,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "remote",
Usage: "View logs on remote server",
},
&cli.BoolFlag{
Name: "follow",
Aliases: []string{"f"},
Usage: "Follow log output",
},
&cli.IntFlag{
Name: "lines",
Aliases: []string{"n"},
Usage: "Number of lines to show (default: 100)",
Value: 100,
},
},
})
cmd.Register(&cli.Command{
Name: "stop",
Usage: "Stop a running service",
Description: `Stop a running service.
Local:
micro stop myservice
Remote:
micro stop myservice --remote user@host`,
Action: Stop,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "remote",
Usage: "Stop service on remote server",
},
},
})
}
+447
View File
@@ -0,0 +1,447 @@
package util
import (
"bytes"
"context"
"encoding/json"
"fmt"
"math"
"os"
"sort"
"strconv"
"strings"
"unicode"
"github.com/stretchr/objx"
"github.com/urfave/cli/v2"
"go-micro.dev/v6/client"
"go-micro.dev/v6/metadata"
"go-micro.dev/v6/registry"
)
// AddMetadataToContext parses metadata strings in the format "Key:Value" and adds them to the context
func AddMetadataToContext(ctx context.Context, metadataStrings []string) context.Context {
if len(metadataStrings) == 0 {
return ctx
}
md := make(metadata.Metadata)
for _, m := range metadataStrings {
parts := strings.SplitN(m, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
md[key] = value
}
return metadata.MergeContext(ctx, md, true)
}
// LookupService queries the service for a service with the given alias. If
// no services are found for a given alias, the registry will return nil and
// the error will also be nil. An error is only returned if there was an issue
// listing from the registry.
func LookupService(name string) (*registry.Service, error) {
// return a lookup in the default domain as a catch all
return serviceWithName(name)
}
// FormatServiceUsage returns a string containing the service usage.
func FormatServiceUsage(srv *registry.Service, c *cli.Context) string {
alias := c.Args().First()
subcommand := c.Args().Get(1)
commands := make([]string, len(srv.Endpoints))
endpoints := make([]*registry.Endpoint, len(srv.Endpoints))
for i, e := range srv.Endpoints {
// map "Helloworld.Call" to "helloworld.call"
parts := strings.Split(e.Name, ".")
for i, part := range parts {
parts[i] = lowercaseInitial(part)
}
name := strings.Join(parts, ".")
// remove the prefix if it is the service name, e.g. rather than
// "micro run helloworld helloworld call", it would be
// "micro run helloworld call".
name = strings.TrimPrefix(name, alias+".")
// instead of "micro run helloworld foo.bar", the command should
// be "micro run helloworld foo bar".
commands[i] = strings.Replace(name, ".", " ", 1)
endpoints[i] = e
}
result := ""
if len(subcommand) > 0 && subcommand != "--help" {
result += fmt.Sprintf("NAME:\n\tmicro %v %v\n\n", alias, subcommand)
result += fmt.Sprintf("USAGE:\n\tmicro %v %v [flags]\n\n", alias, subcommand)
result += "FLAGS:\n"
for i, command := range commands {
if command == subcommand {
result += renderFlags(endpoints[i])
}
}
} else {
// sort the command names alphabetically
sort.Strings(commands)
result += fmt.Sprintf("NAME:\n\tmicro %v\n\n", alias)
result += fmt.Sprintf("VERSION:\n\t%v\n\n", srv.Version)
result += fmt.Sprintf("USAGE:\n\tmicro %v [command]\n\n", alias)
result += fmt.Sprintf("COMMANDS:\n\t%v\n", strings.Join(commands, "\n\t"))
}
return result
}
func lowercaseInitial(str string) string {
for i, v := range str {
return string(unicode.ToLower(v)) + str[i+1:]
}
return ""
}
func renderFlags(endpoint *registry.Endpoint) string {
ret := ""
for _, value := range endpoint.Request.Values {
ret += renderValue([]string{}, value) + "\n"
}
return ret
}
func renderValue(path []string, value *registry.Value) string {
if len(value.Values) > 0 {
renders := []string{}
for _, v := range value.Values {
renders = append(renders, renderValue(append(path, value.Name), v))
}
return strings.Join(renders, "\n")
}
return fmt.Sprintf("\t--%v %v", strings.Join(append(path, value.Name), "_"), value.Type)
}
// CallService will call a service using the arguments and flags provided
// in the context. It will print the result or error to stdout. If there
// was an error performing the call, it will be returned.
func CallService(srv *registry.Service, args []string) error {
// parse the flags and args
args, flags, err := splitCmdArgs(args)
if err != nil {
return err
}
// construct the endpoint
endpoint, err := constructEndpoint(args)
if err != nil {
return err
}
// ensure the endpoint exists on the service
var ep *registry.Endpoint
for _, e := range srv.Endpoints {
if e.Name == endpoint {
ep = e
break
}
}
if ep == nil {
return fmt.Errorf("endpoint %v not found for service %v", endpoint, srv.Name)
}
// create a context for the call
callCtx := context.TODO()
// parse out --header or --metadata flags before parsing request body
// Note: This is for dynamic service calls (e.g., 'micro helloworld call --header X:Y').
// Direct 'micro call' commands are handled in cli.go.
if headerFlags, ok := flags["header"]; ok {
callCtx = AddMetadataToContext(callCtx, headerFlags)
delete(flags, "header")
}
if metadataFlags, ok := flags["metadata"]; ok {
callCtx = AddMetadataToContext(callCtx, metadataFlags)
delete(flags, "metadata")
}
// parse the flags into request body
body, err := FlagsToRequest(flags, ep.Request)
if err != nil {
return err
}
// construct and execute the request using the json content type
req := client.DefaultClient.NewRequest(srv.Name, endpoint, body, client.WithContentType("application/json"))
var rsp json.RawMessage
if err := client.DefaultClient.Call(callCtx, req, &rsp); err != nil {
return err
}
// format the response
var out bytes.Buffer
defer out.Reset()
if err := json.Indent(&out, rsp, "", "\t"); err != nil {
return err
}
out.Write([]byte("\n"))
_, _ = out.WriteTo(os.Stdout)
return nil
}
// splitCmdArgs takes a cli context and parses out the args and flags, for
// example "micro helloworld --name=foo call apple" would result in "call",
// "apple" as args and {"name":"foo"} as the flags.
func splitCmdArgs(arguments []string) ([]string, map[string][]string, error) {
args := []string{}
flags := map[string][]string{}
prev := ""
for _, a := range arguments {
if !strings.HasPrefix(a, "--") {
if len(prev) == 0 {
args = append(args, a)
continue
}
_, exists := flags[prev]
if !exists {
flags[prev] = []string{}
}
flags[prev] = append(flags[prev], a)
prev = ""
continue
}
// comps would be "foo", "bar" for "--foo=bar"
comps := strings.Split(strings.TrimPrefix(a, "--"), "=")
_, exists := flags[comps[0]]
if !exists {
flags[comps[0]] = []string{}
}
switch len(comps) {
case 1:
prev = comps[0]
case 2:
flags[comps[0]] = append(flags[comps[0]], comps[1])
default:
return nil, nil, fmt.Errorf("invalid flag: %v. Expected format: --foo=bar", a)
}
}
return args, flags, nil
}
// constructEndpoint takes a slice of args and converts it into a valid endpoint
// such as Helloworld.Call or Foo.Bar, it will return an error if an invalid number
// of arguments were provided
func constructEndpoint(args []string) (string, error) {
var epComps []string
switch len(args) {
case 1:
epComps = append(args, "call")
case 2:
epComps = args
case 3:
epComps = args[1:3]
default:
return "", fmt.Errorf("incorrect number of arguments")
}
// transform the endpoint components, e.g ["helloworld", "call"] to the
// endpoint name: "Helloworld.Call".
return fmt.Sprintf("%v.%v", strings.Title(epComps[0]), strings.Title(epComps[1])), nil
}
// ShouldRenderHelp returns true if the help flag was passed
func ShouldRenderHelp(args []string) bool {
args, flags, _ := splitCmdArgs(args)
// only 1 arg e.g micro helloworld
if len(args) == 1 {
return true
}
for key := range flags {
if key == "help" {
return true
}
}
return false
}
// FlagsToRequest parses a set of flags, e.g {name:"Foo", "options_surname","Bar"} and
// converts it into a request body. If the key is not a valid object in the request, an
// error will be returned.
//
// This function constructs []interface{} slices
// as opposed to typed ([]string etc) slices for easier testing
func FlagsToRequest(flags map[string][]string, req *registry.Value) (map[string]interface{}, error) {
coerceValue := func(valueType string, value []string) (interface{}, error) {
switch valueType {
case "bool":
if len(value) == 0 || len(strings.TrimSpace(value[0])) == 0 {
return true, nil
}
return strconv.ParseBool(value[0])
case "int32":
i, err := strconv.Atoi(value[0])
if err != nil {
return nil, err
}
if i < math.MinInt32 || i > math.MaxInt32 {
return nil, fmt.Errorf("value out of range for int32: %d", i)
}
return int32(i), nil
case "int64":
return strconv.ParseInt(value[0], 0, 64)
case "float64":
return strconv.ParseFloat(value[0], 64)
case "[]bool":
// length is one if it's a `,` separated int slice
if len(value) == 1 {
value = strings.Split(value[0], ",")
}
ret := []interface{}{}
for _, v := range value {
i, err := strconv.ParseBool(v)
if err != nil {
return nil, err
}
ret = append(ret, i)
}
return ret, nil
case "[]int32":
// length is one if it's a `,` separated int slice
if len(value) == 1 {
value = strings.Split(value[0], ",")
}
ret := []interface{}{}
for _, v := range value {
i, err := strconv.Atoi(v)
if err != nil {
return nil, err
}
if i < math.MinInt32 || i > math.MaxInt32 {
return nil, fmt.Errorf("value out of range for int32: %d", i)
}
ret = append(ret, int32(i))
}
return ret, nil
case "[]int64":
// length is one if it's a `,` separated int slice
if len(value) == 1 {
value = strings.Split(value[0], ",")
}
ret := []interface{}{}
for _, v := range value {
i, err := strconv.ParseInt(v, 0, 64)
if err != nil {
return nil, err
}
ret = append(ret, i)
}
return ret, nil
case "[]float64":
// length is one if it's a `,` separated float slice
if len(value) == 1 {
value = strings.Split(value[0], ",")
}
ret := []interface{}{}
for _, v := range value {
i, err := strconv.ParseFloat(v, 64)
if err != nil {
return nil, err
}
ret = append(ret, i)
}
return ret, nil
case "[]string":
// length is one it's a `,` separated string slice
if len(value) == 1 {
value = strings.Split(value[0], ",")
}
ret := []interface{}{}
for _, v := range value {
ret = append(ret, v)
}
return ret, nil
case "string":
return value[0], nil
case "map[string]string":
var val map[string]string
if err := json.Unmarshal([]byte(value[0]), &val); err != nil {
return value[0], nil
}
return val, nil
default:
return value, nil
}
}
result := objx.MustFromJSON("{}")
var flagType func(key string, values []*registry.Value, path ...string) (string, bool)
flagType = func(key string, values []*registry.Value, path ...string) (string, bool) {
for _, attr := range values {
if strings.Join(append(path, attr.Name), "-") == key {
return attr.Type, true
}
if attr.Values != nil {
typ, found := flagType(key, attr.Values, append(path, attr.Name)...)
if found {
return typ, found
}
}
}
return "", false
}
for key, value := range flags {
ty, found := flagType(key, req.Values)
if !found {
return nil, fmt.Errorf("unknown flag: %v", key)
}
parsed, err := coerceValue(ty, value)
if err != nil {
return nil, err
}
// objx.Set does not create the path,
// so we do that here
if strings.Contains(key, "-") {
parts := strings.Split(key, "-")
for i := range parts {
pToCreate := strings.Join(parts[0:i], ".")
if i > 0 && i < len(parts) && !result.Has(pToCreate) {
result.Set(pToCreate, map[string]interface{}{})
}
}
}
path := strings.ReplaceAll(key, "-", ".")
result.Set(path, parsed)
}
return result, nil
}
// find a service in a domain matching the name
func serviceWithName(name string) (*registry.Service, error) {
srvs, err := registry.GetService(name)
if err == registry.ErrNotFound {
return nil, nil
} else if err != nil {
return nil, err
}
if len(srvs) == 0 {
return nil, nil
}
return srvs[0], nil
}
+453
View File
@@ -0,0 +1,453 @@
package util
import (
"context"
"reflect"
"strings"
"testing"
"github.com/davecgh/go-spew/spew"
"go-micro.dev/v6/metadata"
goregistry "go-micro.dev/v6/registry"
)
type parseCase struct {
args []string
values *goregistry.Value
expected map[string]interface{}
}
func TestDynamicFlagParsing(t *testing.T) {
cases := []parseCase{
{
args: []string{"--ss=a,b"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "ss",
Type: "[]string",
},
},
},
expected: map[string]interface{}{
"ss": []interface{}{"a", "b"},
},
},
{
args: []string{"--ss", "a,b"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "ss",
Type: "[]string",
},
},
},
expected: map[string]interface{}{
"ss": []interface{}{"a", "b"},
},
},
{
args: []string{"--ss=a", "--ss=b"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "ss",
Type: "[]string",
},
},
},
expected: map[string]interface{}{
"ss": []interface{}{"a", "b"},
},
},
{
args: []string{"--ss", "a", "--ss", "b"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "ss",
Type: "[]string",
},
},
},
expected: map[string]interface{}{
"ss": []interface{}{"a", "b"},
},
},
{
args: []string{"--bs=true,false"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "bs",
Type: "[]bool",
},
},
},
expected: map[string]interface{}{
"bs": []interface{}{true, false},
},
},
{
args: []string{"--bs", "true,false"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "bs",
Type: "[]bool",
},
},
},
expected: map[string]interface{}{
"bs": []interface{}{true, false},
},
},
{
args: []string{"--bs=true", "--bs=false"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "bs",
Type: "[]bool",
},
},
},
expected: map[string]interface{}{
"bs": []interface{}{true, false},
},
},
{
args: []string{"--bs", "true", "--bs", "false"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "bs",
Type: "[]bool",
},
},
},
expected: map[string]interface{}{
"bs": []interface{}{true, false},
},
},
{
args: []string{"--is=10,20"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "is",
Type: "[]int32",
},
},
},
expected: map[string]interface{}{
"is": []interface{}{int32(10), int32(20)},
},
},
{
args: []string{"--is", "10,20"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "is",
Type: "[]int32",
},
},
},
expected: map[string]interface{}{
"is": []interface{}{int32(10), int32(20)},
},
},
{
args: []string{"--is=10", "--is=20"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "is",
Type: "[]int32",
},
},
},
expected: map[string]interface{}{
"is": []interface{}{int32(10), int32(20)},
},
},
{
args: []string{"--is", "10", "--is", "20"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "is",
Type: "[]int32",
},
},
},
expected: map[string]interface{}{
"is": []interface{}{int32(10), int32(20)},
},
},
{
args: []string{"--is=10,20"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "is",
Type: "[]int64",
},
},
},
expected: map[string]interface{}{
"is": []interface{}{int64(10), int64(20)},
},
},
{
args: []string{"--is", "10,20"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "is",
Type: "[]int64",
},
},
},
expected: map[string]interface{}{
"is": []interface{}{int64(10), int64(20)},
},
},
{
args: []string{"--is=10", "--is=20"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "is",
Type: "[]int64",
},
},
},
expected: map[string]interface{}{
"is": []interface{}{int64(10), int64(20)},
},
},
{
args: []string{"--is", "10", "--is", "20"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "is",
Type: "[]int64",
},
},
},
expected: map[string]interface{}{
"is": []interface{}{int64(10), int64(20)},
},
},
{
args: []string{"--fs=10.1,20.2"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "fs",
Type: "[]float64",
},
},
},
expected: map[string]interface{}{
"fs": []interface{}{float64(10.1), float64(20.2)},
},
},
{
args: []string{"--fs", "10.1,20.2"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "fs",
Type: "[]float64",
},
},
},
expected: map[string]interface{}{
"fs": []interface{}{float64(10.1), float64(20.2)},
},
},
{
args: []string{"--fs=10.1", "--fs=20.2"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "fs",
Type: "[]float64",
},
},
},
expected: map[string]interface{}{
"fs": []interface{}{float64(10.1), float64(20.2)},
},
},
{
args: []string{"--fs", "10.1", "--fs", "20.2"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "fs",
Type: "[]float64",
},
},
},
expected: map[string]interface{}{
"fs": []interface{}{float64(10.1), float64(20.2)},
},
},
{
args: []string{"--user_email=someemail"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "user_email",
Type: "string",
},
},
},
expected: map[string]interface{}{
"user_email": "someemail",
},
},
{
args: []string{"--user_email=someemail", "--user_name=somename"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "user_email",
Type: "string",
},
{
Name: "user_name",
Type: "string",
},
},
},
expected: map[string]interface{}{
"user_email": "someemail",
"user_name": "somename",
},
},
{
args: []string{"--b"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "b",
Type: "bool",
},
},
},
expected: map[string]interface{}{
"b": true,
},
},
{
args: []string{"--user_friend_email=hi"},
values: &goregistry.Value{
Values: []*goregistry.Value{
{
Name: "user_friend_email",
Type: "string",
},
},
},
expected: map[string]interface{}{
"user_friend_email": "hi",
},
},
}
for _, c := range cases {
t.Run(strings.Join(c.args, " "), func(t *testing.T) {
_, flags, err := splitCmdArgs(c.args)
if err != nil {
t.Fatal(err)
}
req, err := FlagsToRequest(flags, c.values)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(c.expected, req) {
spew.Dump("Expected:", c.expected, "got: ", req)
t.Fatalf("Expected %v, got %v", c.expected, req)
}
})
}
}
func TestAddMetadataToContext(t *testing.T) {
tests := []struct {
name string
metadataStrs []string
expectedKeys []string
expectedValues []string
}{
{
name: "Single metadata",
metadataStrs: []string{"Key1:Value1"},
expectedKeys: []string{"Key1"},
expectedValues: []string{"Value1"},
},
{
name: "Multiple metadata",
metadataStrs: []string{"Key1:Value1", "Key2:Value2"},
expectedKeys: []string{"Key1", "Key2"},
expectedValues: []string{"Value1", "Value2"},
},
{
name: "Metadata with spaces",
metadataStrs: []string{"Key1: Value1 ", " Key2 : Value2"},
expectedKeys: []string{"Key1", "Key2"},
expectedValues: []string{"Value1", "Value2"},
},
{
name: "Metadata with colon in value",
metadataStrs: []string{"Authorization:Bearer token:123"},
expectedKeys: []string{"Authorization"},
expectedValues: []string{"Bearer token:123"},
},
{
name: "Empty metadata",
metadataStrs: []string{},
expectedKeys: []string{},
expectedValues: []string{},
},
{
name: "Invalid metadata format",
metadataStrs: []string{"InvalidFormat"},
expectedKeys: []string{},
expectedValues: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
ctx = AddMetadataToContext(ctx, tt.metadataStrs)
md, ok := metadata.FromContext(ctx)
if len(tt.expectedKeys) == 0 && !ok {
return // Expected no metadata
}
if !ok && len(tt.expectedKeys) > 0 {
t.Fatal("Expected metadata in context but got none")
}
for i, key := range tt.expectedKeys {
value, found := md.Get(key)
if !found {
t.Fatalf("Expected key %s not found in metadata", key)
}
if value != tt.expectedValues[i] {
t.Fatalf("Expected value %s for key %s, got %s", tt.expectedValues[i], key, value)
}
}
})
}
}
+72
View File
@@ -0,0 +1,72 @@
// Package cliutil contains methods used across all cli commands
// @todo: get rid of os.Exits and use errors instread
package util
import (
"fmt"
"regexp"
"strings"
"github.com/urfave/cli/v2"
merrors "go-micro.dev/v6/errors"
)
type Exec func(*cli.Context, []string) ([]byte, error)
func Print(e Exec) func(*cli.Context) error {
return func(c *cli.Context) error {
rsp, err := e(c, c.Args().Slice())
if err != nil {
return CliError(err)
}
if len(rsp) > 0 {
fmt.Printf("%s\n", string(rsp))
}
return nil
}
}
// CliError returns a user friendly message from error. If we can't determine a good one returns an error with code 128
func CliError(err error) cli.ExitCoder {
if err == nil {
return nil
}
// if it's already a cli.ExitCoder we use this
cerr, ok := err.(cli.ExitCoder)
if ok {
return cerr
}
// grpc errors
if mname := regexp.MustCompile(`malformed method name: \\?"(\w+)\\?"`).FindStringSubmatch(err.Error()); len(mname) > 0 {
return cli.Exit(fmt.Sprintf(`Method name "%s" invalid format. Expecting service.endpoint`, mname[1]), 3)
}
if service := regexp.MustCompile(`service ([\w\.]+): route not found`).FindStringSubmatch(err.Error()); len(service) > 0 {
return cli.Exit(fmt.Sprintf(`Service "%s" not found`, service[1]), 4)
}
if service := regexp.MustCompile(`unknown service ([\w\.]+)`).FindStringSubmatch(err.Error()); len(service) > 0 {
if strings.Contains(service[0], ".") {
return cli.Exit(fmt.Sprintf(`Service method "%s" not found`, service[1]), 5)
}
return cli.Exit(fmt.Sprintf(`Service "%s" not found`, service[1]), 5)
}
if address := regexp.MustCompile(`Error while dialing dial tcp.*?([\w]+\.[\w:\.]+): `).FindStringSubmatch(err.Error()); len(address) > 0 {
return cli.Exit(fmt.Sprintf(`Failed to connect to micro server at %s`, address[1]), 4)
}
merr, ok := err.(*merrors.Error)
if !ok {
return cli.Exit(err, 128)
}
switch merr.Code {
case 408:
return cli.Exit("Request timed out", 1)
case 401:
// TODO check if not signed in, prompt to sign in
return cli.Exit("Not authorized to perform this request", 2)
}
// fallback to using the detail from the merr
return cli.Exit(merr.Detail, 127)
}