Files
code-utils/launch_detect.go
2026-08-15 17:18:00 +08:00

339 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}