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
6 changes: 3 additions & 3 deletions commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import (

// Execute executes the ify command and sets flags appropriately.
func Execute(assets *vendorization.VendorAssets) error {
cmd := NewPlatformifyCmd(assets)
rootCmd := NewPlatformifyCmd(assets)
validateCmd := NewValidateCommand(assets)
cmd.AddCommand(validateCmd)
return cmd.ExecuteContext(vendorization.WithVendorAssets(context.Background(), assets))
rootCmd.AddCommand(validateCmd)
return rootCmd.ExecuteContext(vendorization.WithVendorAssets(context.Background(), assets))
}
70 changes: 58 additions & 12 deletions commands/platformify.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"

Expand All @@ -23,28 +24,48 @@ type contextKey string
var FlavorKey contextKey = "flavor"

func NewPlatformifyCmd(assets *vendorization.VendorAssets) *cobra.Command {
var noInteraction bool
cmd := &cobra.Command{
Use: assets.Use,
Aliases: []string{"ify"},
Short: fmt.Sprintf("Creates starter YAML files for your %s project", assets.ServiceName),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return Platformify(cmd.Context(), cmd.OutOrStderr(), cmd.ErrOrStderr(), assets)
return Platformify(
cmd.Context(),
cmd.OutOrStderr(),
cmd.ErrOrStderr(),
noInteraction,
assets,
)
},
}

cmd.Flags().BoolVar(&noInteraction, "no-interaction", false, "Disable interactive prompts")
return cmd
}

