mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 15:05:40 +00:00
9650c0ec0b
* feat(web): add workspace context editor for TASK-131 * feat(workspaces): populate context during onboarding for TASK-132
384 lines
11 KiB
Go
384 lines
11 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// ProjectInfo holds detected project metadata.
|
|
type ProjectInfo struct {
|
|
Language string // go, node, rust, python, java, etc.
|
|
BuildTool string // make, npm, cargo, pip, maven, etc.
|
|
BuildCmd string // detected build command
|
|
SetupCmd string // detected setup/install command
|
|
TestCmd string // detected test command
|
|
LintCmd string // detected lint/format command
|
|
DevCmd string // detected dev/run command
|
|
WebBuildCmd string // detected frontend build command
|
|
PackageManagers []string // detected package managers (e.g., npm, go)
|
|
HasCI bool // CI config detected
|
|
CIProvider string // github-actions, gitlab, circleci, etc.
|
|
HasLinter bool // linter config detected
|
|
Frameworks []string // detected frameworks (e.g., svelte, react, chi)
|
|
}
|
|
|
|
// DetectProject analyzes the current directory to determine project type,
|
|
// build system, test runner, CI, and other metadata.
|
|
func DetectProject(dir string) ProjectInfo {
|
|
info := ProjectInfo{}
|
|
|
|
// Detect language and build tool
|
|
if fileExists(dir, "go.mod") {
|
|
info.Language = "go"
|
|
info.PackageManagers = append(info.PackageManagers, "go")
|
|
if fileExists(dir, "Makefile") {
|
|
info.BuildTool = "make"
|
|
info.BuildCmd = detectMakeBuildCmd(dir)
|
|
info.SetupCmd = detectMakeSetupCmd(dir)
|
|
info.TestCmd = detectMakeTestCmd(dir)
|
|
info.DevCmd = detectMakeDevCmd(dir)
|
|
} else {
|
|
info.BuildTool = "go"
|
|
info.BuildCmd = "go build ./..."
|
|
info.TestCmd = "go test ./..."
|
|
}
|
|
} else if fileExists(dir, "package.json") {
|
|
info.Language = "node"
|
|
info.BuildTool = "npm"
|
|
info.BuildCmd = "npm run build"
|
|
info.SetupCmd = "npm install"
|
|
info.TestCmd = "npm test"
|
|
info.PackageManagers = append(info.PackageManagers, "npm")
|
|
if fileExists(dir, "yarn.lock") {
|
|
info.BuildTool = "yarn"
|
|
info.BuildCmd = "yarn build"
|
|
info.SetupCmd = "yarn install"
|
|
info.TestCmd = "yarn test"
|
|
info.PackageManagers = []string{"yarn"}
|
|
} else if fileExists(dir, "pnpm-lock.yaml") {
|
|
info.BuildTool = "pnpm"
|
|
info.BuildCmd = "pnpm build"
|
|
info.SetupCmd = "pnpm install"
|
|
info.TestCmd = "pnpm test"
|
|
info.PackageManagers = []string{"pnpm"}
|
|
}
|
|
// Check for TypeScript
|
|
if fileExists(dir, "tsconfig.json") {
|
|
info.Language = "typescript"
|
|
}
|
|
} else if fileExists(dir, "Cargo.toml") {
|
|
info.Language = "rust"
|
|
info.BuildTool = "cargo"
|
|
info.BuildCmd = "cargo build"
|
|
info.SetupCmd = "cargo fetch"
|
|
info.TestCmd = "cargo test"
|
|
info.PackageManagers = append(info.PackageManagers, "cargo")
|
|
} else if fileExists(dir, "pyproject.toml") || fileExists(dir, "setup.py") || fileExists(dir, "requirements.txt") {
|
|
info.Language = "python"
|
|
if fileExists(dir, "pyproject.toml") {
|
|
info.BuildTool = "pip"
|
|
info.SetupCmd = "pip install -e ."
|
|
info.TestCmd = "pytest"
|
|
} else {
|
|
info.BuildTool = "pip"
|
|
info.SetupCmd = "pip install -r requirements.txt"
|
|
info.TestCmd = "python -m pytest"
|
|
}
|
|
info.PackageManagers = append(info.PackageManagers, "pip")
|
|
} else if fileExists(dir, "pom.xml") {
|
|
info.Language = "java"
|
|
info.BuildTool = "maven"
|
|
info.BuildCmd = "mvn compile"
|
|
info.SetupCmd = "mvn dependency:resolve"
|
|
info.TestCmd = "mvn test"
|
|
info.PackageManagers = append(info.PackageManagers, "maven")
|
|
} else if fileExists(dir, "build.gradle") || fileExists(dir, "build.gradle.kts") {
|
|
info.Language = "java"
|
|
info.BuildTool = "gradle"
|
|
info.BuildCmd = "gradle build"
|
|
info.SetupCmd = "gradle dependencies"
|
|
info.TestCmd = "gradle test"
|
|
info.PackageManagers = append(info.PackageManagers, "gradle")
|
|
} else if fileExists(dir, "Makefile") {
|
|
info.BuildTool = "make"
|
|
info.BuildCmd = detectMakeBuildCmd(dir)
|
|
info.SetupCmd = detectMakeSetupCmd(dir)
|
|
info.TestCmd = detectMakeTestCmd(dir)
|
|
info.DevCmd = detectMakeDevCmd(dir)
|
|
}
|
|
|
|
// Detect CI
|
|
if dirExists(dir, ".github", "workflows") {
|
|
info.HasCI = true
|
|
info.CIProvider = "github-actions"
|
|
} else if fileExists(dir, ".gitlab-ci.yml") {
|
|
info.HasCI = true
|
|
info.CIProvider = "gitlab"
|
|
} else if dirExists(dir, ".circleci") {
|
|
info.HasCI = true
|
|
info.CIProvider = "circleci"
|
|
}
|
|
|
|
// Detect linter
|
|
if fileExists(dir, ".eslintrc.json") || fileExists(dir, ".eslintrc.js") || fileExists(dir, "eslint.config.js") || fileExists(dir, "eslint.config.mjs") {
|
|
info.HasLinter = true
|
|
info.LintCmd = "npm run lint"
|
|
} else if fileExists(dir, ".golangci.yml") || fileExists(dir, ".golangci.yaml") {
|
|
info.HasLinter = true
|
|
info.LintCmd = "golangci-lint run"
|
|
} else if fileExists(dir, ".prettierrc") || fileExists(dir, ".prettierrc.json") {
|
|
info.HasLinter = true
|
|
info.LintCmd = "prettier --check ."
|
|
} else if fileExists(dir, "rustfmt.toml") || fileExists(dir, ".rustfmt.toml") {
|
|
info.HasLinter = true
|
|
info.LintCmd = "cargo fmt --check"
|
|
} else if fileExists(dir, ".flake8") || fileExists(dir, "ruff.toml") || fileExists(dir, ".ruff.toml") {
|
|
info.HasLinter = true
|
|
info.LintCmd = "ruff check ."
|
|
}
|
|
|
|
detectFrontendProject(dir, &info)
|
|
info.PackageManagers = uniqueNonEmpty(info.PackageManagers)
|
|
info.Frameworks = uniqueNonEmpty(info.Frameworks)
|
|
|
|
return info
|
|
}
|
|
|
|
// SuggestedConventions returns convention titles from the library that match
|
|
// the detected project info. Returns a map of title → customized content.
|
|
func SuggestedConventions(info ProjectInfo) map[string]string {
|
|
suggestions := make(map[string]string)
|
|
|
|
// Build convention
|
|
if info.BuildTool != "" {
|
|
var buildCmd string
|
|
switch info.BuildTool {
|
|
case "make":
|
|
buildCmd = "make build"
|
|
case "npm":
|
|
buildCmd = "npm run build"
|
|
case "yarn":
|
|
buildCmd = "yarn build"
|
|
case "pnpm":
|
|
buildCmd = "pnpm build"
|
|
case "cargo":
|
|
buildCmd = "cargo build"
|
|
case "go":
|
|
buildCmd = "go build ./..."
|
|
case "maven":
|
|
buildCmd = "mvn compile"
|
|
case "gradle":
|
|
buildCmd = "gradle build"
|
|
default:
|
|
buildCmd = info.BuildTool + " build"
|
|
}
|
|
suggestions["Rebuild after code changes"] = "After modifying source code, run `" + buildCmd + "` to verify everything compiles and builds successfully."
|
|
}
|
|
|
|
// Test convention
|
|
if info.TestCmd != "" {
|
|
suggestions["Run tests before completing tasks"] = "Run the project's test suite (`" + info.TestCmd + "`) before marking any task as done. If tests fail, fix them before completing the task."
|
|
}
|
|
|
|
// Linter convention
|
|
if info.HasLinter {
|
|
suggestions["Run linter before committing"] = "Run the project's linter/formatter before committing code to ensure consistent code style."
|
|
}
|
|
|
|
// CI convention
|
|
if info.HasCI {
|
|
suggestions["Verify locally before PR"] = "Before creating a PR, verify the changes work locally: build succeeds, tests pass, and the feature works as expected."
|
|
}
|
|
|
|
// Always suggest these general ones
|
|
suggestions["Commit after task completion"] = "Create a git commit with a descriptive message after completing each discrete unit of work. Reference the task slug or item number in the commit message."
|
|
suggestions["Update task status when starting work"] = "When starting work on a task, update its status to in-progress: `pad item update <ref> --status in-progress`"
|
|
|
|
return suggestions
|
|
}
|
|
|
|
func fileExists(dir, name string) bool {
|
|
_, err := os.Stat(filepath.Join(dir, name))
|
|
return err == nil
|
|
}
|
|
|
|
func dirExists(parts ...string) bool {
|
|
info, err := os.Stat(filepath.Join(parts...))
|
|
return err == nil && info.IsDir()
|
|
}
|
|
|
|
func detectMakeTestCmd(dir string) string {
|
|
data, err := os.ReadFile(filepath.Join(dir, "Makefile"))
|
|
if err != nil {
|
|
return "make test"
|
|
}
|
|
content := string(data)
|
|
// Check if Makefile has a test target
|
|
if strings.Contains(content, "test:") || strings.Contains(content, "test :") {
|
|
return "make test"
|
|
}
|
|
// Fall back to language-specific if no test target
|
|
if strings.Contains(content, "go test") {
|
|
return "go test ./..."
|
|
}
|
|
return "make test"
|
|
}
|
|
|
|
func detectMakeBuildCmd(dir string) string {
|
|
data, err := os.ReadFile(filepath.Join(dir, "Makefile"))
|
|
if err != nil {
|
|
return "make build"
|
|
}
|
|
content := string(data)
|
|
if strings.Contains(content, "build:") || strings.Contains(content, "build :") {
|
|
return "make build"
|
|
}
|
|
if strings.Contains(content, "go build") {
|
|
return "go build ./..."
|
|
}
|
|
return "make build"
|
|
}
|
|
|
|
func detectMakeSetupCmd(dir string) string {
|
|
data, err := os.ReadFile(filepath.Join(dir, "Makefile"))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
content := string(data)
|
|
if strings.Contains(content, "install:") || strings.Contains(content, "install :") {
|
|
return "make install"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func detectMakeDevCmd(dir string) string {
|
|
data, err := os.ReadFile(filepath.Join(dir, "Makefile"))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
content := string(data)
|
|
if strings.Contains(content, "dev:") || strings.Contains(content, "dev :") {
|
|
return "make dev"
|
|
}
|
|
if strings.Contains(content, "serve:") || strings.Contains(content, "serve :") {
|
|
return "make serve"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type packageManifest struct {
|
|
Dependencies map[string]string `json:"dependencies"`
|
|
DevDependencies map[string]string `json:"devDependencies"`
|
|
Scripts map[string]string `json:"scripts"`
|
|
}
|
|
|
|
func detectFrontendProject(dir string, info *ProjectInfo) {
|
|
webDir := filepath.Join(dir, "web")
|
|
if !fileExists(webDir, "package.json") {
|
|
return
|
|
}
|
|
|
|
info.PackageManagers = append(info.PackageManagers, detectNodePackageManager(webDir))
|
|
|
|
manifest, err := readPackageManifest(filepath.Join(webDir, "package.json"))
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
deps := map[string]string{}
|
|
for name, version := range manifest.Dependencies {
|
|
deps[name] = version
|
|
}
|
|
for name, version := range manifest.DevDependencies {
|
|
deps[name] = version
|
|
}
|
|
|
|
if _, ok := deps["@sveltejs/kit"]; ok {
|
|
info.Frameworks = append(info.Frameworks, "sveltekit")
|
|
info.PackageManagers = append(info.PackageManagers, "npm")
|
|
if info.Language == "" {
|
|
info.Language = "typescript"
|
|
}
|
|
}
|
|
if _, ok := deps["react"]; ok {
|
|
info.Frameworks = append(info.Frameworks, "react")
|
|
}
|
|
if _, ok := deps["vue"]; ok {
|
|
info.Frameworks = append(info.Frameworks, "vue")
|
|
}
|
|
|
|
pm := detectNodePackageManager(webDir)
|
|
if scriptExists(manifest.Scripts, "build") {
|
|
info.WebBuildCmd = nodeScriptCmd(pm, "build")
|
|
}
|
|
if scriptExists(manifest.Scripts, "lint") && info.LintCmd == "" {
|
|
info.LintCmd = nodeScriptCmd(pm, "lint")
|
|
info.HasLinter = true
|
|
}
|
|
if scriptExists(manifest.Scripts, "dev") && info.DevCmd == "" {
|
|
info.DevCmd = "cd web && " + nodeScriptCmd(pm, "dev")
|
|
}
|
|
}
|
|
|
|
func detectNodePackageManager(dir string) string {
|
|
if fileExists(dir, "yarn.lock") {
|
|
return "yarn"
|
|
}
|
|
if fileExists(dir, "pnpm-lock.yaml") {
|
|
return "pnpm"
|
|
}
|
|
return "npm"
|
|
}
|
|
|
|
func nodeScriptCmd(pm, script string) string {
|
|
switch pm {
|
|
case "yarn":
|
|
return "yarn " + script
|
|
case "pnpm":
|
|
return "pnpm " + script
|
|
default:
|
|
return "npm run " + script
|
|
}
|
|
}
|
|
|
|
func scriptExists(scripts map[string]string, key string) bool {
|
|
if scripts == nil {
|
|
return false
|
|
}
|
|
_, ok := scripts[key]
|
|
return ok
|
|
}
|
|
|
|
func readPackageManifest(path string) (*packageManifest, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var manifest packageManifest
|
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
|
return nil, err
|
|
}
|
|
return &manifest, nil
|
|
}
|
|
|
|
func uniqueNonEmpty(values []string) []string {
|
|
seen := map[string]struct{}{}
|
|
var out []string
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[value]; ok {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
out = append(out, value)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|