Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions discovery/application_root.go
Original file line number Diff line number Diff line change
@@ -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
}
67 changes: 67 additions & 0 deletions discovery/application_root_test.go
Original file line number Diff line number Diff line change
@@ -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{},
Comment on lines +40 to +42
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test fixture uses composer/composer-lock.json, but the code detects Composer via composer.lock. As written, this test doesn’t exercise Composer detection at all. Rename the fixture to composer/composer.lock (or adjust the detector if the intended file is different).

Suggested change
"yarn/yarn.lock": &fstest.MapFile{},
"poetry/poetry.lock": &fstest.MapFile{},
"composer/composer-lock.json": &fstest.MapFile{},
"yarn/yarn.lock": &fstest.MapFile{},
"poetry/poetry.lock": &fstest.MapFile{},
"composer/composer.lock": &fstest.MapFile{},

Copilot uses AI. Check for mistakes.
},
},
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)
}
})
}
}
137 changes: 137 additions & 0 deletions discovery/build_steps.go
Original file line number Diff line number Diff line change
@@ -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 sync",
)
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),
)
Comment on lines +118 to +127
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

managePyPath is used directly in the collectstatic command, but FindFile() returns a path relative to the repo root (e.g. project/manage.py). If build hooks execute from application_root (common), this becomes the wrong path when application_root != ".". Convert managePyPath to a path relative to appRoot (similar to the existing question implementation) before formatting the command.

Copilot uses AI. Check for mistakes.
}
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
}
109 changes: 109 additions & 0 deletions discovery/build_steps_test.go
Original file line number Diff line number Diff line change
@@ -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": ".",
},
Comment on lines +47 to +59
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test case name says "Pipenv Django with Yarn build", but the setup uses dependency_managers: []string{"poetry", "yarn"} and asserts Poetry commands. Rename the case (or change the fixture) so the test name matches what it actually covers.

Copilot uses AI. Check for mistakes.
},
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)
}
})
}
}
Loading
Loading