339 lines
8.5 KiB
Go
339 lines
8.5 KiB
Go
|
|
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
|
|||
|
|
}
|