From 47f473e63f6351768a5b92fde85f092b14aedfd0 Mon Sep 17 00:00:00 2001 From: Antonis Kalipetis Date: Thu, 13 Feb 2025 13:34:32 +0200 Subject: [PATCH 1/2] feat(commands): add non-interactive mode and Discover function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --no-interaction flag and a Discover() function that can be called programmatically by external services like source-integration-apps. - Add NoInteraction field to Answers (defaults to false for CLI) - Add --no-interaction flag to platformify/upsunify commands - Extract Discover() function from Platformify() — accepts an fs.FS and returns *UserInput without writing files - Platformify() now calls Discover() internally, then writes files - FilesOverwrite accepts a file list and skips in non-interactive mode - All interactive question handlers skip prompts when NoInteraction is true, using Discoverer for auto-detection instead - WorkingDirectory handler skips git detection in non-interactive mode and supports pre-set WorkingDirectory from callers Co-Authored-By: Claude Opus 4.6 (1M context) --- commands/commands.go | 6 +-- commands/platformify.go | 70 ++++++++++++++++++++----- internal/question/almost_done.go | 5 ++ internal/question/dependency_manager.go | 31 +++-------- internal/question/environment.go | 14 ++--- internal/question/files_overwrite.go | 23 ++++++-- internal/question/models/answer.go | 1 + internal/question/name.go | 9 +++- internal/question/services.go | 2 +- internal/question/stack.go | 4 ++ internal/question/type.go | 23 ++++++-- internal/question/welcome.go | 5 ++ internal/question/working_directory.go | 25 ++++----- 13 files changed, 148 insertions(+), 70 deletions(-) diff --git a/commands/commands.go b/commands/commands.go index 93949ab..25d6a9d 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -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)) } diff --git a/commands/platformify.go b/commands/platformify.go index 2088631..d00ea0e 100644 --- a/commands/platformify.go +++ b/commands/platformify.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "path" @@ -23,6 +24,7 @@ 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"}, @@ -30,21 +32,40 @@ func NewPlatformifyCmd(assets *vendorization.VendorAssets) *cobra.Command { 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) { + 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{}, @@ -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 + } + 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 { + 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 { diff --git a/internal/question/almost_done.go b/internal/question/almost_done.go index 252a8a4..6b4d8be 100644 --- a/internal/question/almost_done.go +++ b/internal/question/almost_done.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/platformsh/platformify/internal/colors" + "github.com/platformsh/platformify/internal/question/models" ) type AlmostDone struct{} @@ -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, " (\\_/)")) diff --git a/internal/question/dependency_manager.go b/internal/question/dependency_manager.go index 8c4bf57..d92113b 100644 --- a/internal/question/dependency_manager.go +++ b/internal/question/dependency_manager.go @@ -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{} @@ -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 { diff --git a/internal/question/environment.go b/internal/question/environment.go index b11d8e3..791a4bf 100644 --- a/internal/question/environment.go +++ b/internal/question/environment.go @@ -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 return nil } diff --git a/internal/question/files_overwrite.go b/internal/question/files_overwrite.go index 2806464..f772f34 100644 --- a/internal/question/files_overwrite.go +++ b/internal/question/files_overwrite.go @@ -13,7 +13,12 @@ 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 +} func (q *FilesOverwrite) Ask(ctx context.Context) error { answers, ok := models.FromContext(ctx) @@ -21,20 +26,30 @@ func (q *FilesOverwrite) Ask(ctx context.Context) error { 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( diff --git a/internal/question/models/answer.go b/internal/question/models/answer.go index 51229ea..2357e45 100644 --- a/internal/question/models/answer.go +++ b/internal/question/models/answer.go @@ -12,6 +12,7 @@ import ( ) type Answers struct { + NoInteraction bool Stack Stack `json:"stack"` Flavor string `json:"flavor"` Type RuntimeType `json:"type"` diff --git a/internal/question/name.go b/internal/question/name.go index f8409f3..354f03e 100644 --- a/internal/question/name.go +++ b/internal/question/name.go @@ -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 diff --git a/internal/question/services.go b/internal/question/services.go index 9d4469b..49cf08f 100644 --- a/internal/question/services.go +++ b/internal/question/services.go @@ -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 } diff --git a/internal/question/stack.go b/internal/question/stack.go index f72be4a..018f617 100644 --- a/internal/question/stack.go +++ b/internal/question/stack.go @@ -68,6 +68,10 @@ func (q *Stack) Ask(ctx context.Context) error { answers.Stack = models.Rails return nil case platformifier.Symfony: + if answers.NoInteraction { + answers.Stack = models.GenericStack + return nil + } // Interactive: offer Symfony CLI below. default: answers.Stack = models.GenericStack diff --git a/internal/question/type.go b/internal/question/type.go index 5f7df51..708080d 100644 --- a/internal/question/type.go +++ b/internal/question/type.go @@ -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", @@ -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 { + 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))) diff --git a/internal/question/welcome.go b/internal/question/welcome.go index 959626e..a6c063e 100644 --- a/internal/question/welcome.go +++ b/internal/question/welcome.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/platformsh/platformify/internal/colors" + "github.com/platformsh/platformify/internal/question/models" "github.com/platformsh/platformify/vendorization" ) @@ -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( diff --git a/internal/question/working_directory.go b/internal/question/working_directory.go index 6bccbb5..0ed6e16 100644 --- a/internal/question/working_directory.go +++ b/internal/question/working_directory.go @@ -19,29 +19,30 @@ import ( type WorkingDirectory struct{} func (q *WorkingDirectory) Ask(ctx context.Context) error { - _, stderr, ok := colors.FromContext(ctx) + _, stderr, _ := colors.FromContext(ctx) + answers, ok := models.FromContext(ctx) if !ok { return nil } - - cwd, err := os.Getwd() - if err != nil { - return err + if answers.WorkingDirectory == nil { + cwd, err := os.Getwd() + if err != nil { + return err + } + answers.WorkingDirectory = os.DirFS(cwd) + answers.Cwd = cwd + answers.HasGit = false } - answers, ok := models.FromContext(ctx) - if !ok { + answers.Discoverer = discovery.New(answers.WorkingDirectory) + if answers.NoInteraction { return nil } - 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") cmd.Stdout = &outBuf cmd.Stderr = &errBuf - err = cmd.Run() + err := cmd.Run() if err != nil { fmt.Fprintln( stderr, From 1c50d81fdbf8c01b3ba7eeabe4d28d8af8423854 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 23 Mar 2026 17:34:47 +0000 Subject: [PATCH 2/2] fix(review): fix slice preallocation bug and remove redundant assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In answer.go, make([]string, len(...)) pre-fills with empty strings before appending, producing a slice like ["", "", "composer", "pip"]. Use capacity instead of length. In stack.go, the Symfony non-interactive case redundantly sets answers.Stack to GenericStack — it was already set before the switch. Co-Authored-By: Claude Opus 4.6 --- internal/question/models/answer.go | 2 +- internal/question/stack.go | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/question/models/answer.go b/internal/question/models/answer.go index 2357e45..7a564c8 100644 --- a/internal/question/models/answer.go +++ b/internal/question/models/answer.go @@ -113,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()) } diff --git a/internal/question/stack.go b/internal/question/stack.go index 018f617..f9fd6d5 100644 --- a/internal/question/stack.go +++ b/internal/question/stack.go @@ -69,7 +69,6 @@ func (q *Stack) Ask(ctx context.Context) error { return nil case platformifier.Symfony: if answers.NoInteraction { - answers.Stack = models.GenericStack return nil } // Interactive: offer Symfony CLI below.