func Platformify(ctx context.Context, stdout, stderr io.Writer, assets *vendorization.VendorAssets) error {
answers := models.NewAnswers()
answers.Flavor, _ = ctx.Value(FlavorKey).(string)
ctx = models.ToContext(ctx, answers)
ctx = colors.ToContext(ctx, stdout, stderr)
// Discover detects project configuration non-interactively.
// It is the entry point for programmatic callers like source-integration-apps.
func Discover(
ctx context.Context,
flavor string,
noInteraction bool,
fileSystem fs.FS,
) (*platformifier.UserInput, error) {
Comment on lines +49 to +56
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 Discover() docstring says it detects configuration “non-interactively”, but the function accepts noInteraction=false and will still run interactive questions. Consider updating the comment to reflect actual behavior (or enforce non-interactive behavior inside Discover).

Copilot uses AI. Check for mistakes.
answers, _ := models.FromContext(ctx)
if answers == nil {
answers = models.NewAnswers()
ctx = models.ToContext(ctx, answers)
}
answers.Flavor = flavor
answers.NoInteraction = noInteraction
if fileSystem != nil {
answers.WorkingDirectory = fileSystem
}
q := questionnaire.New(
&question.WorkingDirectory{},
&question.FilesOverwrite{},
&question.Welcome{},
&question.Stack{},
&question.Type{},
Expand All @@ -63,23 +84,48 @@ func Platformify(ctx context.Context, stdout, stderr io.Writer, assets *vendoriz
)
err := q.AskQuestions(ctx)
if errors.Is(err, questionnaire.ErrSilent) {
return nil
return nil, nil
}

if err != nil {
fmt.Fprintln(stderr, colors.Colorize(colors.ErrorCode, err.Error()))
return err
return nil, err
}

input := answers.ToUserInput()
return answers.ToUserInput(), nil
}

func Platformify(
ctx context.Context,
stdout, stderr io.Writer,
noInteraction bool,
assets *vendorization.VendorAssets,
) error {
ctx = colors.ToContext(ctx, stdout, stderr)
ctx = models.ToContext(ctx, models.NewAnswers())
input, err := Discover(ctx, assets.ConfigFlavor, noInteraction, nil)
if err != nil {
return err
}
Comment on lines +103 to +108
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.

With SilenceErrors=true on the Cobra command, returning an error without printing it results in the CLI exiting with status 1 but no error message. After calling Discover()/Platformify(), errors are now returned directly (no colored output). Please print a user-facing error to stderr in Platformify (or disable SilenceErrors).

Copilot uses AI. Check for mistakes.
if input == nil {
return nil
}
answers, _ := models.FromContext(ctx)
pfier := platformifier.New(input, assets.ConfigFlavor)
configFiles, err := pfier.Platformify(ctx)
if err != nil {
fmt.Fprintln(stderr, colors.Colorize(colors.ErrorCode, err.Error()))
return fmt.Errorf("could not configure project: %w", err)
}

filesToCreateUpdate := make([]string, 0, len(configFiles))
for file := range configFiles {
filesToCreateUpdate = append(filesToCreateUpdate, file)
}

filesOverwrite := question.FilesOverwrite{FilesToCreateUpdate: filesToCreateUpdate}
if err := filesOverwrite.Ask(ctx); err != nil {
Comment on lines +119 to +125
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.

filesToCreateUpdate is built by iterating over a map, so the order is non-deterministic. That can lead to inconsistent prompt output in FilesOverwrite (and makes behavior harder to test/debug). Consider sorting the file list before passing it into FilesOverwrite.

Copilot uses AI. Check for mistakes.
return err
}

for file, contents := range configFiles {
filePath := path.Join(answers.Cwd, file)
if err := os.MkdirAll(path.Dir(filePath), os.ModeDir|os.ModePerm); err != nil {
Expand Down
5 changes: 5 additions & 0 deletions internal/question/almost_done.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"

"github.com/platformsh/platformify/internal/colors"
"github.com/platformsh/platformify/internal/question/models"
)

type AlmostDone struct{}
Expand All @@ -14,6 +15,10 @@ func (q *AlmostDone) Ask(ctx context.Context) error {
if !ok {
return nil
}
answers, ok := models.FromContext(ctx)
if !ok || answers.NoInteraction {
return nil
}

fmt.Fprintln(out)
fmt.Fprintln(out, colors.Colorize(colors.AccentCode, " (\\_/)"))
Expand Down
31 changes: 7 additions & 24 deletions internal/question/dependency_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,7 @@ import (
)

const (
npmLockFileName = "package-lock.json"
yarnLockFileName = "yarn.lock"
poetryLockFile = "poetry.lock"
pipenvLockFile = "Pipfile.lock"
pipLockFile = "requirements.txt"
composerLockFile = "composer.lock"
bundlerLockFile = "Gemfile.lock"
bundlerLockFile = "Gemfile.lock"
)

type DependencyManager struct{}
Expand Down Expand Up @@ -58,24 +52,13 @@ func (q *DependencyManager) Ask(ctx context.Context) error {
}
}()

if exists := utils.FileExists(answers.WorkingDirectory, "", poetryLockFile); exists {
answers.DependencyManagers = append(answers.DependencyManagers, models.Poetry)
} else if exists := utils.FileExists(answers.WorkingDirectory, "", pipenvLockFile); exists {
answers.DependencyManagers = append(answers.DependencyManagers, models.Pipenv)
} else if exists := utils.FileExists(answers.WorkingDirectory, "", pipLockFile); exists {
answers.DependencyManagers = append(answers.DependencyManagers, models.Pip)
dependencyManagers, err := answers.Discoverer.DependencyManagers()
if err != nil {
return err
}

if exists := utils.FileExists(answers.WorkingDirectory, "", composerLockFile); exists {
answers.DependencyManagers = append(answers.DependencyManagers, models.Composer)
answers.Dependencies["php"] = map[string]string{"composer/composer": "^2"}
}

if exists := utils.FileExists(answers.WorkingDirectory, "", yarnLockFileName); exists {
answers.DependencyManagers = append(answers.DependencyManagers, models.Yarn)
answers.Dependencies["nodejs"] = map[string]string{"yarn": "^1.22.0"}
} else if exists := utils.FileExists(answers.WorkingDirectory, "", npmLockFileName); exists {
answers.DependencyManagers = append(answers.DependencyManagers, models.Npm)
answers.DependencyManagers = make([]models.DepManager, 0, len(dependencyManagers))
for _, dm := range dependencyManagers {
answers.DependencyManagers = append(answers.DependencyManagers, models.DepManager(dm))
}

if exists := utils.FileExists(answers.WorkingDirectory, "", bundlerLockFile); exists {
Expand Down
14 changes: 4 additions & 10 deletions internal/question/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,11 @@ func (q *Environment) Ask(ctx context.Context) error {
return nil
}

answers.Environment = make(map[string]string)
for _, dm := range answers.DependencyManagers {
switch dm {
case models.Poetry:
answers.Environment["POETRY_VERSION"] = "1.8.4"
answers.Environment["POETRY_VIRTUALENVS_IN_PROJECT"] = "true"
case models.Pipenv:
answers.Environment["PIPENV_TOOL_VERSION"] = "2024.2.0"
answers.Environment["PIPENV_VENV_IN_PROJECT"] = "1"
}
environment, err := answers.Discoverer.Environment()
if err != nil {
return err
}

answers.Environment = environment
Comment on lines +17 to +22
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.

Switching to Discoverer.Environment() changes the pinned tool versions (e.g., Poetry/Pipenv) compared to the previous hard-coded values in this question. Please confirm the intended versions and update discovery.Environment() accordingly, otherwise this is a silent behavior regression for generated configs.

Copilot uses AI. Check for mistakes.
return nil
}
23 changes: 19 additions & 4 deletions internal/question/files_overwrite.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,43 @@ import (
"github.com/platformsh/platformify/vendorization"
)

type FilesOverwrite struct{}
// FilesOverwrite prompts the user to confirm overwriting existing config files.
// If FilesToCreateUpdate is set, those files are checked instead of the
// default proprietary files list.
type FilesOverwrite struct {
FilesToCreateUpdate []string
}
Comment on lines +16 to +21
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 field name FilesToCreateUpdate is a bit unclear/grammatically awkward. Consider renaming to something like FilesToCreateOrUpdate (and updating the comment) to better communicate intent to future maintainers.

Copilot uses AI. Check for mistakes.

func (q *FilesOverwrite) Ask(ctx context.Context) error {
answers, ok := models.FromContext(ctx)
if !ok {
return nil
}

if answers.NoInteraction {
return nil
}

_, stderr, ok := colors.FromContext(ctx)
if !ok {
return nil
}

assets, _ := vendorization.FromContext(ctx)
existingFiles := make([]string, 0, len(assets.ProprietaryFiles()))
for _, p := range assets.ProprietaryFiles() {
filesToCheck := q.FilesToCreateUpdate
if len(filesToCheck) == 0 {
assets, _ := vendorization.FromContext(ctx)
filesToCheck = assets.ProprietaryFiles()
}

existingFiles := make([]string, 0, len(filesToCheck))
for _, p := range filesToCheck {
if st, err := fs.Stat(answers.WorkingDirectory, p); err == nil && !st.IsDir() {
existingFiles = append(existingFiles, p)
}
}

if len(existingFiles) > 0 {
assets, _ := vendorization.FromContext(ctx)
fmt.Fprintln(
stderr,
colors.Colorize(
Expand Down
3 changes: 2 additions & 1 deletion internal/question/models/answer.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
)

type Answers struct {
NoInteraction bool
Stack Stack `json:"stack"`
Flavor string `json:"flavor"`
Type RuntimeType `json:"type"`
Expand Down Expand Up @@ -112,7 +113,7 @@ func (a *Answers) ToUserInput() *platformifier.UserInput {
locations[key] = value
}

dependencyManagers := make([]string, len(a.DependencyManagers))
dependencyManagers := make([]string, 0, len(a.DependencyManagers))
for _, dm := range a.DependencyManagers {
dependencyManagers = append(dependencyManagers, dm.String())
}
Expand Down
9 changes: 8 additions & 1 deletion internal/question/name.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,20 @@ func (q *Name) Ask(ctx context.Context) error {
if !ok {
return nil
}
defaultName := slugify(path.Base(answers.Cwd))
if defaultName == "" {
defaultName = "app"
}
if answers.NoInteraction {
answers.Name = defaultName
}
if answers.Name != "" {
// Skip the step
return nil
}

question := &survey.Input{
Message: "Tell us your project's application name:", Default: slugify(path.Base(answers.Cwd)),
Message: "Tell us your project's application name:", Default: defaultName,
}

var name string
Expand Down
2 changes: 1 addition & 1 deletion internal/question/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func (q *Services) Ask(ctx context.Context) error {
if !ok {
return nil
}
if len(answers.Services) != 0 {
if len(answers.Services) != 0 || answers.NoInteraction {
// Skip the step
return nil
}
Expand Down
3 changes: 3 additions & 0 deletions internal/question/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ func (q *Stack) Ask(ctx context.Context) error {
answers.Stack = models.Rails
return nil
case platformifier.Symfony:
if answers.NoInteraction {
return nil
}
// Interactive: offer Symfony CLI below.
default:
answers.Stack = models.GenericStack
Expand Down
23 changes: 20 additions & 3 deletions internal/question/type.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func (q *Type) Ask(ctx context.Context) error {
return
}

if answers.Stack != models.GenericStack && answers.Type.Runtime != nil {
if answers.Type.Runtime != nil && answers.Type.Runtime.Title() != "" {
fmt.Fprintf(
stderr,
"%s %s\n",
Expand All @@ -41,12 +41,29 @@ func (q *Type) Ask(ctx context.Context) error {
}
}()

runtime := models.RuntimeForStack(answers.Stack)
if runtime == nil {
typ, err := answers.Discoverer.Type()
if err != nil {
return err
}
runtime, _ := models.Runtimes.RuntimeByType(typ)

if answers.NoInteraction {
Comment on lines +48 to +50
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.

RuntimeByType can return a non-nil error when the detected type isn’t in the registry. Currently the error is ignored and non-interactive mode returns a generic "no runtime detected". Please handle/propagate the RuntimeByType error (and include the detected typ in the error message) so callers can diagnose unsupported runtimes.

Suggested change
runtime, _ := models.Runtimes.RuntimeByType(typ)
if answers.NoInteraction {
runtime, runtimeErr := models.Runtimes.RuntimeByType(typ)
if answers.NoInteraction {
if runtimeErr != nil {
return fmt.Errorf("unsupported runtime type %s: %w", typ, runtimeErr)
}

Copilot uses AI. Check for mistakes.
if runtime == nil {
return fmt.Errorf("no runtime detected")
}
answers.Type.Runtime = runtime
answers.Type.Version = runtime.DefaultVersion()
return nil
}

if runtime == nil || answers.Stack == models.GenericStack {
question := &survey.Select{
Message: "What language is your project using? We support the following:",
Options: models.Runtimes.AllTitles(),
}
if runtime != nil {
question.Default = runtime.Title()
}

var title string
err := survey.AskOne(question, &title, survey.WithPageSize(len(question.Options)))
Expand Down
5 changes: 5 additions & 0 deletions internal/question/welcome.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"

"github.com/platformsh/platformify/internal/colors"
"github.com/platformsh/platformify/internal/question/models"
"github.com/platformsh/platformify/vendorization"
)

Expand All @@ -15,6 +16,10 @@ func (q *Welcome) Ask(ctx context.Context) error {
if !ok {
return nil
}
answers, ok := models.FromContext(ctx)
if !ok || answers.NoInteraction {
return nil
}

assets, _ := vendorization.FromContext(ctx)
fmt.Fprintln(
Expand Down
Loading
Loading