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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ To help you start building real-world applications, we've created Kool Presets a
- **Node**: [NestJS](docs/03-Presets/NestJS.md), [AdonisJs](docs/03-Presets/AdonisJs.md), [Express.js](/docs/03-Presets/ExpressJS.md)
- **PHP**: [Laravel](docs/03-Presets/Laravel.md), [Laravel Octane](docs/03-Presets/Laravel+Octane.md), [Symfony](docs/03-Presets/Symfony.md), [CodeIgniter](docs/03-Presets/CodeIgniter.md)
- **Javascript**: [Next.js](docs/03-Presets/NextJS.md), [NuxtJS](docs/03-Presets/NuxtJS.md)
- **Java**: [Java with Gradle](docs/03-Presets/Java.md), [Spring Boot](docs/03-Presets/SpringBoot.md)
- **Others**: [Hugo](docs/03-Presets/Hugo.md), [WordPress](docs/03-Presets/WordPress.md)

#### Monorepo structures
Expand Down
25 changes: 21 additions & 4 deletions commands/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package commands

import (
"fmt"
"kool-dev/kool/core/automate"
"kool-dev/kool/core/environment"
"kool-dev/kool/core/presets"
"kool-dev/kool/core/shell"
Expand All @@ -19,6 +20,8 @@ type KoolCreate struct {
DefaultKoolService
parser presets.Parser
env environment.EnvStorage

Flags *KoolAnswersFlags
}

func AddKoolCreate(root *cobra.Command) {
Expand All @@ -35,15 +38,22 @@ func NewKoolCreate() *KoolCreate {
*newDefaultKoolService(),
presets.NewParser(),
environment.NewEnvStorage(),
&KoolAnswersFlags{},
}
}

// Execute runs the create logic with incoming arguments.
func (c *KoolCreate) Execute(args []string) (err error) {
var (
createDirectory, preset string
answers automate.Answers
noInteraction bool
)

if answers, noInteraction, err = c.Flags.parse(); err != nil {
return
}

if len(args) == 2 {
preset = args[0]
createDirectory = args[1]
Expand Down Expand Up @@ -93,7 +103,7 @@ func (c *KoolCreate) Execute(args []string) (err error) {

c.Shell().Println("Creating new", preset, "project...")

c.parser.PrepareExecutor(c.Shell())
c.parser.PrepareExecutor(c.Shell(), answers, noInteraction)

if err = c.parser.Create(preset); err != nil {
return
Expand Down Expand Up @@ -127,12 +137,19 @@ func NewCreateCommand(create *KoolCreate) (createCmd *cobra.Command) {
createCmd = &cobra.Command{
Use: "create PRESET FOLDER",
Short: "Create a new project using a preset",
Long: "Create a new project using the specified PRESET in a directory named FOLDER.",
Args: cobra.MaximumNArgs(2),
RunE: DefaultCommandRunFunction(create),
Long: `Create a new project using the specified PRESET in a directory named FOLDER.

Questions asked by the preset can be answered beforehand with --answer REF=value,
where REF is the question ref (or the question text when it has no ref). Options
can be given by value or label, and multiple choices are comma separated. Without
a terminal, or with --no-interaction, a question without an answer fails.`,
Args: cobra.MaximumNArgs(2),
RunE: DefaultCommandRunFunction(create),

DisableFlagsInUseLine: true,
}

AddAnswersFlags(createCmd, create.Flags)

return
}
33 changes: 33 additions & 0 deletions commands/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func newFakeKoolCreate() *KoolCreate {
*(newDefaultKoolService().Fake()),
&presets.FakeParser{},
environment.NewFakeEnvStorage(),
&KoolAnswersFlags{},
}
}

Expand Down Expand Up @@ -117,3 +118,35 @@ func TestErrInstallCreateCommand(t *testing.T) {
// return to original folder
_ = os.Chdir(cwd)
}

func TestCreateCommandAnswersFlags(t *testing.T) {
f := newFakeKoolCreate()
f.parser.(*presets.FakeParser).MockExists = true

cmd := NewCreateCommand(f)
cwd, _ := os.Getwd()
cmd.SetArgs([]string{"spring-boot", t.TempDir(), "--answer", "JAVA_VERSION=25", "-a", "SPRING_DEPENDENCIES=web,actuator", "-n"})

if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error executing create command; error: %v", err)
}
_ = os.Chdir(cwd)

calls := f.parser.(*presets.FakeParser).CalledPrepareExecutor
if len(calls) != 1 {
t.Fatalf("expected PrepareExecutor to be called once, got %d", len(calls))
}
if !calls[0].NoInteraction || calls[0].Answers["JAVA_VERSION"] != "25" || calls[0].Answers["SPRING_DEPENDENCIES"] != "web,actuator" {
t.Errorf("unexpected answers passed to the executor: %+v", calls[0])
}
}

func TestCreateCommandInvalidAnswer(t *testing.T) {
f := newFakeKoolCreate()
f.parser.(*presets.FakeParser).MockExists = true

cmd := NewCreateCommand(f)
cmd.SetArgs([]string{"spring-boot", "my-app", "--answer", "JAVA_VERSION"})

assertExecGotError(t, cmd, "invalid answer")
}
44 changes: 42 additions & 2 deletions commands/preset.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package commands

import (
"fmt"
"kool-dev/kool/core/automate"
"kool-dev/kool/core/presets"
"kool-dev/kool/core/shell"
"sort"
Expand All @@ -14,6 +15,32 @@ type KoolPreset struct {
DefaultKoolService
presetsParser presets.Parser
promptSelect shell.PromptSelect

Flags *KoolAnswersFlags
}

// KoolAnswersFlags holds the flags for answering the preset questions
// beforehand, so they are not prompted interactively
type KoolAnswersFlags struct {
Answers []string
NoInteraction bool
}

// AddAnswersFlags registers the flags for answering preset questions
func AddAnswersFlags(cmd *cobra.Command, flags *KoolAnswersFlags) {
cmd.Flags().StringArrayVarP(&flags.Answers, "answer", "a", []string{}, "Answer to a preset question as REF=value (repeatable; comma separate multiple choices)")
cmd.Flags().BoolVarP(&flags.NoInteraction, "no-interaction", "n", false, "Fail on any preset question without an answer instead of prompting")
}

// parse parses the provided answers flags
func (f *KoolAnswersFlags) parse() (answers automate.Answers, noInteraction bool, err error) {
if f == nil {
return
}

answers, err = automate.ParseAnswers(f.Answers)
noInteraction = f.NoInteraction
return
}

func AddKoolPreset(root *cobra.Command) {
Expand All @@ -31,6 +58,7 @@ func NewKoolPreset() *KoolPreset {
*newDefaultKoolService(),
presets.NewParser(),
shell.NewPromptSelect(),
&KoolAnswersFlags{},
}
}

Expand All @@ -47,9 +75,14 @@ func (p *KoolPreset) Execute(args []string) (err error) {
return
}

answers, noInteraction, err := p.Flags.parse()
if err != nil {
return
}

p.Shell().Println("Preset", preset, "is initializing!")

p.presetsParser.PrepareExecutor(p.Shell())
p.presetsParser.PrepareExecutor(p.Shell(), answers, noInteraction)

if err = p.presetsParser.Install(preset); err != nil {
return
Expand All @@ -66,12 +99,19 @@ func NewPresetCommand(preset *KoolPreset) (presetCmd *cobra.Command) {
Short: "Install configuration files customized for Kool in the current directory",
Long: `Initialize a project using the specified [PRESET] by installing configuration
files customized for Kool in the current working directory. If no [PRESET] is provided,
an interactive wizard will present the available options.`,
an interactive wizard will present the available options.

Questions asked by the preset can be answered beforehand with --answer REF=value,
where REF is the question ref (or the question text when it has no ref). Options
can be given by value or label, and multiple choices are comma separated. Without
a terminal, or with --no-interaction, a question without an answer fails.`,
Args: cobra.MaximumNArgs(1),
RunE: DefaultCommandRunFunction(preset),
DisableFlagsInUseLine: true,
}

AddAnswersFlags(presetCmd, preset.Flags)

return
}

Expand Down
36 changes: 36 additions & 0 deletions core/automate/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ const (
TypePrompt
TypeRecipe
TypeMerge
TypeInput
TypeAPI
TypeReplace
)

// ActionSet represents a set of single actions or a question
Expand All @@ -34,8 +37,29 @@ type Action struct {
Scripts []string `yaml:"scripts"`
// prompt
Prompt string `yaml:"prompt"`
Input string `yaml:"input"`
Default string `yaml:"default"`
Options []*ActionSet `yaml:"options"`
// api
API string `yaml:"api"`
Prompts []*APIField `yaml:"prompts"`
// replace
Replace string `yaml:"replace"`
}

// APIField describes a prompt backed by a field in an API response.
type APIField struct {
Path string `yaml:"path"`
Options string `yaml:"options"`
Value string `yaml:"value"`
Label string `yaml:"label"`
Default string `yaml:"default"`
Prompt string `yaml:"prompt"`
Ref string `yaml:"ref"`
Multiple bool `yaml:"multiple"`
// Compatible is the path of an API field whose default version must
// satisfy each option's versionRange; incompatible options are hidden
Compatible string `yaml:"compatible"`
}

// Type tells the actual implementation of this action
Expand All @@ -56,9 +80,21 @@ func (a *Action) Type() ActionType {
return TypePrompt
}

if a.Input != "" {
return TypeInput
}

if a.Merge != "" {
return TypeMerge
}

if a.API != "" {
return TypeAPI
}

if a.Replace != "" {
return TypeReplace
}

return TypeUnknown
}
54 changes: 54 additions & 0 deletions core/automate/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,60 @@ func TestParseActionPrompt(t *testing.T) {
})
}

func TestParseActionInput(t *testing.T) {
a := parseAction("input: 'Project name'\nref: 'PROJECT_NAME'\ndefault: 'application'", t)

if a.Input != "Project name" || a.Ref != "PROJECT_NAME" || a.Default != "application" {
t.Errorf("failed parsing ActionInput: %v", a)
}
if a.Type() != TypeInput {
t.Errorf("failed parsing ActionInput type; got: %v", a.Type())
}
}

func TestParseActionAPI(t *testing.T) {
a := parseAction("api: 'https://example.test/options'\nprompts:\n - path: 'data'\n options: 'items'\n value: 'key'\n label: 'title'\n multiple: true", t)

if a.API == "" || len(a.Prompts) != 1 || a.Prompts[0].Options != "items" || !a.Prompts[0].Multiple {
t.Errorf("failed parsing ActionAPI: %v", a)
}
if a.Type() != TypeAPI {
t.Errorf("failed parsing ActionAPI type; got: %v", a.Type())
}
}

func TestParseActionReplace(t *testing.T) {
a := parseAction("replace: 'JAVA_VERSION $JAVA_VERSION'", t)

if a.Replace != "JAVA_VERSION $JAVA_VERSION" {
t.Errorf("failed parsing ActionReplace: %v", a)
}
if a.Type() != TypeReplace {
t.Errorf("failed parsing ActionReplace type; got: %v", a.Type())
}
}

func TestAPIOptions(t *testing.T) {
data := map[string]any{
"data": map[string]any{
"default": "two",
"items": []any{
map[string]any{"key": "one", "title": "One"},
map[string]any{"key": "two", "title": "Two"},
},
},
}
options, defaultValue, err := apiOptions(data, &APIField{
Path: "data",
Options: "items",
Value: "key",
Label: "title",
})
if err != nil || len(options) != 2 || options[1].Value != "two" || defaultValue != "two" {
t.Errorf("unexpected API options: options=%v default=%q err=%v", options, defaultValue, err)
}
}

func TestParseActionMerge(t *testing.T) {
t.Run("Parse merge basic", func(t *testing.T) {
a := parseAction("merge: 'foo'", t)
Expand Down
Loading
Loading