chore: import upstream snapshot with attribution
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
# Micro [](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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
`
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
`
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
`
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package template
|
||||
|
||||
var (
|
||||
GitIgnore = `
|
||||
{{.Alias}}
|
||||
.micro
|
||||
`
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
`
|
||||
)
|
||||
@@ -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
|
||||
`
|
||||
@@ -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}}`
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
`
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
`
|
||||
)
|
||||
@@ -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)
|
||||
` + "```" + `
|
||||
`
|
||||
)
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user