Files
code-utils/launch_detect.go

339 lines
8.5 KiB
Go
Raw Normal View History

2026-08-15 17:18:00 +08:00
package main
// launch_detect.go根据项目目录识别种类并推荐启动命令 / 默认端口。
// 优先读 package.json scripts、go.mod、pom.xml 等标记文件;再回落到静态种类建议。
import (
"encoding/json"
"os"
"path/filepath"
"strings"
)
type npmPackageJSON struct {
Name string `json:"name"`
Scripts map[string]string `json:"scripts"`
}
func fileExists(dir, name string) bool {
st, e := os.Stat(filepath.Join(dir, name))
return e == nil && !st.IsDir()
}
func dirExists(dir, name string) bool {
st, e := os.Stat(filepath.Join(dir, name))
return e == nil && st.IsDir()
}
func hasExtInDir(dir, ext string) bool {
ents, e := os.ReadDir(dir)
if e != nil {
return false
}
ext = strings.ToLower(ext)
n := 0
for _, ent := range ents {
if ent.IsDir() {
continue
}
if strings.EqualFold(filepath.Ext(ent.Name()), ext) {
n++
if n >= 1 {
return true
}
}
}
return false
}
// findLaunchExes 收集可直接启动的 .exe相对工作目录的路径优先浅层与常见输出目录。
func findLaunchExes(dir string) []string {
dir = strings.TrimSpace(dir)
if dir == "" {
return nil
}
var out []string
seen := map[string]bool{}
add := func(abs string) {
rel, e := filepath.Rel(dir, abs)
if e != nil {
rel = filepath.Base(abs)
}
rel = filepath.ToSlash(rel)
key := strings.ToLower(rel)
if seen[key] {
return
}
// 跳过安装器 / 卸载 / 明显工具
base := strings.ToLower(filepath.Base(abs))
for _, skip := range []string{"uninstall", "setup", "installer", "update", "crashpad", "vcredist"} {
if strings.Contains(base, skip) {
return
}
}
seen[key] = true
// Windows 路径含空格时加引号,便于 cmd /C 执行
if strings.ContainsAny(rel, " \t") {
out = append(out, `"`+filepath.FromSlash(rel)+`"`)
} else {
out = append(out, filepath.FromSlash(rel))
}
}
scan := func(root string, maxDepth int) {
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
rel, _ := filepath.Rel(root, path)
depth := 0
if rel != "." {
depth = strings.Count(rel, string(filepath.Separator))
}
if d.IsDir() {
name := strings.ToLower(d.Name())
if name == "node_modules" || name == ".git" || name == "vendor" {
return filepath.SkipDir
}
if depth >= maxDepth {
return filepath.SkipDir
}
return nil
}
if strings.EqualFold(filepath.Ext(d.Name()), ".exe") {
add(path)
}
return nil
})
}
scan(dir, 1) // 根目录
for _, sub := range []string{"bin", "dist", "build", "out", "release", "Debug", "Release", "x64", "publish"} {
p := filepath.Join(dir, sub)
if st, e := os.Stat(p); e == nil && st.IsDir() {
scan(p, 2)
}
}
if len(out) > 8 {
out = out[:8]
}
return out
}
// detectLaunchProfile 扫描目录,返回种类、名称、默认端口与推荐启停命令。
func detectLaunchProfile(dir string) LaunchProfile {
dir = strings.TrimSpace(dir)
out := LaunchProfile{
Dir: dir,
Kind: "other",
Name: filepath.Base(dir),
}
if dir == "" {
return out
}
if st, e := os.Stat(dir); e != nil || !st.IsDir() {
return out
}
base := filepath.Base(dir)
out.Name = base
switch {
case fileExists(dir, "package.json"):
out.Kind = "node"
out.Port = 5173
raw, e := os.ReadFile(filepath.Join(dir, "package.json"))
pkg := npmPackageJSON{}
if e == nil {
_ = json.Unmarshal(raw, &pkg)
}
if n := strings.TrimSpace(pkg.Name); n != "" {
// 去掉 npm scope@org/app → app
if i := strings.LastIndex(n, "/"); i >= 0 && i+1 < len(n) {
n = n[i+1:]
}
out.Name = n
}
starts := []string{}
pick := []string{"dev", "start", "serve", "preview", "develop"}
pm := "npm"
if fileExists(dir, "pnpm-lock.yaml") {
pm = "pnpm"
} else if fileExists(dir, "yarn.lock") {
pm = "yarn"
}
run := func(script string) string {
switch pm {
case "pnpm":
return "pnpm " + script
case "yarn":
return "yarn " + script
default:
return "npm run " + script
}
}
if pkg.Scripts != nil {
for _, k := range pick {
if _, ok := pkg.Scripts[k]; ok {
starts = append(starts, run(k))
}
}
}
if len(starts) == 0 {
starts = append([]string{}, launchSuggestions["node"].Start...)
} else {
// 补全常见命令,去重
for _, c := range launchSuggestions["node"].Start {
dup := false
for _, s := range starts {
if s == c {
dup = true
break
}
}
if !dup {
starts = append(starts, c)
}
}
}
if fileExists(dir, "next.config.js") || fileExists(dir, "next.config.mjs") || fileExists(dir, "next.config.ts") || dirExists(dir, ".next") {
out.Port = 3000
}
if fileExists(dir, "vite.config.js") || fileExists(dir, "vite.config.ts") || fileExists(dir, "vite.config.mjs") {
out.Port = 5173
}
out.Start = starts
if len(starts) > 0 {
out.StartCmd = starts[0]
}
case fileExists(dir, "go.mod"):
out.Kind = "go"
out.Port = 8080
if b, e := os.ReadFile(filepath.Join(dir, "go.mod")); e == nil {
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "module ") {
mod := strings.TrimSpace(strings.TrimPrefix(line, "module "))
if i := strings.LastIndex(mod, "/"); i >= 0 {
mod = mod[i+1:]
}
if mod != "" {
out.Name = mod
}
break
}
}
}
out.Start = append([]string{}, launchSuggestions["go"].Start...)
if fileExists(dir, "main.go") {
out.Start = append([]string{"go run .", "go run main.go"}, out.Start...)
}
out.StartCmd = out.Start[0]
case fileExists(dir, "manage.py"):
out.Kind = "python"
out.Port = 8000
out.Start = []string{"python manage.py runserver", "python manage.py runserver 0.0.0.0:8000"}
out.Start = append(out.Start, launchSuggestions["python"].Start...)
out.StartCmd = out.Start[0]
case fileExists(dir, "pyproject.toml") || fileExists(dir, "requirements.txt") || fileExists(dir, "main.py") || fileExists(dir, "app.py"):
out.Kind = "python"
out.Port = 8000
starts := []string{}
if fileExists(dir, "main.py") {
starts = append(starts, "uvicorn main:app --reload", "python main.py")
}
if fileExists(dir, "app.py") {
starts = append(starts, "uvicorn app:app --reload", "python app.py", "flask --app app run")
}
starts = append(starts, launchSuggestions["python"].Start...)
out.Start = uniqStrings(starts)
out.StartCmd = out.Start[0]
case fileExists(dir, "pom.xml") || fileExists(dir, "build.gradle") || fileExists(dir, "build.gradle.kts"):
out.Kind = "java"
out.Port = 8080
out.Start = append([]string{}, launchSuggestions["java"].Start...)
out.StartCmd = out.Start[0]
case fileExists(dir, "artisan") || fileExists(dir, "composer.json"):
out.Kind = "php"
out.Port = 8000
out.Start = append([]string{}, launchSuggestions["php"].Start...)
out.StartCmd = out.Start[0]
case hasExtInDir(dir, ".csproj") || hasExtInDir(dir, ".sln"):
out.Kind = "dotnet"
out.Port = 5000
out.Start = append([]string{}, launchSuggestions["dotnet"].Start...)
out.StartCmd = out.Start[0]
default:
// Windows 可执行文件:目录内 / bin / dist 下的 .exe 作为默认启动方式
if exes := findLaunchExes(dir); len(exes) > 0 {
out.Kind = "exe"
out.Start = exes
out.StartCmd = exes[0]
} else {
out.Kind = "other"
out.Start = nil
}
}
if sug, ok := launchSuggestions[out.Kind]; ok {
out.Stop = append([]string{}, sug.Stop...)
}
if out.Name == "" || out.Name == "." || out.Name == string(filepath.Separator) {
out.Name = base
}
return out
}
func uniqStrings(in []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
func normLaunchPath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return ""
}
p = filepath.Clean(p)
return strings.ToLower(filepath.ToSlash(p))
}
// matchProjectByDir 用目录前缀匹配「我的项目」,取最长路径命中。
func (a *App) matchProjectByDir(dir string) (Project, bool) {
nd := normLaunchPath(dir)
if nd == "" || a.store == nil {
return Project{}, false
}
ps, e := a.store.ListProjects(0)
if e != nil {
return Project{}, false
}
var best Project
bestLen := 0
for _, p := range ps {
np := normLaunchPath(p.Path)
if np == "" {
continue
}
if nd == np || strings.HasPrefix(nd, np+"/") {
if len(np) > bestLen {
best, bestLen = p, len(np)
}
}
}
return best, bestLen > 0
}