Files
fchengyan 52ced3710f fix(build): embed commit SHA for release provenance (#1571 part 2) (#1590)
* fix(build): embed commit SHA for release provenance (#1571 part 2)

Docker builds exclude .git via .dockerignore, so buildvcs cannot read
VCS metadata and published images carry no commit information. A running
image cannot be lined up with the source commit it was built from.

Embed cmd.CommitSHA at link time (maintainer-endorsed option 2) and
surface it in goclaw version output:

- cmd: add CommitSHA var, print commit in version cmd when injected
- Makefile: pass git rev-parse HEAD via LDFLAGS
- Dockerfile: accept COMMIT_SHA build arg (default unknown)
- docker-compose.yml: pass GOCLAW_COMMIT_SHA through as build arg
- release workflows: inject github.sha into release and dev-beta builds

Backward compatible: binaries built without the flag keep the existing
version output format.

* fix(build): pass COMMIT_SHA to docker image builds, surface it in doctor/upgrade

Review follow-up for PR #1590:

- Add COMMIT_SHA=${{ github.sha }} to the build-args of every
  docker/build-push-action step (release.yaml, dev-beta-release.yaml,
  release-beta.yaml, fork-image.yaml) so published images — the
  artifact issue #1571 is about — carry the commit, not just release
  tarball binaries.
- Surface the commit in doctor and upgrade output (App version line)
  via a shared commitSuffix() helper, so operators can read provenance
  from logs without exec'ing goclaw version (review suggestion #2).
- version cmd refactored onto the same helper; output unchanged.
2026-09-30 15:38:53 +07:00

246 lines
7.0 KiB
Go

package cmd
import (
"context"
"database/sql"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/spf13/cobra"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/upgrade"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
func doctorCmd() *cobra.Command {
return &cobra.Command{
Use: "doctor",
Short: "Check system environment and configuration health",
Run: func(cmd *cobra.Command, args []string) {
runDoctor()
},
}
}
func runDoctor() {
fmt.Println("goclaw doctor")
fmt.Printf(" Version: %s%s (protocol %d)\n", Version, commitSuffix(), protocol.ProtocolVersion)
fmt.Printf(" OS: %s/%s\n", runtime.GOOS, runtime.GOARCH)
fmt.Printf(" Go: %s\n", runtime.Version())
fmt.Println()
// Config
cfgPath := resolveConfigPath()
fmt.Printf(" Config: %s", cfgPath)
if _, err := os.Stat(cfgPath); err != nil {
fmt.Println(" (NOT FOUND)")
} else {
fmt.Println(" (OK)")
}
cfg, err := config.Load(cfgPath)
if err != nil {
fmt.Printf(" Config load error: %s\n", err)
return
}
// Database
var db *sql.DB
if cfg.Database.PostgresDSN == "" {
fmt.Println()
fmt.Println(" Database:")
fmt.Printf(" %-12s NOT CONFIGURED (set GOCLAW_POSTGRES_DSN)\n", "Status:")
} else {
fmt.Println()
fmt.Println(" Database:")
var dbErr error
db, dbErr = sql.Open("pgx", cfg.Database.PostgresDSN)
if dbErr != nil {
fmt.Printf(" %-12s CONNECT FAILED (%s)\n", "Status:", dbErr)
db = nil
} else if pingErr := db.Ping(); pingErr != nil {
fmt.Printf(" %-12s CONNECT FAILED (%s)\n", "Status:", pingErr)
db.Close()
db = nil
} else {
defer db.Close()
s, schemaErr := upgrade.CheckSchema(db)
if schemaErr != nil {
fmt.Printf(" %-12s CHECK FAILED (%s)\n", "Schema:", schemaErr)
} else if s.Dirty {
fmt.Printf(" %-12s v%d (DIRTY — run: goclaw migrate force %d)\n", "Schema:", s.CurrentVersion, s.CurrentVersion-1)
} else if s.Compatible {
fmt.Printf(" %-12s v%d (up to date)\n", "Schema:", s.CurrentVersion)
} else if s.CurrentVersion > s.RequiredVersion {
fmt.Printf(" %-12s v%d (binary too old, requires v%d)\n", "Schema:", s.CurrentVersion, s.RequiredVersion)
} else {
fmt.Printf(" %-12s v%d (upgrade needed — run: goclaw upgrade)\n", "Schema:", s.CurrentVersion)
}
pending, hookErr := upgrade.PendingHooks(context.Background(), db)
if hookErr == nil && len(pending) > 0 {
fmt.Printf(" %-12s %d pending\n", "Data hooks:", len(pending))
} else if hookErr == nil {
fmt.Printf(" %-12s all applied\n", "Data hooks:")
}
}
}
// Providers — show DB providers, plus config-only providers (env vars).
fmt.Println()
fmt.Println(" Providers:")
if db != nil {
checkDBProviders(db)
checkProvider("Anthropic (env)", cfg.Providers.Anthropic.APIKey)
checkProvider("OpenAI (env)", cfg.Providers.OpenAI.APIKey)
checkProvider("API Route (env)", cfg.Providers.APIRoute.APIKey)
checkProvider("Requesty (env)", cfg.Providers.Requesty.APIKey)
checkProvider("OpenRouter (env)", cfg.Providers.OpenRouter.APIKey)
} else {
checkProvider("Anthropic", cfg.Providers.Anthropic.APIKey)
checkProvider("OpenAI", cfg.Providers.OpenAI.APIKey)
checkProvider("API Route", cfg.Providers.APIRoute.APIKey)
checkProvider("Requesty", cfg.Providers.Requesty.APIKey)
checkProvider("OpenRouter", cfg.Providers.OpenRouter.APIKey)
checkProvider("Gemini", cfg.Providers.Gemini.APIKey)
checkProvider("Groq", cfg.Providers.Groq.APIKey)
checkProvider("DeepSeek", cfg.Providers.DeepSeek.APIKey)
checkProvider("Mistral", cfg.Providers.Mistral.APIKey)
checkProvider("XAI", cfg.Providers.XAI.APIKey)
}
// Channels — show DB channels, fallback to config channels.
fmt.Println()
fmt.Println(" Channels:")
if db != nil {
checkDBChannels(db)
} else {
checkChannel("Telegram", cfg.Channels.Telegram.Enabled, cfg.Channels.Telegram.Token != "")
checkChannel("Discord", cfg.Channels.Discord.Enabled, cfg.Channels.Discord.Token != "")
checkChannel("Zalo", cfg.Channels.Zalo.Enabled, cfg.Channels.Zalo.Token != "")
checkChannel("Feishu", cfg.Channels.Feishu.Enabled, cfg.Channels.Feishu.AppID != "")
checkChannel("WhatsApp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.Enabled)
}
// External tools
fmt.Println()
fmt.Println(" External Tools:")
checkBinary("docker")
checkBinary("curl")
checkBinary("git")
// Workspace
fmt.Println()
ws := config.ExpandHome(cfg.Agents.Defaults.Workspace)
fmt.Printf(" Workspace: %s", ws)
if _, err := os.Stat(ws); err != nil {
fmt.Println(" (NOT FOUND)")
} else {
fmt.Println(" (OK)")
}
fmt.Println()
fmt.Println("Doctor check complete.")
}
func checkProvider(name, apiKey string) {
if apiKey != "" {
maskedKey := apiKey[:4] + strings.Repeat("*", len(apiKey)-8) + apiKey[len(apiKey)-4:]
fmt.Printf(" %-12s %s\n", name+":", maskedKey)
} else {
fmt.Printf(" %-12s (not configured)\n", name+":")
}
}
func checkChannel(name string, enabled, hasCredentials bool) {
status := "disabled"
if enabled && hasCredentials {
status = "enabled"
} else if enabled {
status = "enabled (missing credentials)"
}
fmt.Printf(" %-12s %s\n", name+":", status)
}
func checkDBChannels(db *sql.DB) {
rows, err := db.QueryContext(context.Background(),
"SELECT name, channel_type, enabled FROM channel_instances ORDER BY channel_type, name")
if err != nil {
fmt.Printf(" (could not query channels: %s)\n", err)
return
}
defer rows.Close()
found := false
for rows.Next() {
var name, channelType string
var enabled bool
if err := rows.Scan(&name, &channelType, &enabled); err != nil {
continue
}
found = true
status := "enabled"
if !enabled {
status = "disabled"
}
label := fmt.Sprintf("%s/%s", channelType, name)
fmt.Printf(" %-24s %s\n", label+":", status)
}
if err := rows.Err(); err != nil {
fmt.Printf(" (could not read channels: %s)\n", err)
return
}
if !found {
fmt.Println(" (none configured in database)")
}
}
func checkDBProviders(db *sql.DB) {
rows, err := db.QueryContext(context.Background(),
"SELECT name, COALESCE(NULLIF(display_name, ''), name), enabled, (api_key IS NOT NULL AND api_key != '') AS has_key FROM llm_providers ORDER BY name")
if err != nil {
fmt.Printf(" (could not query providers: %s)\n", err)
return
}
defer rows.Close()
found := false
for rows.Next() {
var name, displayName string
var enabled, hasKey bool
if err := rows.Scan(&name, &displayName, &enabled, &hasKey); err != nil {
continue
}
found = true
status := "enabled"
if !enabled {
status = "disabled"
}
if !hasKey {
status += " (no API key)"
}
fmt.Printf(" %-16s %s\n", displayName+":", status)
}
if err := rows.Err(); err != nil {
fmt.Printf(" (could not read providers: %s)\n", err)
return
}
if !found {
fmt.Println(" (none configured in database)")
}
}
func checkBinary(name string) {
path, err := exec.LookPath(name)
if err != nil {
fmt.Printf(" %-12s NOT FOUND\n", name+":")
} else {
fmt.Printf(" %-12s %s\n", name+":", path)
}
}