diff --git a/.gitignore b/.gitignore index c0be3b1a..d67186a4 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ sandbox/proxy/claude-proxy-linux-* release/* sandbox/TODO-VNC.md sandbox/docker/chrome/PLAN.md +sandbox/DESIGN-REMOTE.md diff --git a/cmd/inspect.go b/cmd/inspect.go index 44955fa2..9c969e33 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -5,6 +5,7 @@ import ( "github.com/yaoapp/kun/maps" "github.com/yaoapp/kun/utils" "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/engine" "github.com/yaoapp/yao/share" ) @@ -14,10 +15,14 @@ var inspectCmd = &cobra.Command{ Long: L("Show app configure"), Run: func(cmd *cobra.Command, args []string) { Boot() + engine.InspectExtTools() res := maps.Map{ "version": share.VERSION, "config": config.Conf, } + if share.Tools != nil { + res["tools"] = share.Tools + } utils.Dump(res) }, } diff --git a/cmd/root.go b/cmd/root.go index 55ba5095..9e513f7a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -104,18 +104,6 @@ var rootCmd = &cobra.Command{ }, } -var studioCmd = &cobra.Command{ - Use: "studio", - Short: "Yao Studio CLI", - Long: `Yao Studio CLI`, - CompletionOptions: cobra.CompletionOptions{ - DisableDefaultCmd: true, - }, - Run: func(cmd *cobra.Command, args []string) { - cmd.Help() - }, -} - var suiCmd = &cobra.Command{ Use: "sui", Short: L("SUI Template Engine"), @@ -143,8 +131,6 @@ var agentCmd = &cobra.Command{ // Command initialize func init() { - // studioCmd.AddCommand(studio.RunCmd) - // Sui suiCmd.AddCommand(sui.WatchCmd) suiCmd.AddCommand(sui.BuildCmd) @@ -166,7 +152,6 @@ func init() { // socketCmd, // websocketCmd, // packCmd, - // studioCmd, suiCmd, agentCmd, // upgradeCmd, diff --git a/cmd/start.go b/cmd/start.go index d935beb6..d27df94a 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -169,7 +169,6 @@ var startCmd = &cobra.Command{ printConnectors(false) printStores(false) printMCPs(false) - // printStudio(false, host) } @@ -398,29 +397,6 @@ func printStores(silent bool) { } } -func printStudio(silent bool, host string) { - - if silent { - log.Info("[Studio] http://%s:%d", host, config.Conf.Studio.Port) - if config.Conf.Studio.Auto { - log.Info("[Studio] Secret: %s", config.Conf.Studio.Secret) - } - return - } - - fmt.Println(color.WhiteString("\n---------------------------------")) - fmt.Println(color.WhiteString(L("Yao Studio Server"))) - fmt.Println(color.WhiteString("---------------------------------")) - fmt.Print(color.CyanString("HOST : ")) - fmt.Print(color.WhiteString(" %s\n", config.Conf.Host)) - fmt.Print(color.CyanString("PORT : ")) - fmt.Print(color.WhiteString(" %d\n", config.Conf.Studio.Port)) - if config.Conf.Studio.Auto { - fmt.Print(color.CyanString("SECRET: ")) - fmt.Print(color.WhiteString(" %s\n", config.Conf.Studio.Secret)) - } -} - func printSchedules(silent bool) { if len(schedule.Schedules) == 0 { diff --git a/config/config.go b/config/config.go index 1d932fbf..ec1e860c 100644 --- a/config/config.go +++ b/config/config.go @@ -5,15 +5,12 @@ import ( "io" "os" "path/filepath" - "strings" "github.com/caarlos0/env/v6" "github.com/gin-gonic/gin" - "github.com/google/uuid" "github.com/joho/godotenv" "github.com/yaoapp/kun/exception" "github.com/yaoapp/kun/log" - "github.com/yaoapp/yao/crypto" "gopkg.in/natefinch/lumberjack.v2" ) @@ -138,16 +135,6 @@ func LoadWithRoot(root string) Config { cfg.AppSource = cfg.Root } - // Studio Secret - if cfg.Studio.Secret == "" { - v, err := crypto.Hash(crypto.HashTypes["SHA256"], uuid.New().String()) - if err != nil { - exception.New("Can't gengrate studio secret %s", 500, err.Error()).Throw() - } - cfg.Studio.Secret = strings.ToUpper(v) - cfg.Studio.Auto = true - } - // DataRoot if cfg.DataRoot == "" { cfg.DataRoot = filepath.Join(cfg.Root, "data") diff --git a/config/types.go b/config/types.go index 510b208f..772421e6 100644 --- a/config/types.go +++ b/config/types.go @@ -23,18 +23,10 @@ type Config struct { DB Database `json:"db,omitempty"` // The database config AllowFrom []string `json:"allowfrom,omitempty" envSeparator:"|" env:"YAO_ALLOW_FROM"` // Domain list the separator is | Session Session `json:"session,omitempty"` // Session Config - Studio Studio `json:"studio,omitempty"` // Studio config Runtime Runtime `json:"runtime,omitempty"` // Runtime config Trace Trace `json:"trace,omitempty"` // Trace config } -// Studio the studio config -type Studio struct { - Port int `json:"studio_port,omitempty" env:"YAO_STUDIO_PORT" envDefault:"5077"` // Studio port - Secret string `json:"studio_secret,omitempty" env:"YAO_STUDIO_SECRET"` // Studio Secret, if does not set, auto-generate a secret - Auto bool `json:"-"` -} - // Database 数据库配置 type Database struct { Driver string `json:"driver,omitempty" env:"YAO_DB_DRIVER" envDefault:"sqlite3"` // 数据库驱动 sqlite3| mysql| postgres diff --git a/engine/load.go b/engine/load.go index 1f8bcc1a..411d19a5 100644 --- a/engine/load.go +++ b/engine/load.go @@ -4,12 +4,15 @@ import ( "fmt" "log" "os" + "os/exec" "regexp" "strings" "time" "github.com/fatih/color" "github.com/yaoapp/gou/application" + "github.com/yaoapp/gou/ffmpeg" + "github.com/yaoapp/gou/pdf" "github.com/yaoapp/gou/process" "github.com/yaoapp/kun/exception" "github.com/yaoapp/yao/agent" @@ -139,6 +142,12 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string warnings = append(warnings, Warning{Widget: "Connector", Error: err}) } + // Inspect external tools (silent, non-fatal) + loadStep("ExtTools", func() error { + InspectExtTools() + return nil + }, callback) + // Load FileSystem err = loadStep("FileSystem", func() error { return fs.Load(cfg) @@ -755,6 +764,146 @@ func loadApp(root string) error { return nil } +// InspectExtTools performs silent detection of external tools (ffmpeg, pdf converters, docker) +// and stores the results in share.Tools for later access via Inspect / settings API. +// Exported so it can be called from both engine.Load() and cmd/inspect.go. +func InspectExtTools() { + tools := &share.ExtTools{} + + // Inspect ffmpeg/ffprobe + ffmpegStatus := ffmpeg.Inspect() + if s, ok := ffmpegStatus["ffmpeg"]; ok { + tools.FFmpeg = &share.ExtToolInfo{ + Name: s.Name, + Available: s.Available, + Path: s.Path, + Version: s.Version, + EnvVar: s.EnvVar, + Error: s.Error, + } + } + if s, ok := ffmpegStatus["ffprobe"]; ok { + tools.FFprobe = &share.ExtToolInfo{ + Name: s.Name, + Available: s.Available, + Path: s.Path, + Version: s.Version, + EnvVar: s.EnvVar, + Error: s.Error, + } + } + + // Inspect PDF tools + pdfStatus := pdf.Inspect() + if s, ok := pdfStatus["pdftoppm"]; ok { + tools.Pdftoppm = &share.ExtToolInfo{ + Name: s.Name, + Available: s.Available, + Path: s.Path, + Version: s.Version, + EnvVar: s.EnvVar, + Error: s.Error, + } + } + if s, ok := pdfStatus["mutool"]; ok { + tools.Mutool = &share.ExtToolInfo{ + Name: s.Name, + Available: s.Available, + Path: s.Path, + Version: s.Version, + EnvVar: s.EnvVar, + Error: s.Error, + } + } + if s, ok := pdfStatus["imagemagick"]; ok { + tools.ImageMagick = &share.ExtToolInfo{ + Name: s.Name, + Available: s.Available, + Path: s.Path, + Version: s.Version, + EnvVar: s.EnvVar, + Error: s.Error, + } + } + + // Inspect Docker + tools.Docker = inspectDocker() + + share.Tools = tools +} + +// inspectDocker silently detects Docker availability, version, and host configuration. +// Returns real runtime info: what Docker is actually connected to, not just env vars. +func inspectDocker() *share.DockerInfo { + sandboxHost := os.Getenv("YAO_SANDBOX_HOST") + sandboxMode := os.Getenv("YAO_SANDBOX_MODE") + + info := &share.DockerInfo{} + + // Try to find docker CLI path + dockerPath, err := exec.LookPath("docker") + if err != nil { + info.Available = false + info.Error = "docker not found in PATH" + return info + } + info.Path = dockerPath + + // Get real Docker context info: actual daemon address from docker info + cmd := exec.Command(dockerPath, "info", "--format", "{{.ClientInfo.Context}}") + ctxOutput, _ := cmd.Output() + dockerContext := strings.TrimSpace(string(ctxOutput)) + + // Get the actual daemon host from docker context inspect + var actualHost string + if dockerContext != "" { + cmd = exec.Command(dockerPath, "context", "inspect", dockerContext, "--format", "{{.Endpoints.docker.Host}}") + hostOutput, err := cmd.Output() + if err == nil { + actualHost = strings.TrimSpace(string(hostOutput)) + } + } + // Fallback: DOCKER_HOST env or let docker figure it out + if actualHost == "" { + actualHost = os.Getenv("DOCKER_HOST") + } + info.Host = actualHost + + // Get docker server version (also verifies daemon is reachable) + cmd = exec.Command(dockerPath, "version", "--format", "{{.Server.Version}}") + output, err := cmd.Output() + if err != nil { + info.Available = false + if actualHost != "" { + info.Error = fmt.Sprintf("docker daemon not reachable at %s", actualHost) + } else { + info.Error = "docker daemon not reachable" + } + return info + } + info.Available = true + info.Version = strings.TrimSpace(string(output)) + + // Determine sandbox mode from real host + if sandboxMode != "" { + info.Mode = sandboxMode + } else if actualHost == "" || strings.HasPrefix(actualHost, "unix://") || strings.HasPrefix(actualHost, "ssh://") || strings.HasPrefix(actualHost, "npipe://") { + info.Mode = "local" + } else if strings.HasPrefix(actualHost, "tcp://") { + info.Mode = "remote" + } else { + info.Mode = "local" + } + + // Yao sandbox env vars + info.EnvVars = map[string]string{ + "YAO_SANDBOX_HOST": sandboxHost, + "YAO_SANDBOX_MODE": sandboxMode, + } + + return info +} + func printErr(mode, widget string, err error) { message := fmt.Sprintf("[%s] %s", widget, err.Error()) if !strings.Contains(message, "does not exists") && !strings.Contains(message, "no such file or directory") && mode == "development" { diff --git a/engine/process.go b/engine/process.go index 04409926..006bdb73 100644 --- a/engine/process.go +++ b/engine/process.go @@ -38,12 +38,16 @@ func processPing(process *process.Process) interface{} { // processInspect 返回系统信息 func processInspect(process *process.Process) interface{} { - return map[string]interface{}{ + result := map[string]interface{}{ "VERSION": fmt.Sprintf("%s %s", share.VERSION, share.PRVERSION), "CUI": fmt.Sprintf("%s %s", share.CUI, share.PRCUI), "BUILDNAME": share.BUILDNAME, "CONFIG": config.Conf, } + if share.Tools != nil { + result["TOOLS"] = share.Tools + } + return result } // processFavicon 运行模型 MustCreate diff --git a/share/types.go b/share/types.go index a4782f07..7b79dcab 100644 --- a/share/types.go +++ b/share/types.go @@ -151,3 +151,38 @@ type AppRoot struct { Screens string Data string } + +// ExtToolInfo represents the detection result for a single external tool. +type ExtToolInfo struct { + Name string `json:"name"` // Tool name (e.g. "ffmpeg", "pdftoppm") + Available bool `json:"available"` // Whether the tool is available + Path string `json:"path,omitempty"` // Resolved executable path + Version string `json:"version,omitempty"` // Version string + EnvVar string `json:"env_var,omitempty"` // Environment variable name for custom path override + Error string `json:"error,omitempty"` // Error message if not available +} + +// DockerInfo represents the detection result for Docker. +type DockerInfo struct { + Available bool `json:"available"` // Whether Docker daemon is reachable + Path string `json:"path,omitempty"` // Docker CLI path + Version string `json:"version,omitempty"` // Docker server version + Mode string `json:"mode,omitempty"` // "local" or "remote" + Host string `json:"host,omitempty"` // DOCKER_HOST value (empty = local socket) + Error string `json:"error,omitempty"` // Error message if not available + EnvVars map[string]string `json:"env_vars,omitempty"` // Related Yao environment variables +} + +// ExtTools holds the detection results for all external tools. +// Populated during engine.Load() and exposed via utils.app.Inspect. +type ExtTools struct { + FFmpeg *ExtToolInfo `json:"ffmpeg"` + FFprobe *ExtToolInfo `json:"ffprobe"` + Pdftoppm *ExtToolInfo `json:"pdftoppm"` + Mutool *ExtToolInfo `json:"mutool"` + ImageMagick *ExtToolInfo `json:"imagemagick"` + Docker *DockerInfo `json:"docker"` +} + +// Tools holds the global external tool detection results. +var Tools *ExtTools diff --git a/widgets/login/process.go b/widgets/login/process.go index 8a2ef0ba..43781277 100644 --- a/widgets/login/process.go +++ b/widgets/login/process.go @@ -10,7 +10,6 @@ import ( "github.com/yaoapp/kun/exception" "github.com/yaoapp/kun/log" "github.com/yaoapp/kun/maps" - "github.com/yaoapp/yao/config" "github.com/yaoapp/yao/helper" "golang.org/x/crypto/bcrypt" ) @@ -113,20 +112,6 @@ func auth(field string, value string, password string, sid string) maps.Map { session.Global().Expire(time.Duration(token.ExpiresAt)*time.Second).ID(sid).Set("user", row) session.Global().Expire(time.Duration(token.ExpiresAt)*time.Second).ID(sid).Set("issuer", "yao") - studio := map[string]interface{}{} - if config.Conf.Mode == "development" { - - studioToken := helper.JwtMake(id, map[string]interface{}{}, map[string]interface{}{ - "expires_at": expiresAt, - "sid": sid, - "issuer": "yao", - }, []byte(config.Conf.Studio.Secret)) - - studio["port"] = config.Conf.Studio.Port - studio["token"] = studioToken.Token - studio["expires_at"] = studioToken.ExpiresAt - } - // Get user menus menus := process.New("yao.app.menu").WithSID(sid).Run() return maps.Map{ @@ -134,6 +119,5 @@ func auth(field string, value string, password string, sid string) maps.Map { "token": token.Token, "user": row, "menus": menus, - "studio": studio, } }