Skip to content

Commit 8a1c432

Browse files
committed
feat: project detection support added
1 parent 252605e commit 8a1c432

2 files changed

Lines changed: 585 additions & 81 deletions

File tree

internal/cli/quickstart_detect.go

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
)
9+
10+
// DetectionResult holds the values resolved by scanning the working directory.
11+
// Fields are empty/zero when not detected. AmbiguousCandidates is populated when
12+
// multiple package.json deps match and the framework cannot be determined uniquely.
13+
type DetectionResult struct {
14+
Framework string
15+
Type string // "spa" | "regular" | "native"
16+
BuildTool string // "vite" | "maven" | "gradle" | "composer" | "" (NA)
17+
Port int // 0 means no applicable default
18+
AppName string // basename of the working directory
19+
Detected bool // true if any signal file matched
20+
AmbiguousCandidates []string // set when >1 package.json dep matched
21+
}
22+
23+
// detectionCandidate is used internally during package.json dep scanning.
24+
type detectionCandidate struct {
25+
framework string
26+
qsType string
27+
buildTool string
28+
port int
29+
}
30+
31+
// DetectProject scans dir for framework signal files and returns a DetectionResult.
32+
// Rules follow the priority order from the spec: config files beat package.json scanning.
33+
func DetectProject(dir string) DetectionResult {
34+
result := DetectionResult{
35+
AppName: filepath.Base(dir),
36+
}
37+
38+
// ── 1. angular.json ─────────────────────────────────────────────────────
39+
if fileExists(dir, "angular.json") {
40+
result.Framework = "angular"
41+
result.Type = "spa"
42+
result.Port = 4200
43+
result.Detected = true
44+
return result
45+
}
46+
47+
// ── 2. pubspec.yaml (Flutter) ────────────────────────────────────────────
48+
if data, ok := readFileContent(dir, "pubspec.yaml"); ok {
49+
if strings.Contains(data, "sdk: flutter") {
50+
result.Detected = true
51+
if isFlutterWeb(dir) {
52+
result.Framework = "flutter-web"
53+
result.Type = "spa"
54+
} else {
55+
result.Framework = "flutter"
56+
result.Type = "native"
57+
}
58+
return result
59+
}
60+
}
61+
62+
// ── 3. vite.config.[ts|js] + package.json deps ───────────────────────────
63+
if fileExistsAny(dir, "vite.config.ts", "vite.config.js") {
64+
deps := readPackageJSONDeps(dir)
65+
result.Type = "spa"
66+
result.BuildTool = "vite"
67+
result.Port = 5173
68+
result.Detected = true
69+
switch {
70+
case hasDep(deps, "react"):
71+
result.Framework = "react"
72+
case hasDep(deps, "vue"):
73+
result.Framework = "vue"
74+
case hasDep(deps, "svelte"):
75+
result.Framework = "svelte"
76+
default:
77+
result.Framework = "vanilla-javascript"
78+
}
79+
return result
80+
}
81+
82+
// ── 4. next.config.[js|ts|mjs] ──────────────────────────────────────────
83+
if fileExistsAny(dir, "next.config.js", "next.config.ts", "next.config.mjs") {
84+
result.Framework = "nextjs"
85+
result.Type = "regular"
86+
result.Port = 3000
87+
result.Detected = true
88+
return result
89+
}
90+
91+
// ── 5. nuxt.config.[ts|js] ───────────────────────────────────────────────
92+
if fileExistsAny(dir, "nuxt.config.ts", "nuxt.config.js") {
93+
result.Framework = "nuxt"
94+
result.Type = "regular"
95+
result.Port = 3000
96+
result.Detected = true
97+
return result
98+
}
99+
100+
// ── 6. svelte.config.[js|ts] ─────────────────────────────────────────────
101+
if fileExistsAny(dir, "svelte.config.js", "svelte.config.ts") {
102+
result.Framework = "sveltekit"
103+
result.Type = "regular"
104+
result.Detected = true
105+
return result
106+
}
107+
108+
// ── 7. expo.json ─────────────────────────────────────────────────────────
109+
if fileExists(dir, "expo.json") {
110+
result.Framework = "expo"
111+
result.Type = "native"
112+
result.Detected = true
113+
return result
114+
}
115+
116+
// ── 8. .csproj ───────────────────────────────────────────────────────────
117+
if content, ok := findCsprojContent(dir); ok {
118+
if fw, qsType, found := detectFromCsproj(content); found {
119+
result.Framework = fw
120+
result.Type = qsType
121+
result.Detected = true
122+
return result
123+
}
124+
}
125+
126+
// ── 9. pom.xml / build.gradle (Java) ─────────────────────────────────────
127+
if content, buildTool, ok := findJavaBuildContent(dir); ok {
128+
fw, port := detectJavaFramework(content)
129+
result.Framework = fw
130+
result.Type = "regular"
131+
result.BuildTool = buildTool
132+
result.Port = port
133+
result.Detected = true
134+
return result
135+
}
136+
137+
// ── 10. composer.json (PHP) ───────────────────────────────────────────────
138+
if data, ok := readFileContent(dir, "composer.json"); ok {
139+
result.BuildTool = "composer"
140+
result.Type = "regular"
141+
result.Detected = true
142+
if strings.Contains(data, "laravel/framework") {
143+
result.Framework = "laravel"
144+
result.Port = 8000
145+
} else {
146+
result.Framework = "vanilla-php"
147+
}
148+
return result
149+
}
150+
151+
// ── 11. go.mod ───────────────────────────────────────────────────────────
152+
if fileExists(dir, "go.mod") {
153+
result.Framework = "vanilla-go"
154+
result.Type = "regular"
155+
result.Detected = true
156+
return result
157+
}
158+
159+
// ── 12. Gemfile (Ruby on Rails) ──────────────────────────────────────────
160+
if data, ok := readFileContent(dir, "Gemfile"); ok {
161+
if strings.Contains(data, "rails") {
162+
result.Framework = "rails"
163+
result.Type = "regular"
164+
result.Port = 3000
165+
result.Detected = true
166+
return result
167+
}
168+
}
169+
170+
// ── 13. requirements.txt / pyproject.toml (Python / Flask) ───────────────
171+
for _, pyFile := range []string{"requirements.txt", "pyproject.toml"} {
172+
if data, ok := readFileContent(dir, pyFile); ok {
173+
if strings.Contains(strings.ToLower(data), "flask") {
174+
result.Framework = "vanilla-python"
175+
result.Type = "regular"
176+
result.Port = 5000
177+
result.Detected = true
178+
return result
179+
}
180+
}
181+
}
182+
183+
// ── 14. package.json dep scanning (lowest priority) ──────────────────────
184+
deps := readPackageJSONDeps(dir)
185+
if len(deps) > 0 {
186+
candidates := collectPackageJSONCandidates(deps)
187+
switch len(candidates) {
188+
case 1:
189+
c := candidates[0]
190+
result.Framework = c.framework
191+
result.Type = c.qsType
192+
result.BuildTool = c.buildTool
193+
result.Port = c.port
194+
result.Detected = true
195+
default:
196+
if len(candidates) > 1 {
197+
result.Type = "regular" // all package.json web deps are regular/native
198+
result.Detected = true
199+
for _, c := range candidates {
200+
result.AmbiguousCandidates = append(result.AmbiguousCandidates, c.framework)
201+
}
202+
}
203+
}
204+
}
205+
206+
return result
207+
}
208+
209+
// collectPackageJSONCandidates returns all framework candidates found in deps.
210+
func collectPackageJSONCandidates(deps map[string]bool) []detectionCandidate {
211+
var candidates []detectionCandidate
212+
if hasDep(deps, "@ionic/angular") {
213+
candidates = append(candidates, detectionCandidate{framework: "ionic-angular", qsType: "native"})
214+
}
215+
if hasDep(deps, "@ionic/react") {
216+
candidates = append(candidates, detectionCandidate{framework: "ionic-react", qsType: "native", buildTool: "vite"})
217+
}
218+
if hasDep(deps, "@ionic/vue") {
219+
candidates = append(candidates, detectionCandidate{framework: "ionic-vue", qsType: "native", buildTool: "vite"})
220+
}
221+
// react-native without expo (expo.json would have matched earlier)
222+
if hasDep(deps, "react-native") {
223+
candidates = append(candidates, detectionCandidate{framework: "react-native", qsType: "native"})
224+
}
225+
if hasDep(deps, "express") {
226+
candidates = append(candidates, detectionCandidate{framework: "express", qsType: "regular", port: 3000})
227+
}
228+
if hasDep(deps, "hono") {
229+
candidates = append(candidates, detectionCandidate{framework: "hono", qsType: "regular", port: 3000})
230+
}
231+
if hasDep(deps, "fastify") {
232+
candidates = append(candidates, detectionCandidate{framework: "fastify", qsType: "regular", port: 3000})
233+
}
234+
return candidates
235+
}
236+
237+
// detectFromCsproj returns framework and type from .csproj file content.
238+
func detectFromCsproj(content string) (framework, qsType string, found bool) {
239+
switch {
240+
case strings.Contains(content, "Microsoft.AspNetCore.Components"):
241+
return "aspnet-blazor", "regular", true
242+
case strings.Contains(content, "Microsoft.AspNetCore.Mvc"):
243+
return "aspnet-mvc", "regular", true
244+
case strings.Contains(content, "Microsoft.Owin"):
245+
return "aspnet-owin", "regular", true
246+
case strings.Contains(content, "Microsoft.Maui") ||
247+
strings.Contains(content, "-android") ||
248+
strings.Contains(content, "-ios"):
249+
return "maui", "native", true
250+
case strings.Contains(content, "-windows"):
251+
return "wpf-winforms", "native", true
252+
}
253+
return "", "", false
254+
}
255+
256+
// detectJavaFramework returns the framework key and default port from Java build file content.
257+
func detectJavaFramework(content string) (framework string, port int) {
258+
lower := strings.ToLower(content)
259+
switch {
260+
case strings.Contains(lower, "spring-boot"):
261+
return "spring-boot", 8080
262+
case strings.Contains(lower, "javax.ee") ||
263+
strings.Contains(lower, "jakarta.ee") ||
264+
strings.Contains(lower, "javax.servlet") ||
265+
strings.Contains(lower, "jakarta.servlet"):
266+
return "java-ee", 0
267+
default:
268+
return "vanilla-java", 0
269+
}
270+
}
271+
272+
// isFlutterWeb returns true if the project has web platform support enabled.
273+
// It checks for the standard web/ directory that Flutter creates for web targets.
274+
func isFlutterWeb(dir string) bool {
275+
_, err := os.Stat(filepath.Join(dir, "web", "index.html"))
276+
return err == nil
277+
}
278+
279+
// fileExists returns true if the named file exists in dir.
280+
func fileExists(dir, name string) bool {
281+
_, err := os.Stat(filepath.Join(dir, name))
282+
return err == nil
283+
}
284+
285+
// fileExistsAny returns true if any of the named files exist in dir.
286+
func fileExistsAny(dir string, names ...string) bool {
287+
for _, name := range names {
288+
if fileExists(dir, name) {
289+
return true
290+
}
291+
}
292+
return false
293+
}
294+
295+
// readFileContent reads a file and returns its content as a string.
296+
func readFileContent(dir, name string) (string, bool) {
297+
data, err := os.ReadFile(filepath.Join(dir, name))
298+
if err != nil {
299+
return "", false
300+
}
301+
return string(data), true
302+
}
303+
304+
// readPackageJSONDeps reads package.json and returns a set of all dependency names
305+
// (from both "dependencies" and "devDependencies").
306+
func readPackageJSONDeps(dir string) map[string]bool {
307+
data, err := os.ReadFile(filepath.Join(dir, "package.json"))
308+
if err != nil {
309+
return nil
310+
}
311+
var pkg struct {
312+
Dependencies map[string]string `json:"dependencies"`
313+
DevDependencies map[string]string `json:"devDependencies"`
314+
}
315+
if err := json.Unmarshal(data, &pkg); err != nil {
316+
return nil
317+
}
318+
deps := make(map[string]bool)
319+
for k := range pkg.Dependencies {
320+
deps[k] = true
321+
}
322+
for k := range pkg.DevDependencies {
323+
deps[k] = true
324+
}
325+
return deps
326+
}
327+
328+
// hasDep returns true if the named dependency is in the deps set.
329+
func hasDep(deps map[string]bool, name string) bool {
330+
return deps[name]
331+
}
332+
333+
// findCsprojContent finds the first .csproj file in dir and returns its content.
334+
func findCsprojContent(dir string) (string, bool) {
335+
entries, err := os.ReadDir(dir)
336+
if err != nil {
337+
return "", false
338+
}
339+
for _, e := range entries {
340+
if !e.IsDir() && strings.HasSuffix(e.Name(), ".csproj") {
341+
if data, err := os.ReadFile(filepath.Join(dir, e.Name())); err == nil {
342+
return string(data), true
343+
}
344+
}
345+
}
346+
return "", false
347+
}
348+
349+
// findJavaBuildContent finds pom.xml or build.gradle and returns content + build tool name.
350+
func findJavaBuildContent(dir string) (content, buildTool string, ok bool) {
351+
if data, err := os.ReadFile(filepath.Join(dir, "pom.xml")); err == nil {
352+
return string(data), "maven", true
353+
}
354+
if data, err := os.ReadFile(filepath.Join(dir, "build.gradle")); err == nil {
355+
return string(data), "gradle", true
356+
}
357+
if data, err := os.ReadFile(filepath.Join(dir, "build.gradle.kts")); err == nil {
358+
return string(data), "gradle", true
359+
}
360+
return "", "", false
361+
}
362+
363+
// friendlyAppType returns the human-readable label for an app type key.
364+
func friendlyAppType(qsType string) string {
365+
switch qsType {
366+
case "spa":
367+
return "Single Page App (SPA)"
368+
case "regular":
369+
return "Regular Web App / API / Backend"
370+
case "native":
371+
return "Native / Mobile"
372+
case "m2m":
373+
return "Machine to Machine"
374+
default:
375+
return qsType
376+
}
377+
}

0 commit comments

Comments
 (0)