From 73c15f099c1cd27218117f3bec420495fab8ef74 Mon Sep 17 00:00:00 2001 From: Antonis Kalipetis Date: Tue, 5 Dec 2023 09:16:06 +0200 Subject: [PATCH 1/2] feat(discovery): add project discovery package Extract project detection logic from question handlers into a new discovery/ package. The Discoverer operates on an fs.FS and provides memoized methods for detecting stack, runtime, dependency managers, build steps, application root, and environment. - Add discovery package with Stack, Type, DependencyManagers, BuildSteps, ApplicationRoot, Environment detection - Add comprehensive tests for all discovery methods - Add Discoverer field to Answers, initialized in WorkingDirectory - Rewrite stack.go to delegate to Discoverer.Stack() instead of inline detection - Add Symfony, Ibexa, Shopware stack constants to platformifier - Add CountFiles utility for runtime detection heuristic Co-Authored-By: Claude Opus 4.6 (1M context) --- discovery/application_root.go | 45 +++++ discovery/application_root_test.go | 67 ++++++++ discovery/build_steps.go | 137 +++++++++++++++ discovery/build_steps_test.go | 109 ++++++++++++ discovery/dependency_managers.go | 105 ++++++++++++ discovery/dependency_managers_test.go | 73 ++++++++ discovery/discovery.go | 24 +++ discovery/environment.go | 47 ++++++ discovery/environment_test.go | 83 +++++++++ discovery/runtime.go | 83 +++++++++ discovery/runtime_test.go | 80 +++++++++ discovery/stack.go | 191 +++++++++++++++++++++ internal/question/build_steps.go | 2 + internal/question/models/answer.go | 2 + internal/question/stack.go | 223 +++++++------------------ internal/question/stack_test.go | 89 ++++++++++ internal/question/working_directory.go | 3 + internal/utils/utils.go | 26 +++ platformifier/models.go | 9 + 19 files changed, 1231 insertions(+), 167 deletions(-) create mode 100644 discovery/application_root.go create mode 100644 discovery/application_root_test.go create mode 100644 discovery/build_steps.go create mode 100644 discovery/build_steps_test.go create mode 100644 discovery/dependency_managers.go create mode 100644 discovery/dependency_managers_test.go create mode 100644 discovery/discovery.go create mode 100644 discovery/environment.go create mode 100644 discovery/environment_test.go create mode 100644 discovery/runtime.go create mode 100644 discovery/runtime_test.go create mode 100644 discovery/stack.go create mode 100644 internal/question/stack_test.go diff --git a/discovery/application_root.go b/discovery/application_root.go new file mode 100644 index 0000000..2a38c39 --- /dev/null +++ b/discovery/application_root.go @@ -0,0 +1,45 @@ +package discovery + +import ( + "path" + "slices" + + "github.com/platformsh/platformify/internal/utils" +) + +// Returns the application root, either from memory or by discovering it on the spot +func (d *Discoverer) ApplicationRoot() (string, error) { + if applicationRoot, ok := d.memory["application_root"]; ok { + return applicationRoot.(string), nil + } + + appRoot, err := d.discoverApplicationRoot() + if err != nil { + return "", err + } + + d.memory["application_root"] = appRoot + return appRoot, nil +} + +func (d *Discoverer) discoverApplicationRoot() (string, error) { + depManagers, err := d.DependencyManagers() + if err != nil { + return "", err + } + + for _, dependencyManager := range dependencyManagersMap { + if !slices.Contains(depManagers, dependencyManager.name) { + continue + } + + lockPath := utils.FindFile(d.fileSystem, "", dependencyManager.lockFile) + if lockPath == "" { + continue + } + + return path.Dir(lockPath), nil + } + + return "", nil +} diff --git a/discovery/application_root_test.go b/discovery/application_root_test.go new file mode 100644 index 0000000..b582a32 --- /dev/null +++ b/discovery/application_root_test.go @@ -0,0 +1,67 @@ +package discovery + +import ( + "io/fs" + "testing" + "testing/fstest" +) + +func TestDiscoverer_discoverApplicationRoot(t *testing.T) { + type fields struct { + fileSystem fs.FS + memory map[string]any + } + tests := []struct { + name string + fields fields + want string + wantErr bool + }{ + { + name: "Simple", + fields: fields{ + fileSystem: fstest.MapFS{ + "package-lock.json": &fstest.MapFile{}, + }, + }, + want: ".", + }, + { + name: "No root", + fields: fields{ + fileSystem: fstest.MapFS{}, + }, + want: "", + }, + { + name: "Priority", + fields: fields{ + fileSystem: fstest.MapFS{ + "yarn/yarn.lock": &fstest.MapFile{}, + "poetry/poetry.lock": &fstest.MapFile{}, + "composer/composer-lock.json": &fstest.MapFile{}, + }, + }, + want: "poetry", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &Discoverer{ + fileSystem: tt.fields.fileSystem, + memory: tt.fields.memory, + } + if d.memory == nil { + d.memory = make(map[string]any) + } + got, err := d.discoverApplicationRoot() + if (err != nil) != tt.wantErr { + t.Errorf("Discoverer.discoverApplicationRoot() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Discoverer.discoverApplicationRoot() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/discovery/build_steps.go b/discovery/build_steps.go new file mode 100644 index 0000000..4ff140e --- /dev/null +++ b/discovery/build_steps.go @@ -0,0 +1,137 @@ +package discovery + +import ( + "fmt" + "slices" + + "github.com/platformsh/platformify/internal/utils" + "github.com/platformsh/platformify/platformifier" +) + +// Returns the application build steps, either from memory or by discovering it on the spot +func (d *Discoverer) BuildSteps() ([]string, error) { + if buildSteps, ok := d.memory["build_steps"]; ok { + return buildSteps.([]string), nil + } + + buildSteps, err := d.discoverBuildSteps() + if err != nil { + return nil, err + } + + d.memory["build_steps"] = buildSteps + return buildSteps, nil +} + +func (d *Discoverer) discoverBuildSteps() ([]string, error) { + dependencyManagers, err := d.DependencyManagers() + if err != nil { + return nil, err + } + + typ, err := d.Type() + if err != nil { + return nil, err + } + + stack, err := d.Stack() + if err != nil { + return nil, err + } + + appRoot, err := d.ApplicationRoot() + if err != nil { + return nil, err + } + + buildSteps := make([]string, 0) + + // Start with lower priority dependency managers first + slices.Reverse(dependencyManagers) + for _, dm := range dependencyManagers { + switch dm { + case "poetry": + buildSteps = append( + buildSteps, + "# Set PIP_USER to 0 so that Poetry does not complain", + "export PIP_USER=0", + "# Install poetry as a global tool", + "python -m venv /app/.global", + "pip install poetry==$POETRY_VERSION", + "poetry install", + ) + case "pipenv": + buildSteps = append( + buildSteps, + "# Set PIP_USER to 0 so that Pipenv does not complain", + "export PIP_USER=0", + "# Install Pipenv as a global tool", + "python -m venv /app/.global", + "pip install pipenv==$PIPENV_TOOL_VERSION", + "pipenv install", + ) + case "pip": + buildSteps = append( + buildSteps, + "pip install -r requirements.txt", + ) + case "yarn", "npm": + // Install n, if on different runtime + if typ != "nodejs" { + buildSteps = append( + buildSteps, + "n auto || n lts", + "hash -r", + ) + } + + if dm == "yarn" { + buildSteps = append( + buildSteps, + "yarn", + ) + } else { + buildSteps = append( + buildSteps, + "npm i", + ) + } + + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"scripts", "build"}, + "package.json", + true, + ); ok { + buildSteps = append(buildSteps, d.nodeScriptPrefix()+"build") + } + case "composer": + buildSteps = append( + buildSteps, + "composer --no-ansi --no-interaction install --no-progress --prefer-dist --optimize-autoloader --no-dev", + ) + } + } + + switch stack { + case platformifier.Django: + if managePyPath := utils.FindFile( + d.fileSystem, + appRoot, + managePyFile, + ); managePyPath != "" { + buildSteps = append( + buildSteps, + "# Collect static files", + fmt.Sprintf("%spython %s collectstatic --noinput", d.pythonPrefix(), managePyPath), + ) + } + case platformifier.NextJS: + // If there is no custom build script, fallback to next build for Next.js projects + if !slices.Contains(buildSteps, "yarn build") && !slices.Contains(buildSteps, "npm run build") { + buildSteps = append(buildSteps, d.nodeExecPrefix()+"next build") + } + } + + return buildSteps, nil +} diff --git a/discovery/build_steps_test.go b/discovery/build_steps_test.go new file mode 100644 index 0000000..ffc6c3c --- /dev/null +++ b/discovery/build_steps_test.go @@ -0,0 +1,109 @@ +package discovery + +import ( + "io/fs" + "reflect" + "testing" + "testing/fstest" + + "github.com/platformsh/platformify/platformifier" +) + +func TestDiscoverer_discoverBuildSteps(t *testing.T) { + type fields struct { + fileSystem fs.FS + memory map[string]any + } + tests := []struct { + name string + fields fields + want []string + wantErr bool + }{ + { + name: "Poetry Django", + fields: fields{ + fileSystem: fstest.MapFS{ + "project/manage.py": &fstest.MapFile{}, + }, + memory: map[string]any{ + "stack": platformifier.Django, + "type": "python", + "dependency_managers": []string{"poetry"}, + "application_root": ".", + }, + }, + want: []string{ + "# Set PIP_USER to 0 so that Poetry does not complain", + "export PIP_USER=0", + "# Install poetry as a global tool", + "python -m venv /app/.global", + "pip install poetry==$POETRY_VERSION", + "poetry install", + "# Collect static files", + "poetry run python project/manage.py collectstatic --noinput", + }, + }, + { + name: "Pipenv Django with Yarn build", + fields: fields{ + fileSystem: fstest.MapFS{ + "project/manage.py": &fstest.MapFile{}, + "package.json": &fstest.MapFile{Data: []byte(`{"scripts": {"build": "nuxt build"}}`)}, + }, + memory: map[string]any{ + "stack": platformifier.Django, + "type": "python", + "dependency_managers": []string{"poetry", "yarn"}, + "application_root": ".", + }, + }, + want: []string{ + "n auto || n lts", + "hash -r", + "yarn", + "yarn build", + "# Set PIP_USER to 0 so that Poetry does not complain", + "export PIP_USER=0", + "# Install poetry as a global tool", + "python -m venv /app/.global", + "pip install poetry==$POETRY_VERSION", + "poetry install", + "# Collect static files", + "poetry run python project/manage.py collectstatic --noinput", + }, + }, + { + name: "Next.js without build script", + fields: fields{ + fileSystem: fstest.MapFS{}, + memory: map[string]any{ + "stack": platformifier.NextJS, + "type": "nodejs", + "dependency_managers": []string{"npm"}, + "application_root": ".", + }, + }, + want: []string{ + "npm i", + "npm exec next build", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &Discoverer{ + fileSystem: tt.fields.fileSystem, + memory: tt.fields.memory, + } + got, err := d.discoverBuildSteps() + if (err != nil) != tt.wantErr { + t.Errorf("Discoverer.discoverBuildSteps() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Discoverer.discoverBuildSteps() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/discovery/dependency_managers.go b/discovery/dependency_managers.go new file mode 100644 index 0000000..a520a53 --- /dev/null +++ b/discovery/dependency_managers.go @@ -0,0 +1,105 @@ +package discovery + +import ( + "slices" + + "github.com/platformsh/platformify/internal/utils" +) + +var ( + dependencyManagersMap = []struct { + lang string + lockFile string + name string + }{ + {lockFile: "poetry.lock", name: "poetry", lang: "python"}, + {lockFile: "Pipfile.lock", name: "pipenv", lang: "python"}, + {lockFile: "requirements.txt", name: "pip", lang: "python"}, + {lockFile: "composer.lock", name: "composer", lang: "php"}, + {lockFile: "yarn.lock", name: "yarn", lang: "nodejs"}, + {lockFile: "package-lock.json", name: "npm", lang: "nodejs"}, + } +) + +// Returns the dependency managers, either from memory or by discovering it on the spot +func (d *Discoverer) DependencyManagers() ([]string, error) { + if dependencyManagers, ok := d.memory["dependency_managers"]; ok { + return dependencyManagers.([]string), nil + } + + dependencyManagers, err := d.discoverDependencyManagers() + if err != nil { + return nil, err + } + + d.memory["dependency_managers"] = dependencyManagers + return dependencyManagers, nil +} + +func (d *Discoverer) discoverDependencyManagers() ([]string, error) { + dependencyManagers := make([]string, 0) + matchedLanguages := make([]string, 0) + for _, dependencyManager := range dependencyManagersMap { + if slices.Contains(matchedLanguages, dependencyManager.lang) { + continue + } + + if utils.FileExists(d.fileSystem, "", dependencyManager.lockFile) { + dependencyManagers = append(dependencyManagers, dependencyManager.name) + matchedLanguages = append(matchedLanguages, dependencyManager.lang) + } + } + + return dependencyManagers, nil +} + +func (d *Discoverer) pythonPrefix() string { + dependencyManagers, err := d.DependencyManagers() + if err != nil { + return "" + } + + if slices.Contains(dependencyManagers, "pipenv") { + return "pipenv run " + } + + if slices.Contains(dependencyManagers, "poetry") { + return "poetry run " + } + + return "" +} + +func (d *Discoverer) nodeScriptPrefix() string { + dependencyManagers, err := d.DependencyManagers() + if err != nil { + return "" + } + + if slices.Contains(dependencyManagers, "yarn") { + return "yarn " + } + + if slices.Contains(dependencyManagers, "npm") { + return "npm run " + } + + return "" +} + +func (d *Discoverer) nodeExecPrefix() string { + dependencyManagers, err := d.DependencyManagers() + if err != nil { + return "" + } + + if slices.Contains(dependencyManagers, "yarn") { + return "yarn exec" + } + + if slices.Contains(dependencyManagers, "npm") { + return "npm exec " + } + + return "" +} diff --git a/discovery/dependency_managers_test.go b/discovery/dependency_managers_test.go new file mode 100644 index 0000000..3b8ceb3 --- /dev/null +++ b/discovery/dependency_managers_test.go @@ -0,0 +1,73 @@ +package discovery + +import ( + "io/fs" + "reflect" + "slices" + "testing" + "testing/fstest" +) + +func TestDiscoverer_discoverDependencyManagers(t *testing.T) { + type fields struct { + fileSystem fs.FS + memory map[string]any + } + tests := []struct { + name string + fields fields + want []string + wantErr bool + }{ + { + name: "Simple", + fields: fields{ + fileSystem: fstest.MapFS{ + "package-lock.json": &fstest.MapFile{}, + }, + }, + want: []string{"npm"}, + wantErr: false, + }, + { + name: "Multiple", + fields: fields{ + fileSystem: fstest.MapFS{ + "package-lock.json": &fstest.MapFile{}, + "poetry.lock": &fstest.MapFile{}, + }, + }, + want: []string{"npm", "poetry"}, + wantErr: false, + }, + { + name: "Priority", + fields: fields{ + fileSystem: fstest.MapFS{ + "package-lock.json": &fstest.MapFile{}, + "poetry.lock": &fstest.MapFile{}, + "requirements.txt": &fstest.MapFile{}, + }, + }, + want: []string{"npm", "poetry"}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &Discoverer{ + fileSystem: tt.fields.fileSystem, + memory: tt.fields.memory, + } + got, err := d.discoverDependencyManagers() + slices.Sort(got) + if (err != nil) != tt.wantErr { + t.Errorf("Discoverer.discoverDependencyManagers() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Discoverer.discoverDependencyManagers() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/discovery/discovery.go b/discovery/discovery.go new file mode 100644 index 0000000..62c25cf --- /dev/null +++ b/discovery/discovery.go @@ -0,0 +1,24 @@ +package discovery + +import ( + "io/fs" +) + +const ( + settingsPyFile = "settings.py" + managePyFile = "manage.py" + composerJSONFile = "composer.json" + packageJSONFile = "package.json" + symfonyLockFile = "symfony.lock" +) + +// Discoverer detects project characteristics from the filesystem. +type Discoverer struct { + fileSystem fs.FS + memory map[string]any +} + +// New creates a Discoverer for the given filesystem. +func New(fileSystem fs.FS) *Discoverer { + return &Discoverer{fileSystem: fileSystem, memory: make(map[string]any)} +} diff --git a/discovery/environment.go b/discovery/environment.go new file mode 100644 index 0000000..b59f966 --- /dev/null +++ b/discovery/environment.go @@ -0,0 +1,47 @@ +package discovery + +// Returns the application environment, either from memory or by discovering it on the spot +func (d *Discoverer) Environment() (map[string]string, error) { + if environment, ok := d.memory["environment"]; ok { + return environment.(map[string]string), nil + } + + environment, err := d.discoverEnvironment() + if err != nil { + return nil, err + } + + d.memory["environment"] = environment + return environment, nil +} + +func (d *Discoverer) discoverEnvironment() (map[string]string, error) { + depManagers, err := d.DependencyManagers() + if err != nil { + return nil, err + } + + typ, err := d.Type() + if err != nil { + return nil, err + } + + environment := make(map[string]string) + + for _, dm := range depManagers { + switch dm { + case "poetry": + environment["POETRY_VERSION"] = "1.4.0" + environment["POETRY_VIRTUALENVS_IN_PROJECT"] = "true" + case "pipenv": + environment["PIPENV_TOOL_VERSION"] = "2023.2.18" + environment["PIPENV_VENV_IN_PROJECT"] = "1" + case "npm", "yarn": + if typ != "nodejs" { + environment["N_PREFIX"] = "/app/.global" + } + } + } + + return environment, nil +} diff --git a/discovery/environment_test.go b/discovery/environment_test.go new file mode 100644 index 0000000..0d19cc3 --- /dev/null +++ b/discovery/environment_test.go @@ -0,0 +1,83 @@ +package discovery + +import ( + "io/fs" + "reflect" + "testing" + "testing/fstest" +) + +func TestDiscoverer_discoverEnvironment(t *testing.T) { + type fields struct { + fileSystem fs.FS + memory map[string]any + } + tests := []struct { + name string + fields fields + want map[string]string + wantErr bool + }{ + { + name: "No env", + fields: fields{ + fileSystem: fstest.MapFS{}, + memory: map[string]any{}, + }, + want: make(map[string]string), + }, + { + name: "Simple poetry", + fields: fields{ + fileSystem: fstest.MapFS{}, + memory: map[string]any{ + "dependency_managers": []string{"poetry"}, + }, + }, + want: map[string]string{ + "POETRY_VERSION": "1.4.0", + "POETRY_VIRTUALENVS_IN_PROJECT": "true", + }, + }, + { + name: "Node.js", + fields: fields{ + fileSystem: fstest.MapFS{}, + memory: map[string]any{ + "dependency_managers": []string{"yarn"}, + "type": "nodejs", + }, + }, + want: map[string]string{}, + }, + { + name: "Node.js on different runtime", + fields: fields{ + fileSystem: fstest.MapFS{}, + memory: map[string]any{ + "dependency_managers": []string{"yarn"}, + "type": "python", + }, + }, + want: map[string]string{ + "N_PREFIX": "/app/.global", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &Discoverer{ + fileSystem: tt.fields.fileSystem, + memory: tt.fields.memory, + } + got, err := d.discoverEnvironment() + if (err != nil) != tt.wantErr { + t.Errorf("Discoverer.discoverEnvironment() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Discoverer.discoverEnvironment() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/discovery/runtime.go b/discovery/runtime.go new file mode 100644 index 0000000..db2bccb --- /dev/null +++ b/discovery/runtime.go @@ -0,0 +1,83 @@ +package discovery + +import ( + "github.com/platformsh/platformify/internal/utils" + "github.com/platformsh/platformify/platformifier" +) + +var ( + languageMap = map[string]string{ + ".py": "python", + ".js": "nodejs", + ".go": "golang", + ".php": "php", + ".rb": "ruby", + ".exs": "elixir", + ".ex": "elixir", + ".cs": "dotnet", + ".rs": "rust", + ".lisp": "lisp", + ".lsp": "lisp", + ".l": "lisp", + ".cl": "lisp", + ".fasl": "lisp", + ".java": "java", + } +) + +// Returns the Runtime, either from memory or by discovering it on the spot +func (d *Discoverer) Type() (string, error) { + if typ, ok := d.memory["type"]; ok { + return typ.(string), nil + } + + typ, err := d.discoverType() + if err != nil { + return "", err + } + + d.memory["type"] = typ + return typ, nil +} + +func (d *Discoverer) discoverType() (string, error) { + stack, err := d.Stack() + if err != nil { + return "", err + } + + switch stack { + case platformifier.Laravel, platformifier.Symfony: + return "php", nil + case platformifier.Django, platformifier.Flask: + return "python", nil + case platformifier.Express, platformifier.NextJS, platformifier.Strapi: + return "nodejs", nil + } + + extCount, err := utils.CountFiles(d.fileSystem) + if err != nil { + return "", err + } + + langCount := make(map[string]int) + for ext, count := range extCount { + if lang, ok := languageMap[ext]; ok { + if _, _ok := langCount[lang]; !_ok { + langCount[lang] = 0 + } + langCount[lang] += count + } + } + + maxCount := 0 + selectedLang := "" + for lang, count := range langCount { + if count > maxCount { + maxCount = count + selectedLang = lang + } + } + + return selectedLang, nil +} diff --git a/discovery/runtime_test.go b/discovery/runtime_test.go new file mode 100644 index 0000000..fc478cc --- /dev/null +++ b/discovery/runtime_test.go @@ -0,0 +1,80 @@ +package discovery + +import ( + "io/fs" + "testing" + "testing/fstest" + + "github.com/platformsh/platformify/platformifier" +) + +func TestDiscoverer_discoverType(t *testing.T) { + type fields struct { + fileSystem fs.FS + memory map[string]any + } + tests := []struct { + name string + fields fields + want string + wantErr bool + }{ + { + name: "Python", + fields: fields{ + fileSystem: fstest.MapFS{ + "hey.py": &fstest.MapFile{}, + "hey.js": &fstest.MapFile{}, + "another.py": &fstest.MapFile{}, + }, + }, + want: "python", + wantErr: false, + }, + { + name: "Express stack override", + fields: fields{ + fileSystem: fstest.MapFS{ + "hey.py": &fstest.MapFile{}, + "hey.js": &fstest.MapFile{}, + "another.py": &fstest.MapFile{}, + }, + memory: map[string]any{"stack": platformifier.Express}, + }, + want: "nodejs", + wantErr: false, + }, + { + name: "Node.js skip vendor", + fields: fields{ + fileSystem: fstest.MapFS{ + "vendor/a.py": &fstest.MapFile{}, + "vendor/b.py": &fstest.MapFile{}, + "vendor/c.py": &fstest.MapFile{}, + "hey.js": &fstest.MapFile{}, + }, + }, + want: "nodejs", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.fields.memory == nil { + tt.fields.memory = make(map[string]any) + } + d := &Discoverer{ + fileSystem: tt.fields.fileSystem, + memory: tt.fields.memory, + } + got, err := d.discoverType() + if (err != nil) != tt.wantErr { + t.Errorf("Discoverer.discoverType() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Discoverer.discoverType() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/discovery/stack.go b/discovery/stack.go new file mode 100644 index 0000000..ee68586 --- /dev/null +++ b/discovery/stack.go @@ -0,0 +1,191 @@ +package discovery + +import ( + "slices" + "strings" + + "github.com/platformsh/platformify/internal/utils" + "github.com/platformsh/platformify/platformifier" +) + +// Returns the stack, either from memory or by discovering it on the spot +func (d *Discoverer) Stack() (platformifier.Stack, error) { + if stack, ok := d.memory["stack"]; ok { + return stack.(platformifier.Stack), nil + } + + stack, err := d.discoverStack() + if err != nil { + return platformifier.Generic, err + } + + d.memory["stack"] = stack + return stack, nil +} + +func (d *Discoverer) discoverStack() (platformifier.Stack, error) { + hasSettingsPy := utils.FileExists(d.fileSystem, "", settingsPyFile) + hasManagePy := utils.FileExists(d.fileSystem, "", managePyFile) + if hasSettingsPy && hasManagePy { + return platformifier.Django, nil + } + + requirementsPath := utils.FindFile(d.fileSystem, "", "requirements.txt") + if requirementsPath != "" { + f, err := d.fileSystem.Open(requirementsPath) + if err == nil { + defer f.Close() + if ok, _ := utils.ContainsStringInFile(f, "flask", true); ok { + return platformifier.Flask, nil + } + } + } + + pyProjectPath := utils.FindFile(d.fileSystem, "", "pyproject.toml") + if pyProjectPath != "" { + if _, ok := utils.GetTOMLValue( + d.fileSystem, + []string{"tool", "poetry", "dependencies", "flask"}, + pyProjectPath, + true, + ); ok { + return platformifier.Flask, nil + } + } + + pipfilePath := utils.FindFile(d.fileSystem, "", "Pipfile") + if pipfilePath != "" { + if _, ok := utils.GetTOMLValue( + d.fileSystem, + []string{"packages", "flask"}, + pipfilePath, + true, + ); ok { + return platformifier.Flask, nil + } + } + + composerJSONPaths := utils.FindAllFiles(d.fileSystem, "", composerJSONFile) + for _, composerJSONPath := range composerJSONPaths { + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"require", "laravel/framework"}, + composerJSONPath, + true, + ); ok { + return platformifier.Laravel, nil + } + } + + packageJSONPaths := utils.FindAllFiles(d.fileSystem, "", packageJSONFile) + for _, packageJSONPath := range packageJSONPaths { + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"dependencies", "next"}, + packageJSONPath, + true, + ); ok { + return platformifier.NextJS, nil + } + + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"dependencies", "@strapi/strapi"}, + packageJSONPath, + true, + ); ok { + return platformifier.Strapi, nil + } + + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"dependencies", "strapi"}, + packageJSONPath, + true, + ); ok { + return platformifier.Strapi, nil + } + + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"dependencies", "express"}, + packageJSONPath, + true, + ); ok { + return platformifier.Express, nil + } + } + + hasSymfonyLock := utils.FileExists(d.fileSystem, "", symfonyLockFile) + hasSymfonyBundle := false + for _, composerJSONPath := range composerJSONPaths { + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"autoload", "psr-0", "shopware"}, + composerJSONPath, + true, + ); ok { + return platformifier.Shopware, nil + } + + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"autoload", "psr-4", "shopware\\core\\"}, + composerJSONPath, + true, + ); ok { + return platformifier.Shopware, nil + } + + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"autoload", "psr-4", "shopware\\appbundle\\"}, + composerJSONPath, + true, + ); ok { + return platformifier.Shopware, nil + } + + if keywords, ok := utils.GetJSONValue( + d.fileSystem, + []string{"keywords"}, + composerJSONPath, + true, + ); ok { + if keywordsVal, ok := keywords.([]string); ok && slices.Contains(keywordsVal, "shopware") { + return platformifier.Shopware, nil + } + } + if requirements, ok := utils.GetJSONValue( + d.fileSystem, + []string{"require"}, + composerJSONPath, + true, + ); ok { + if requirementsVal, requirementsOK := requirements.(map[string]interface{}); requirementsOK { + if _, requiresSymfony := requirementsVal["symfony/framework-bundle"]; requiresSymfony { + hasSymfonyBundle = true + } + + for requirement := range requirementsVal { + if strings.HasPrefix(requirement, "shopware/") { + return platformifier.Shopware, nil + } + if strings.HasPrefix(requirement, "ibexa/") { + return platformifier.Ibexa, nil + } + if strings.HasPrefix(requirement, "ezsystems/") { + return platformifier.Ibexa, nil + } + } + } + } + } + + isSymfony := hasSymfonyBundle || hasSymfonyLock + if isSymfony { + return platformifier.Symfony, nil + } + + return platformifier.Generic, nil +} diff --git a/internal/question/build_steps.go b/internal/question/build_steps.go index d13b7fc..01a6a72 100644 --- a/internal/question/build_steps.go +++ b/internal/question/build_steps.go @@ -11,6 +11,8 @@ import ( "github.com/platformsh/platformify/vendorization" ) +const managePyFile = "manage.py" + type BuildSteps struct{} func (q *BuildSteps) Ask(ctx context.Context) error { diff --git a/internal/question/models/answer.go b/internal/question/models/answer.go index 9bb9e26..51229ea 100644 --- a/internal/question/models/answer.go +++ b/internal/question/models/answer.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/platformsh/platformify/discovery" "github.com/platformsh/platformify/platformifier" ) @@ -32,6 +33,7 @@ type Answers struct { HasGit bool `json:"has_git"` FilesCreated []string `json:"files_created"` Locations map[string]map[string]interface{} `json:"locations"` + Discoverer *discovery.Discoverer } type Service struct { diff --git a/internal/question/stack.go b/internal/question/stack.go index e21df01..f72be4a 100644 --- a/internal/question/stack.go +++ b/internal/question/stack.go @@ -3,26 +3,16 @@ package question import ( "context" "fmt" - "strings" "github.com/AlecAivazis/survey/v2" "github.com/platformsh/platformify/internal/colors" "github.com/platformsh/platformify/internal/question/models" "github.com/platformsh/platformify/internal/questionnaire" - "github.com/platformsh/platformify/internal/utils" + "github.com/platformsh/platformify/platformifier" "github.com/platformsh/platformify/vendorization" ) -const ( - settingsPyFile = "settings.py" - managePyFile = "manage.py" - composerJSONFile = "composer.json" - packageJSONFile = "package.json" - symfonyLockFile = "symfony.lock" - rackFile = "config.ru" -) - type Stack struct{} func (q *Stack) Ask(ctx context.Context) error { @@ -32,8 +22,8 @@ func (q *Stack) Ask(ctx context.Context) error { } defer func() { - _, stderr, ok := colors.FromContext(ctx) - if !ok { + _, stderr, stderrOK := colors.FromContext(ctx) + if !stderrOK { return } if answers.Stack != models.GenericStack { @@ -50,170 +40,69 @@ func (q *Stack) Ask(ctx context.Context) error { }() answers.Stack = models.GenericStack + stack, err := answers.Discoverer.Stack() + if err != nil { + return err + } - hasSettingsPy := utils.FileExists(answers.WorkingDirectory, "", settingsPyFile) - hasManagePy := utils.FileExists(answers.WorkingDirectory, "", managePyFile) - if hasSettingsPy && hasManagePy { + switch stack { + case platformifier.Django: answers.Stack = models.Django return nil + case platformifier.Express: + answers.Stack = models.Express + return nil + case platformifier.Flask: + answers.Stack = models.Flask + return nil + case platformifier.Laravel: + answers.Stack = models.Laravel + return nil + case platformifier.NextJS: + answers.Stack = models.NextJS + return nil + case platformifier.Strapi: + answers.Stack = models.Strapi + return nil + case platformifier.Rails: + answers.Stack = models.Rails + return nil + case platformifier.Symfony: + // Interactive: offer Symfony CLI below. + default: + answers.Stack = models.GenericStack + return nil } - rackPath := utils.FindFile(answers.WorkingDirectory, "", rackFile) - if rackPath != "" { - f, err := answers.WorkingDirectory.Open(rackPath) - if err == nil { - defer f.Close() - if ok, _ := utils.ContainsStringInFile(f, "Rails.application.load_server", true); ok { - answers.Stack = models.Rails - return nil - } - } - } - - requirementsPath := utils.FindFile(answers.WorkingDirectory, "", "requirements.txt") - if requirementsPath != "" { - f, err := answers.WorkingDirectory.Open(requirementsPath) - if err == nil { - defer f.Close() - if ok, _ := utils.ContainsStringInFile(f, "flask", true); ok { - answers.Stack = models.Flask - return nil - } - } - } - - pyProjectPath := utils.FindFile(answers.WorkingDirectory, "", "pyproject.toml") - if pyProjectPath != "" { - if _, ok := utils.GetTOMLValue(answers.WorkingDirectory, []string{"tool", "poetry", "dependencies", "flask"}, pyProjectPath, true); ok { - answers.Stack = models.Flask - return nil - } - } - - pipfilePath := utils.FindFile(answers.WorkingDirectory, "", "Pipfile") - if pipfilePath != "" { - if _, ok := utils.GetTOMLValue(answers.WorkingDirectory, []string{"packages", "flask"}, pipfilePath, true); ok { - answers.Stack = models.Flask - return nil - } - } - - composerJSONPaths := utils.FindAllFiles(answers.WorkingDirectory, "", composerJSONFile) - for _, composerJSONPath := range composerJSONPaths { - if _, ok := utils.GetJSONValue(answers.WorkingDirectory, []string{"require", "laravel/framework"}, composerJSONPath, true); ok { - answers.Stack = models.Laravel - return nil - } - } - - packageJSONPaths := utils.FindAllFiles(answers.WorkingDirectory, "", packageJSONFile) - for _, packageJSONPath := range packageJSONPaths { - if _, ok := utils.GetJSONValue(answers.WorkingDirectory, []string{"dependencies", "next"}, packageJSONPath, true); ok { - answers.Stack = models.NextJS - return nil - } - - if _, ok := utils.GetJSONValue(answers.WorkingDirectory, []string{"dependencies", "@strapi/strapi"}, packageJSONPath, true); ok { - answers.Stack = models.Strapi - return nil - } - - if _, ok := utils.GetJSONValue(answers.WorkingDirectory, []string{"dependencies", "strapi"}, packageJSONPath, true); ok { - answers.Stack = models.Strapi - return nil - } - - if _, ok := utils.GetJSONValue(answers.WorkingDirectory, []string{"dependencies", "express"}, packageJSONPath, true); ok { - answers.Stack = models.Express - return nil - } + _, stderr, ok := colors.FromContext(ctx) + if !ok { + return questionnaire.ErrSilent } - hasSymfonyLock := utils.FileExists(answers.WorkingDirectory, "", symfonyLockFile) - hasSymfonyBundle := false - hasIbexaDependencies := false - hasShopwareDependencies := false - for _, composerJSONPath := range composerJSONPaths { - if _, ok := utils.GetJSONValue(answers.WorkingDirectory, []string{"autoload", "psr-0", "shopware"}, composerJSONPath, true); ok { - hasShopwareDependencies = true - break - } - if _, ok := utils.GetJSONValue(answers.WorkingDirectory, []string{"autoload", "psr-4", "shopware\\core\\"}, composerJSONPath, true); ok { - hasShopwareDependencies = true - break - } - if _, ok := utils.GetJSONValue(answers.WorkingDirectory, []string{"autoload", "psr-4", "shopware\\appbundle\\"}, composerJSONPath, true); ok { - hasShopwareDependencies = true - break - } - - if keywords, ok := utils.GetJSONValue(answers.WorkingDirectory, []string{"keywords"}, composerJSONPath, true); ok { - if keywordsVal, ok := keywords.([]any); ok { - for _, kw := range keywordsVal { - if kwStr, ok := kw.(string); ok && kwStr == "shopware" { - hasShopwareDependencies = true - break - } - } - } - } - if requirements, ok := utils.GetJSONValue(answers.WorkingDirectory, []string{"require"}, composerJSONPath, true); ok { - if requirementsVal, requirementsOK := requirements.(map[string]interface{}); requirementsOK { - if _, hasSymfonyFrameworkBundle := requirementsVal["symfony/framework-bundle"]; hasSymfonyFrameworkBundle { - hasSymfonyBundle = true - } - - for requirement := range requirementsVal { - if strings.HasPrefix(requirement, "shopware/") { - hasShopwareDependencies = true - break - } - if strings.HasPrefix(requirement, "ibexa/") { - hasIbexaDependencies = true - break - } - if strings.HasPrefix(requirement, "ezsystems/") { - hasIbexaDependencies = true - break - } - } - } - } + confirm := true + if err := survey.AskOne( + &survey.Confirm{ + Message: "It seems like this project uses Symfony full-stack. For a better experience, you should use Symfony CLI. Would you like to use it to deploy your project instead?", //nolint:lll + Default: confirm, + }, + &confirm, + ); err != nil { + return err } - isSymfony := hasSymfonyBundle || hasSymfonyLock - if isSymfony && !hasIbexaDependencies && !hasShopwareDependencies { - _, stderr, ok := colors.FromContext(ctx) - if !ok { - return questionnaire.ErrSilent - } - - confirm := true - err := survey.AskOne( - &survey.Confirm{ - Message: "It seems like this project uses Symfony full-stack. For a better experience, you should use Symfony CLI. Would you like to use it to deploy your project instead?", //nolint:lll - Default: confirm, - }, - &confirm, - ) - if err != nil { - return err - } - - assets, _ := vendorization.FromContext(ctx) - if confirm { - fmt.Fprintln( - stderr, - colors.Colorize( - colors.WarningCode, - fmt.Sprintf( - "Check out the Symfony CLI documentation here: %s", - assets.Docs().SymfonyCLI, - ), + assets, _ := vendorization.FromContext(ctx) + if confirm { + fmt.Fprintln( + stderr, + colors.Colorize( + colors.WarningCode, + fmt.Sprintf( + "Check out the Symfony CLI documentation here: %s", + assets.Docs().SymfonyCLI, ), - ) - return questionnaire.ErrSilent - } + ), + ) + return questionnaire.ErrSilent } return nil diff --git a/internal/question/stack_test.go b/internal/question/stack_test.go new file mode 100644 index 0000000..7eb557f --- /dev/null +++ b/internal/question/stack_test.go @@ -0,0 +1,89 @@ +package question + +import ( + "context" + "io/fs" + "testing" + "testing/fstest" + + "github.com/platformsh/platformify/discovery" + "github.com/platformsh/platformify/internal/question/models" +) + +func TestStack_Ask(t *testing.T) { + tests := []struct { + name string + fileSystem fs.FS + want string + wantErr bool + }{ + { + name: "Django", + fileSystem: fstest.MapFS{ + "demo/settings.py": &fstest.MapFile{}, + "manage.py": &fstest.MapFile{}, + }, + want: "Django", + wantErr: false, + }, + { + name: "Django subdir", + fileSystem: fstest.MapFS{ + "sub/settings.py": &fstest.MapFile{}, + "sub/demo/manage.py": &fstest.MapFile{}, + }, + want: "Django", + wantErr: false, + }, + { + name: "Flask requirements.txt", + fileSystem: fstest.MapFS{ + "requirements.txt": &fstest.MapFile{Data: []byte("FlAsK==1.2.3#hash-here")}, + }, + want: "Flask", + wantErr: false, + }, + { + name: "Flask Poetry", + fileSystem: fstest.MapFS{ + "pyproject.toml": &fstest.MapFile{Data: []byte(` +[tool.poetry.dependencies] +# Get the latest revision on the branch named "next" +requests = { git = "https://github.com/kennethreitz/requests.git", branch = "next" } +# Get a revision by its commit hash +FlAsK = { git = "https://github.com/pallets/flask.git", rev = "38eb5d3b" } + `)}, + }, + want: "Flask", + wantErr: false, + }, + { + name: "Flask Pipenv", + fileSystem: fstest.MapFS{ + "Pipfile": &fstest.MapFile{Data: []byte(` +[packages] +fLaSk = "^1.2.3" + `)}, + }, + want: "Flask", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q := &Stack{} + a := models.NewAnswers() + a.WorkingDirectory = tt.fileSystem + a.Discoverer = discovery.New(tt.fileSystem) + ctx := models.ToContext(context.Background(), a) + + if err := q.Ask(ctx); (err != nil) != tt.wantErr { + t.Errorf("Stack.Ask() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && a.Stack.Title() != tt.want { + t.Errorf("Stack.Ask().Stack = %s, want %s", a.Stack.Title(), tt.want) + } + }) + } +} diff --git a/internal/question/working_directory.go b/internal/question/working_directory.go index dfd89af..6bccbb5 100644 --- a/internal/question/working_directory.go +++ b/internal/question/working_directory.go @@ -10,6 +10,7 @@ import ( "github.com/AlecAivazis/survey/v2" + "github.com/platformsh/platformify/discovery" "github.com/platformsh/platformify/internal/colors" "github.com/platformsh/platformify/internal/question/models" "github.com/platformsh/platformify/vendorization" @@ -34,6 +35,7 @@ func (q *WorkingDirectory) Ask(ctx context.Context) error { answers.WorkingDirectory = os.DirFS(cwd) answers.Cwd = cwd answers.HasGit = false + answers.Discoverer = discovery.New(answers.WorkingDirectory) var outBuf, errBuf bytes.Buffer cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-dir") @@ -88,6 +90,7 @@ func (q *WorkingDirectory) Ask(ctx context.Context) error { answers.WorkingDirectory = os.DirFS(gitRepoAbsPath) answers.Cwd = gitRepoAbsPath answers.HasGit = true + answers.Discoverer = discovery.New(answers.WorkingDirectory) } } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 381c3bb..fde0618 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -163,3 +163,29 @@ func GetTOMLValue(fileSystem fs.FS, keyPath []string, filePath string, caseInsen return GetMapValue(keyPath, data) } + +// CountFiles counts files by extension in the given filesystem. +func CountFiles(fileSystem fs.FS) (map[string]int, error) { + fileCounter := make(map[string]int) + err := fs.WalkDir(fileSystem, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + if slices.Contains(skipDirs, d.Name()) { + return filepath.SkipDir + } + return nil + } + + ext := filepath.Ext(path) + fileCounter[ext]++ + return nil + }) + if err != nil { + return nil, err + } + + return fileCounter, nil +} diff --git a/platformifier/models.go b/platformifier/models.go index 596f556..b580d44 100644 --- a/platformifier/models.go +++ b/platformifier/models.go @@ -28,6 +28,9 @@ const ( Flask Express Rails + Symfony + Ibexa + Shopware ) type Stack int @@ -50,6 +53,12 @@ func (s Stack) Name() string { return "flask" case Express: return "express" + case Symfony: + return "symfony" + case Ibexa: + return "ibexa" + case Shopware: + return "shopware" default: return "" } From c8068a9e283586a2630194520209aa14301d4a7c Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 23 Mar 2026 17:34:10 +0000 Subject: [PATCH 2/2] fix(discovery): fix bugs and clean up in discovery package - Add missing trailing space in nodeExecPrefix() for yarn, which caused "yarn execnext build" instead of "yarn exec next build". - Add missing Rails detection via config.ru, which was dropped during the refactor from question/stack.go to discovery/stack.go. - Add Rails case to discoverType() to return "ruby". - Change pipenv build step from "pipenv install" to "pipenv sync" to match the original behavior (installs from lock file only). - Remove redundant zero-value map initialization in discoverType(). Co-Authored-By: Claude Opus 4.6 --- discovery/build_steps.go | 2 +- discovery/dependency_managers.go | 2 +- discovery/discovery.go | 1 + discovery/runtime.go | 5 ++--- discovery/stack.go | 11 +++++++++++ 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/discovery/build_steps.go b/discovery/build_steps.go index 4ff140e..e8d754d 100644 --- a/discovery/build_steps.go +++ b/discovery/build_steps.go @@ -68,7 +68,7 @@ func (d *Discoverer) discoverBuildSteps() ([]string, error) { "# Install Pipenv as a global tool", "python -m venv /app/.global", "pip install pipenv==$PIPENV_TOOL_VERSION", - "pipenv install", + "pipenv sync", ) case "pip": buildSteps = append( diff --git a/discovery/dependency_managers.go b/discovery/dependency_managers.go index a520a53..1f92791 100644 --- a/discovery/dependency_managers.go +++ b/discovery/dependency_managers.go @@ -94,7 +94,7 @@ func (d *Discoverer) nodeExecPrefix() string { } if slices.Contains(dependencyManagers, "yarn") { - return "yarn exec" + return "yarn exec " } if slices.Contains(dependencyManagers, "npm") { diff --git a/discovery/discovery.go b/discovery/discovery.go index 62c25cf..2dd7031 100644 --- a/discovery/discovery.go +++ b/discovery/discovery.go @@ -10,6 +10,7 @@ const ( composerJSONFile = "composer.json" packageJSONFile = "package.json" symfonyLockFile = "symfony.lock" + rackFile = "config.ru" ) // Discoverer detects project characteristics from the filesystem. diff --git a/discovery/runtime.go b/discovery/runtime.go index db2bccb..f6c6fa0 100644 --- a/discovery/runtime.go +++ b/discovery/runtime.go @@ -51,6 +51,8 @@ func (d *Discoverer) discoverType() (string, error) { return "php", nil case platformifier.Django, platformifier.Flask: return "python", nil + case platformifier.Rails: + return "ruby", nil case platformifier.Express, platformifier.NextJS, platformifier.Strapi: return "nodejs", nil } @@ -63,9 +65,6 @@ func (d *Discoverer) discoverType() (string, error) { langCount := make(map[string]int) for ext, count := range extCount { if lang, ok := languageMap[ext]; ok { - if _, _ok := langCount[lang]; !_ok { - langCount[lang] = 0 - } langCount[lang] += count } } diff --git a/discovery/stack.go b/discovery/stack.go index ee68586..2863cf5 100644 --- a/discovery/stack.go +++ b/discovery/stack.go @@ -30,6 +30,17 @@ func (d *Discoverer) discoverStack() (platformifier.Stack, error) { return platformifier.Django, nil } + rackPath := utils.FindFile(d.fileSystem, "", rackFile) + if rackPath != "" { + f, err := d.fileSystem.Open(rackPath) + if err == nil { + defer f.Close() + if ok, _ := utils.ContainsStringInFile(f, "Rails.application.load_server", true); ok { + return platformifier.Rails, nil + } + } + } + requirementsPath := utils.FindFile(d.fileSystem, "", "requirements.txt") if requirementsPath != "" { f, err := d.fileSystem.Open(requirementsPath)