From 71af9619cb35f9741e75fd1a48517e552823d51b Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Sun, 18 Oct 2026 14:37:13 +0100 Subject: [PATCH 1/4] refactor(init): import Platformify into internal/platformify Copy the Platformify code that `init` uses (the guided wizard and its templates) from github.com/platformsh/platformify at dc7b173 (main, three commits after v0.5.0), and drop the module dependency. Left out: the standalone binaries, the `validate` command and the `validator` package with its schemas, which `lint` replaced. The platformifier tests now check the generated config with pkg/lint. Its stricter checks showed that the test fixtures were unlike real input (the type and runtime were swapped, and names and service types were invalid), so the fixtures are corrected. Refs CLI-184. Co-Authored-By: Claude Opus 5.5 --- commands/auth_test.go | 3 +- commands/completion_test.go | 3 +- commands/init.go | 7 +- commands/lint_test.go | 3 +- commands/root.go | 3 +- go.mod | 8 +- go.sum | 14 +- internal/platformify/colors/colors.go | 48 ++ internal/platformify/colors/context.go | 33 + internal/platformify/init_colors.go | 60 ++ internal/platformify/platformifier/django.go | 93 +++ .../platformify/platformifier/django_test.go | 123 ++++ internal/platformify/platformifier/fs.go | 90 +++ .../platformify/platformifier/fs_mock_test.go | 80 +++ internal/platformify/platformifier/generic.go | 65 ++ .../platformify/platformifier/generic_test.go | 173 +++++ internal/platformify/platformifier/laravel.go | 48 ++ internal/platformify/platformifier/models.go | 130 ++++ internal/platformify/platformifier/nextjs.go | 20 + .../platformifier/platformifier.go | 97 +++ .../platformifier/platformifier_mock_test.go | 49 ++ .../platformifier/platformifier_test.go | 595 ++++++++++++++++++ .../templates/django/settings_psh.py | 73 +++ .../templates/generic/.environment | 59 ++ .../templates/generic/.platform.app.yaml | 229 +++++++ .../templates/generic/.platform/routes.yaml | 14 + .../templates/generic/.platform/services.yaml | 21 + .../templates/upsun/.environment | 55 ++ .../templates/upsun/.upsun/config.yaml | 258 ++++++++ internal/platformify/platformify.go | 87 +++ internal/platformify/question/almost_done.go | 23 + .../platformify/question/application_root.go | 51 ++ internal/platformify/question/build_steps.go | 143 +++++ .../platformify/question/build_steps_test.go | 88 +++ .../question/dependency_manager.go | 86 +++ .../platformify/question/deploy_command.go | 52 ++ internal/platformify/question/done.go | 89 +++ internal/platformify/question/environment.go | 30 + .../platformify/question/files_overwrite.go | 73 +++ internal/platformify/question/locations.go | 43 ++ .../platformify/question/models/answer.go | 179 ++++++ .../question/models/answer_test.go | 85 +++ .../platformify/question/models/context.go | 24 + .../question/models/dependency_manager.go | 41 ++ .../question/models/generate_versions.go | 288 +++++++++ .../platformify/question/models/runtime.go | 81 +++ .../question/models/service_disk.go | 42 ++ .../question/models/service_name.go | 137 ++++ .../question/models/socket_family.go | 23 + internal/platformify/question/models/stack.go | 104 +++ .../platformify/question/models/version.go | 45 ++ internal/platformify/question/mounts.go | 85 +++ internal/platformify/question/name.go | 63 ++ internal/platformify/question/services.go | 91 +++ .../platformify/question/socket_family.go | 27 + internal/platformify/question/stack.go | 218 +++++++ internal/platformify/question/type.go | 66 ++ internal/platformify/question/web_command.go | 165 +++++ internal/platformify/question/welcome.go | 29 + .../platformify/question/working_directory.go | 93 +++ internal/platformify/questionnaire/errors.go | 11 + .../questionnaire/questionnaire.go | 29 + internal/platformify/utils/utils.go | 173 +++++ .../vendorization/vendorization.go | 87 +++ 64 files changed, 5392 insertions(+), 13 deletions(-) create mode 100644 internal/platformify/colors/colors.go create mode 100644 internal/platformify/colors/context.go create mode 100644 internal/platformify/init_colors.go create mode 100644 internal/platformify/platformifier/django.go create mode 100644 internal/platformify/platformifier/django_test.go create mode 100644 internal/platformify/platformifier/fs.go create mode 100644 internal/platformify/platformifier/fs_mock_test.go create mode 100644 internal/platformify/platformifier/generic.go create mode 100644 internal/platformify/platformifier/generic_test.go create mode 100644 internal/platformify/platformifier/laravel.go create mode 100644 internal/platformify/platformifier/models.go create mode 100644 internal/platformify/platformifier/nextjs.go create mode 100644 internal/platformify/platformifier/platformifier.go create mode 100644 internal/platformify/platformifier/platformifier_mock_test.go create mode 100644 internal/platformify/platformifier/platformifier_test.go create mode 100644 internal/platformify/platformifier/templates/django/settings_psh.py create mode 100644 internal/platformify/platformifier/templates/generic/.environment create mode 100644 internal/platformify/platformifier/templates/generic/.platform.app.yaml create mode 100644 internal/platformify/platformifier/templates/generic/.platform/routes.yaml create mode 100644 internal/platformify/platformifier/templates/generic/.platform/services.yaml create mode 100644 internal/platformify/platformifier/templates/upsun/.environment create mode 100644 internal/platformify/platformifier/templates/upsun/.upsun/config.yaml create mode 100644 internal/platformify/platformify.go create mode 100644 internal/platformify/question/almost_done.go create mode 100644 internal/platformify/question/application_root.go create mode 100644 internal/platformify/question/build_steps.go create mode 100644 internal/platformify/question/build_steps_test.go create mode 100644 internal/platformify/question/dependency_manager.go create mode 100644 internal/platformify/question/deploy_command.go create mode 100644 internal/platformify/question/done.go create mode 100644 internal/platformify/question/environment.go create mode 100644 internal/platformify/question/files_overwrite.go create mode 100644 internal/platformify/question/locations.go create mode 100644 internal/platformify/question/models/answer.go create mode 100644 internal/platformify/question/models/answer_test.go create mode 100644 internal/platformify/question/models/context.go create mode 100644 internal/platformify/question/models/dependency_manager.go create mode 100644 internal/platformify/question/models/generate_versions.go create mode 100644 internal/platformify/question/models/runtime.go create mode 100644 internal/platformify/question/models/service_disk.go create mode 100644 internal/platformify/question/models/service_name.go create mode 100644 internal/platformify/question/models/socket_family.go create mode 100644 internal/platformify/question/models/stack.go create mode 100644 internal/platformify/question/models/version.go create mode 100644 internal/platformify/question/mounts.go create mode 100644 internal/platformify/question/name.go create mode 100644 internal/platformify/question/services.go create mode 100644 internal/platformify/question/socket_family.go create mode 100644 internal/platformify/question/stack.go create mode 100644 internal/platformify/question/type.go create mode 100644 internal/platformify/question/web_command.go create mode 100644 internal/platformify/question/welcome.go create mode 100644 internal/platformify/question/working_directory.go create mode 100644 internal/platformify/questionnaire/errors.go create mode 100644 internal/platformify/questionnaire/questionnaire.go create mode 100644 internal/platformify/utils/utils.go create mode 100644 internal/platformify/vendorization/vendorization.go diff --git a/commands/auth_test.go b/commands/auth_test.go index a5fa9b705..b5022fca8 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -7,9 +7,10 @@ import ( "strings" "testing" - "github.com/platformsh/platformify/vendorization" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/upsun/cli/internal/platformify/vendorization" ) func TestAuthCommands_Disabled(t *testing.T) { diff --git a/commands/completion_test.go b/commands/completion_test.go index 1f4d73beb..6cc1b96bc 100644 --- a/commands/completion_test.go +++ b/commands/completion_test.go @@ -7,11 +7,12 @@ import ( "strings" "testing" - "github.com/platformsh/platformify/vendorization" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/upsun/cli/internal/platformify/vendorization" ) // TestCompletionScriptShellOption checks that the generated completion scripts diff --git a/commands/init.go b/commands/init.go index 1b5e20242..2fe0c8861 100644 --- a/commands/init.go +++ b/commands/init.go @@ -14,14 +14,15 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" "github.com/fatih/color" - "github.com/platformsh/platformify/commands" - "github.com/platformsh/platformify/vendorization" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/upsun/whatsun/pkg/digest" "github.com/upsun/whatsun/pkg/files" "gopkg.in/yaml.v3" + "github.com/upsun/cli/internal/platformify" + "github.com/upsun/cli/internal/platformify/vendorization" + "github.com/upsun/cli/internal/legacy" "github.com/upsun/cli/internal/api" @@ -105,7 +106,7 @@ func runInitCommand( } runNonAIConfig := func() error { - return commands.Platformify(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), assets) + return platformify.Platformify(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), assets) } cnf := config.FromContext(cmd.Context()) diff --git a/commands/lint_test.go b/commands/lint_test.go index ed8569411..8a9171cd6 100644 --- a/commands/lint_test.go +++ b/commands/lint_test.go @@ -10,11 +10,12 @@ import ( "testing" "github.com/fatih/color" - "github.com/platformsh/platformify/vendorization" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/upsun/cli/internal/platformify/vendorization" + "github.com/upsun/cli/internal/config" "github.com/upsun/cli/pkg/lint" ) diff --git a/commands/root.go b/commands/root.go index 944ab40bb..729d7f8a3 100644 --- a/commands/root.go +++ b/commands/root.go @@ -14,10 +14,11 @@ import ( "time" "github.com/fatih/color" - "github.com/platformsh/platformify/vendorization" "github.com/spf13/cobra" "github.com/spf13/viper" + "github.com/upsun/cli/internal/platformify/vendorization" + "github.com/upsun/cli/internal" "github.com/upsun/cli/internal/config" "github.com/upsun/cli/internal/config/alt" diff --git a/go.mod b/go.mod index 4b7585845..0d3763b32 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.27.1 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/Masterminds/semver/v3 v3.5.0 + github.com/Masterminds/sprig/v3 v3.3.0 github.com/alecthomas/chroma/v2 v2.27.0 github.com/briandowns/spinner v1.23.2 github.com/charmbracelet/x/ansi v0.11.9 @@ -14,9 +15,11 @@ require ( github.com/go-playground/validator/v10 v10.30.5 github.com/godbus/dbus/v5 v5.2.3 github.com/gofrs/flock v0.13.1 + github.com/golang/mock v1.6.0 + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/oklog/ulid/v2 v2.1.2 + github.com/pelletier/go-toml/v2 v2.3.0 github.com/pkg/sftp v1.13.11 - github.com/platformsh/platformify v0.5.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 @@ -42,7 +45,6 @@ require ( github.com/BurntSushi/toml v1.6.0 // indirect github.com/IGLOU-EU/go-wildcard/v2 v2.1.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/STARRY-S/zip v0.2.3 // indirect @@ -95,7 +97,6 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-runewidth v0.0.27 // indirect - github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mholt/archives v0.1.5 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/minlz v1.1.1 // indirect @@ -103,7 +104,6 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/nwaples/rardecode/v2 v2.2.2 // indirect - github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pjbgf/sha1cd v0.7.0 // indirect github.com/pkg/errors v0.9.1 // indirect diff --git a/go.sum b/go.sum index 662cf3b6e..9f81c961d 100644 --- a/go.sum +++ b/go.sum @@ -224,8 +224,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0= -github.com/platformsh/platformify v0.5.0 h1:i9cRbM0K1f14Yr0dJ6dhYq9BcmZb8fsx/mpYSnw8ssA= -github.com/platformsh/platformify v0.5.0/go.mod h1:fgmCcfQfHbhe1oXsIdIhpnniyZu8IdIMOlcBAa/ygic= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -302,6 +300,7 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= @@ -315,17 +314,21 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= @@ -333,15 +336,19 @@ golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -361,9 +368,12 @@ golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo= google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw= google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= diff --git a/internal/platformify/colors/colors.go b/internal/platformify/colors/colors.go new file mode 100644 index 000000000..459e6d1d8 --- /dev/null +++ b/internal/platformify/colors/colors.go @@ -0,0 +1,48 @@ +package colors + +import ( + "fmt" + "os" + + "github.com/AlecAivazis/survey/v2/core" + "github.com/mgutz/ansi" +) + +var ( + Brand = "210" + Accent = "33" + Secondary = "247" + Green = "green" + Warning = "yellow" + Error = "red" + Default = "default" + + BrandCode = ansi.ColorCode(Brand) + AccentCode = ansi.ColorCode(Accent) + SecondaryCode = ansi.ColorCode(Secondary) + GreenCode = ansi.ColorCode(Green) + WarningCode = ansi.ColorCode(Warning) + ErrorCode = ansi.ColorCode(Error) + DefaultCode = ansi.ColorCode(Default) + resetCode = ansi.ColorCode("reset") +) + +func init() { + // If NO_COLOR environment variable is set and is not 0, reset colors to "" + if os.Getenv("NO_COLOR") != "" && os.Getenv("NO_COLOR") != "0" { + BrandCode = "" + AccentCode = "" + SecondaryCode = "" + GreenCode = "" + WarningCode = "" + ErrorCode = "" + DefaultCode = "" + resetCode = "" + core.DisableColor = true + } +} + +// Colorize the given string with the given color code +func Colorize(code, str string) string { + return fmt.Sprintf("%s%s%s", code, str, resetCode) +} diff --git a/internal/platformify/colors/context.go b/internal/platformify/colors/context.go new file mode 100644 index 000000000..01fea1eef --- /dev/null +++ b/internal/platformify/colors/context.go @@ -0,0 +1,33 @@ +package colors + +import ( + "context" + "io" +) + +// key is an unexported type for keys defined in this package. +// This prevents collisions with keys defined in other packages. +type key int + +const ( + outKey key = iota + errKey +) + +// ToContext returns a new Context that carries answers value. +func ToContext(ctx context.Context, out, err io.Writer) context.Context { + return context.WithValue(context.WithValue(ctx, outKey, out), errKey, err) +} + +// FromContext returns the out and err writers stored in ctx, if any. +func FromContext(ctx context.Context) (out, err io.Writer, ok bool) { + out, ok = ctx.Value(outKey).(io.Writer) + if !ok { + return nil, nil, false + } + err, ok = ctx.Value(errKey).(io.Writer) + if !ok { + return nil, nil, false + } + return out, err, true +} diff --git a/internal/platformify/init_colors.go b/internal/platformify/init_colors.go new file mode 100644 index 000000000..01dc41bb9 --- /dev/null +++ b/internal/platformify/init_colors.go @@ -0,0 +1,60 @@ +package platformify + +import ( + "fmt" + + "github.com/AlecAivazis/survey/v2" + + "github.com/upsun/cli/internal/platformify/colors" +) + +func init() { + survey.InputQuestionTemplate = fmt.Sprintf(` +{{- color "%[1]s"}}{{ .Message }} {{color "reset"}} +{{- if .ShowAnswer}} + {{- color "%[2]s"}}[{{.Answer}}]{{color "reset"}}{{"\n"}} +{{ else }} + {{- if .Default }}{{color "%[3]s"}}[{{.Default}}] {{color "reset"}}{{ end }} +{{- end }}`, colors.Accent, colors.Brand, colors.Secondary, colors.Error, colors.Default) + + //nolint:lll + survey.SelectQuestionTemplate = fmt.Sprintf(` +{{- define "option"}} + {{- if eq .SelectedIndex .CurrentIndex }}{{color "%[2]s" }}{{ .Config.Icons.SelectFocus.Text }} {{else}}{{color "%[5]s"}} {{end}} + {{- .CurrentOpt.Value}} + {{- color "reset"}} +{{end}} +{{- color "%[1]s"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}} +{{- if .ShowAnswer}}{{color "%[2]s"}} [{{.Answer}}]{{color "reset"}}{{"\n"}}{{"\n"}} +{{- else}} +{{ color "%[5]s"}}Use arrows to move up and down, type to filter{{color "reset"}}{{"\n"}} + {{- range $ix, $option := .PageEntries}} + {{- template "option" $.IterateOption $ix $option}} + {{- end}} +{{- end }}`, colors.Accent, colors.Brand, colors.Secondary, colors.Error, colors.Default) + + survey.ConfirmQuestionTemplate = fmt.Sprintf(` +{{- color "%[1]s"}}{{ .Message }} {{color "reset"}} +{{- if .Answer}} + {{- color "%[2]s"}}[{{.Answer}}]{{color "reset"}}{{"\n"}} +{{- else }} + {{- color "%[3]s"}}{{if .Default}}(Y/n){{else}}(y/N){{end}}{{color "reset"}}{{"\n"}} +{{- end }}`, colors.Accent, colors.Brand, colors.Secondary, colors.Error, colors.Default) + + //nolint:lll + survey.MultiSelectQuestionTemplate = fmt.Sprintf(` +{{- define "option"}} + {{- if eq .SelectedIndex .CurrentIndex }}{{color "%[5]s" }}{{ .Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}} + {{- if index .Checked .CurrentOpt.Index }}{{color "%[2]s" }} {{ .Config.Icons.MarkedOption.Text }} {{else}}{{color "%[5]s" }} {{ .Config.Icons.UnmarkedOption.Text }} {{end}} + {{- color "reset"}} + {{- " "}}{{- if index .Checked .CurrentOpt.Index }}{{color "%[2]s" }}{{else}}{{color "%[5]s" }}{{end}}{{- .CurrentOpt.Value}}{{color "reset"}} +{{end}} +{{- color "%[1]s"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}} +{{- if .ShowAnswer}}{{color "%[2]s"}} [{{.Answer}}]{{color "reset"}}{{"\n"}}{{"\n"}} +{{- else }} +{{ color "%[5]s"}}Use arrows to move, space to select, type to filter{{color "reset"}}{{"\n"}} + {{- range $ix, $option := .PageEntries}} + {{- template "option" $.IterateOption $ix $option}} + {{- end}} +{{- end }}`, colors.Accent, colors.Brand, colors.Secondary, colors.Error, colors.Default) +} diff --git a/internal/platformify/platformifier/django.go b/internal/platformify/platformifier/django.go new file mode 100644 index 000000000..3c43b852a --- /dev/null +++ b/internal/platformify/platformifier/django.go @@ -0,0 +1,93 @@ +package platformifier + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "text/template" + + "github.com/Masterminds/sprig/v3" + + "github.com/upsun/cli/internal/platformify/colors" + "github.com/upsun/cli/internal/platformify/utils" + "github.com/upsun/cli/internal/platformify/vendorization" +) + +const ( + settingsPyFile = "settings.py" + settingsPshPyFile = "settings_psh.py" + importSettingsPshLine = "from .settings_psh import *" +) + +func newDjangoPlatformifier(templates fs.FS, fileSystem FS) *djangoPlatformifier { + return &djangoPlatformifier{ + templates: templates, + fileSystem: fileSystem, + } +} + +type djangoPlatformifier struct { + templates fs.FS + fileSystem FS +} + +func (p *djangoPlatformifier) Platformify(ctx context.Context, input *UserInput) error { + appRoot := filepath.Join(input.Root, input.ApplicationRoot) + if settingsPath := p.fileSystem.Find(appRoot, settingsPyFile, true); len(settingsPath) > 0 { + pshSettingsPath := filepath.Join(filepath.Dir(settingsPath[0]), settingsPshPyFile) + tpl, parseErr := template.New(settingsPshPyFile).Funcs(sprig.FuncMap()). + ParseFS(p.templates, settingsPshPyFile) + if parseErr != nil { + return fmt.Errorf("could not parse template: %w", parseErr) + } + pshSettingsFile, err := p.fileSystem.Create(pshSettingsPath) + if err != nil { + return err + } + defer pshSettingsFile.Close() + + assets, _ := vendorization.FromContext(ctx) + err = tpl.Execute(pshSettingsFile, templateData{input, assets}) + if err != nil { + return err + } + + // append from .settings_psh import * to the bottom of settings.py + settingsFile, err := p.fileSystem.Open(settingsPath[0], os.O_APPEND|os.O_RDWR, 0o644) + if err != nil { + return nil + } + defer settingsFile.Close() + + // Check if there is an import line in the file + found, err := utils.ContainsStringInFile(settingsFile, importSettingsPshLine, false) + if err != nil { + return err + } + + if !found { + if _, err = settingsFile.Write([]byte("\n\n" + importSettingsPshLine + "\n")); err != nil { + out, _, ok := colors.FromContext(ctx) + if !ok { + return nil + } + + fmt.Fprintf( + out, + colors.Colorize( + colors.WarningCode, + "We have created a %s file for you. Please add the following line to your %s file:\n", + ), + settingsPshPyFile, + settingsPyFile, + ) + fmt.Fprint(out, colors.Colorize(colors.WarningCode, " "+importSettingsPshLine+"\n")) + return nil + } + } + } + + return nil +} diff --git a/internal/platformify/platformifier/django_test.go b/internal/platformify/platformifier/django_test.go new file mode 100644 index 000000000..50631f86e --- /dev/null +++ b/internal/platformify/platformifier/django_test.go @@ -0,0 +1,123 @@ +package platformifier + +import ( + "context" + "embed" + "errors" + "io/fs" + "os" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/upsun/cli/internal/platformify/utils" +) + +const ( + djangoTemplatesDir = "templates/django" +) + +var ( + //go:embed templates/django/* + testDjangoTemplatesFS embed.FS +) + +type PlatformifyDjangoSuiteTester struct { + suite.Suite + + cwd string + templates fs.FS + fileSystem *MockFS +} + +func (s *PlatformifyDjangoSuiteTester) SetupTest() { + ctrl := gomock.NewController(s.T()) + + s.fileSystem = NewMockFS(ctrl) + + cwd, err := os.Getwd() + require.NoError(s.T(), err) + s.cwd = cwd + + templates, err := fs.Sub(testDjangoTemplatesFS, djangoTemplatesDir) + require.NoError(s.T(), err) + s.templates = templates +} + +func (s *PlatformifyDjangoSuiteTester) TestSuccessfulFileCreation() { + // GIVEN mock buffers to store settings and PSH settings files + settingsBuff, settingsPSHBuff := &MockBuffer{}, &MockBuffer{} + // AND working directory is a current directory + input := &UserInput{WorkingDirectory: s.cwd} + // AND the settings.py file exists + s.fileSystem.EXPECT(). + Find("", settingsPyFile, true). + Return([]string{settingsPyFile}).Times(1) + s.fileSystem.EXPECT(). + Open(gomock.Eq(settingsPyFile), gomock.Any(), gomock.Any()). + Return(settingsBuff, nil).Times(1) + // AND creation of the PSH settings file returns no errors + s.fileSystem.EXPECT(). + Create(gomock.Eq(settingsPshPyFile)). + Return(settingsPSHBuff, nil).Times(1) + + // WHEN run config files creation + p := newDjangoPlatformifier(s.templates, s.fileSystem) + err := p.Platformify(context.Background(), input) + // THEN it doesn't return any errors + assert.NoError(s.T(), err) + // AND the buffer contains settings file + assert.NotEmpty(s.T(), settingsPSHBuff) + + // WHEN check if settings file contains the line that imported psh settings file + found, err := utils.ContainsStringInFile(settingsBuff, importSettingsPshLine, false) + // THEN it doesn't return any errors + assert.NoError(s.T(), err) + // AND the line is found + assert.True(s.T(), found) +} + +func (s *PlatformifyDjangoSuiteTester) TestSettingsFileNotFound() { + // GIVEN mock buffer to store PSH settings file + buff := &MockBuffer{} + // AND working directory is a current directory + input := &UserInput{WorkingDirectory: s.cwd} + // AND the settings.py file doesn't exist + s.fileSystem.EXPECT(). + Find("", settingsPyFile, true). + Return([]string{}).Times(1) + + // WHEN run config files creation + p := newDjangoPlatformifier(s.templates, s.fileSystem) + err := p.Platformify(context.Background(), input) + // THEN it doesn't return any errors + assert.NoError(s.T(), err) + // AND the buffer is empty + assert.Empty(s.T(), buff) +} + +func (s *PlatformifyDjangoSuiteTester) TestPSHSettingsFileCreationError() { + // GIVEN working directory is a current directory + input := &UserInput{WorkingDirectory: s.cwd} + // AND the settings.py file exists + s.fileSystem.EXPECT(). + Find("", settingsPyFile, true). + Return([]string{settingsPyFile}).Times(1) + // AND creating PSH settings file fails + s.fileSystem.EXPECT(). + Create(gomock.Eq(settingsPshPyFile)). + Return(nil, errors.New("")).Times(1) + + // WHEN run config files creation + p := newDjangoPlatformifier(s.templates, s.fileSystem) + err := p.Platformify(context.Background(), input) + // THEN it fails + assert.Error(s.T(), err) +} + +func TestPlatformifyDjangoSuite(t *testing.T) { + suite.Run(t, new(PlatformifyDjangoSuiteTester)) +} diff --git a/internal/platformify/platformifier/fs.go b/internal/platformify/platformifier/fs.go new file mode 100644 index 000000000..9d6062722 --- /dev/null +++ b/internal/platformify/platformifier/fs.go @@ -0,0 +1,90 @@ +package platformifier + +import ( + "errors" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "slices" + "strings" +) + +var skipDirs = []string{ + "vendor", + "node_modules", + ".next", + ".git", +} + +//go:generate mockgen -destination=fs_mock_test.go -package=platformifier -source=fs.go +type FS interface { + Create(name string) (io.WriteCloser, error) + Find(root, name string, firstMatch bool) []string + Open(name string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) +} + +func NewOSFileSystem(root string) *OSFileSystem { + return &OSFileSystem{ + root: root, + } +} + +type OSFileSystem struct { + root string +} + +func (f *OSFileSystem) Open(name string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) { + return os.OpenFile(f.fullPath(name), flag, perm) +} + +func (f *OSFileSystem) Create(name string) (io.WriteCloser, error) { + filePath := f.fullPath(name) + if err := os.MkdirAll(path.Dir(filePath), os.ModeDir|os.ModePerm); err != nil { + return nil, err + } + + return os.Create(filePath) +} + +// Find searches for the file inside the path recursively and returns all matches +func (f *OSFileSystem) Find(root, name string, firstMatch bool) []string { + root = strings.TrimPrefix(root, "/") + if root == "" { + root = "." + } + found := make([]string, 0) + _ = fs.WalkDir(f.readonly(), root, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + // Skip vendor directories + if slices.Contains(skipDirs, d.Name()) { + return filepath.SkipDir + } + return nil + } + + if d.Name() == name { + found = append(found, p) + if firstMatch { + return errors.New("found") + } + } + + return nil + }) + + return found +} + +func (f *OSFileSystem) readonly() fs.FS { + return os.DirFS(f.root) +} + +func (f *OSFileSystem) fullPath(name string) string { + return filepath.Join(f.root, name) +} diff --git a/internal/platformify/platformifier/fs_mock_test.go b/internal/platformify/platformifier/fs_mock_test.go new file mode 100644 index 000000000..af7f785eb --- /dev/null +++ b/internal/platformify/platformifier/fs_mock_test.go @@ -0,0 +1,80 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: fs.go + +// Package platformifier is a generated GoMock package. +package platformifier + +import ( + io "io" + os "os" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockFS is a mock of FS interface. +type MockFS struct { + ctrl *gomock.Controller + recorder *MockFSMockRecorder +} + +// MockFSMockRecorder is the mock recorder for MockFS. +type MockFSMockRecorder struct { + mock *MockFS +} + +// NewMockFS creates a new mock instance. +func NewMockFS(ctrl *gomock.Controller) *MockFS { + mock := &MockFS{ctrl: ctrl} + mock.recorder = &MockFSMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFS) EXPECT() *MockFSMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockFS) Create(name string) (io.WriteCloser, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", name) + ret0, _ := ret[0].(io.WriteCloser) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockFSMockRecorder) Create(name interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockFS)(nil).Create), name) +} + +// Find mocks base method. +func (m *MockFS) Find(root, name string, firstMatch bool) []string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Find", root, name, firstMatch) + ret0, _ := ret[0].([]string) + return ret0 +} + +// Find indicates an expected call of Find. +func (mr *MockFSMockRecorder) Find(root, name, firstMatch interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockFS)(nil).Find), root, name, firstMatch) +} + +// Open mocks base method. +func (m *MockFS) Open(name string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Open", name, flag, perm) + ret0, _ := ret[0].(io.ReadWriteCloser) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Open indicates an expected call of Open. +func (mr *MockFSMockRecorder) Open(name, flag, perm interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Open", reflect.TypeOf((*MockFS)(nil).Open), name, flag, perm) +} diff --git a/internal/platformify/platformifier/generic.go b/internal/platformify/platformifier/generic.go new file mode 100644 index 000000000..152ee9351 --- /dev/null +++ b/internal/platformify/platformifier/generic.go @@ -0,0 +1,65 @@ +package platformifier + +import ( + "bytes" + "context" + "fmt" + "io" + "io/fs" + "strings" + "text/template" + + "github.com/upsun/cli/internal/platformify/vendorization" + + "github.com/Masterminds/sprig/v3" +) + +func newGenericPlatformifier(templates fs.FS, fileSystem FS) *genericPlatformifier { + return &genericPlatformifier{ + templates: templates, + fileSystem: fileSystem, + } +} + +// genericPlatformifier contains the configuration for the application to Platformify +type genericPlatformifier struct { + templates fs.FS + fileSystem FS +} + +// Platformify will generate the needed configuration files in the current directory. +func (p *genericPlatformifier) Platformify(ctx context.Context, input *UserInput) error { + assets, _ := vendorization.FromContext(ctx) + err := fs.WalkDir(p.templates, ".", func(name string, d fs.DirEntry, _ error) error { + if d.IsDir() { + return nil + } + tpl := template.Must(template.New(d.Name()).Funcs(sprig.FuncMap()).ParseFS(p.templates, name)) + contents := &bytes.Buffer{} + if err := tpl.Execute(contents, templateData{input, assets}); err != nil { + return err + } + + // Skip empty files + if strings.TrimSpace(contents.String()) == "" { + return nil + } + + f, writeErr := p.fileSystem.Create(name) + if writeErr != nil { + return fmt.Errorf("could not write template: %w", writeErr) + } + defer f.Close() + + if _, err := io.Copy(f, contents); err != nil { + return err + } + + return nil + }) + if err != nil { + return err + } + + return nil +} diff --git a/internal/platformify/platformifier/generic_test.go b/internal/platformify/platformifier/generic_test.go new file mode 100644 index 000000000..86db6f5d5 --- /dev/null +++ b/internal/platformify/platformifier/generic_test.go @@ -0,0 +1,173 @@ +package platformifier + +import ( + "bytes" + "context" + "embed" + "errors" + "io/fs" + "os" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +const ( + genericTemplatesDir = "templates/generic" + environmentFile = ".environment" + appConfigFile = ".platform.app.yaml" + routesConfigFile = ".platform/routes.yaml" + servicesConfigFile = ".platform/services.yaml" +) + +var ( + //go:embed templates/generic/* + testGenericTemplatesFS embed.FS +) + +type MockBuffer struct { + bytes.Buffer +} + +func (b *MockBuffer) Close() error { + return nil +} + +type PlatformifyGenericSuiteTester struct { + suite.Suite + + cwd string + templates fs.FS + fileSystem *MockFS +} + +func (s *PlatformifyGenericSuiteTester) SetupTest() { + ctrl := gomock.NewController(s.T()) + + cwd, err := os.Getwd() + require.NoError(s.T(), err) + s.cwd = cwd + + templates, err := fs.Sub(testGenericTemplatesFS, genericTemplatesDir) + require.NoError(s.T(), err) + s.templates = templates + + s.fileSystem = NewMockFS(ctrl) +} + +func (s *PlatformifyGenericSuiteTester) TestSuccessfulConfigsCreation() { + // GIVEN mock buffers to store config files + envBuff, appBuff, routesBuff, servicesBuff := &MockBuffer{}, &MockBuffer{}, &MockBuffer{}, &MockBuffer{} + // AND working directory is a current directory + input := &UserInput{WorkingDirectory: s.cwd} + // AND creation of the environment file returns no errors + s.fileSystem.EXPECT(). + Create(gomock.Eq(environmentFile)). + Return(envBuff, nil).Times(1) + // AND creation of the app config file returns no errors + s.fileSystem.EXPECT(). + Create(gomock.Eq(appConfigFile)). + Return(appBuff, nil).Times(1) + // AND creation of the routes config file returns no errors + s.fileSystem.EXPECT(). + Create(gomock.Eq(routesConfigFile)). + Return(routesBuff, nil).Times(1) + // AND creation of the services config file returns no errors + s.fileSystem.EXPECT(). + Create(gomock.Eq(servicesConfigFile)). + Return(servicesBuff, nil).Times(1) + + // WHEN run config files creation + p := newGenericPlatformifier(s.templates, s.fileSystem) + err := p.Platformify(context.Background(), input) + // THEN it doesn't return any errors + assert.NoError(s.T(), err) + // AND the buffers contain configs + assert.NotEmpty(s.T(), envBuff) + assert.NotEmpty(s.T(), appBuff) + assert.NotEmpty(s.T(), routesBuff) + assert.NotEmpty(s.T(), servicesBuff) +} + +func (s *PlatformifyGenericSuiteTester) TestEnvironmentCreationError() { + // GIVEN working directory is a current directory + input := &UserInput{WorkingDirectory: s.cwd} + // AND creating environment file fails + s.fileSystem.EXPECT(). + Create(gomock.Eq(environmentFile)). + Return(nil, errors.New("")).Times(1) + // AND creating other config files work fine + s.fileSystem.EXPECT(). + Create(gomock.Any()). + Return(&MockBuffer{}, nil).AnyTimes() + + // WHEN run config files creation + p := newGenericPlatformifier(s.templates, s.fileSystem) + err := p.Platformify(context.Background(), input) + // THEN it fails + assert.Error(s.T(), err) +} + +func (s *PlatformifyGenericSuiteTester) TestAppConfigCreationError() { + // GIVEN working directory is a current directory + input := &UserInput{WorkingDirectory: s.cwd} + // AND creating app config file fails + s.fileSystem.EXPECT(). + Create(gomock.Eq(appConfigFile)). + Return(nil, errors.New("")).Times(1) + // AND creating other config files work fine + s.fileSystem.EXPECT(). + Create(gomock.Any()). + Return(&MockBuffer{}, nil).AnyTimes() + + // WHEN run config files creation + p := newGenericPlatformifier(s.templates, s.fileSystem) + err := p.Platformify(context.Background(), input) + // THEN it fails + assert.Error(s.T(), err) +} + +func (s *PlatformifyGenericSuiteTester) TestRoutesConfigCreationError() { + // GIVEN working directory is a current directory + input := &UserInput{WorkingDirectory: s.cwd} + // AND creating routes config file fails + s.fileSystem.EXPECT(). + Create(gomock.Eq(routesConfigFile)). + Return(nil, errors.New("")).Times(1) + // AND creating other config files work fine + s.fileSystem.EXPECT(). + Create(gomock.Any()). + Return(&MockBuffer{}, nil).AnyTimes() + + // WHEN run config files creation + p := newGenericPlatformifier(s.templates, s.fileSystem) + err := p.Platformify(context.Background(), input) + // THEN it fails + assert.Error(s.T(), err) +} + +func (s *PlatformifyGenericSuiteTester) TestServicesConfigCreationError() { + // GIVEN working directory is a current directory + input := &UserInput{WorkingDirectory: s.cwd} + // AND creating services config file fails + s.fileSystem.EXPECT(). + Create(gomock.Eq(servicesConfigFile)). + Return(nil, errors.New("")).Times(1) + // AND creating other config files work fine + s.fileSystem.EXPECT(). + Create(gomock.Any()). + Return(&MockBuffer{}, nil).AnyTimes() + + // WHEN run config files creation + p := newGenericPlatformifier(s.templates, s.fileSystem) + err := p.Platformify(context.Background(), input) + // THEN it fails + assert.Error(s.T(), err) +} + +func TestPlatformifyGenericSuite(t *testing.T) { + suite.Run(t, new(PlatformifyGenericSuiteTester)) +} diff --git a/internal/platformify/platformifier/laravel.go b/internal/platformify/platformifier/laravel.go new file mode 100644 index 000000000..878e8d4f8 --- /dev/null +++ b/internal/platformify/platformifier/laravel.go @@ -0,0 +1,48 @@ +package platformifier + +import ( + "context" + "fmt" + "io/fs" + "path/filepath" + + "github.com/upsun/cli/internal/platformify/colors" + "github.com/upsun/cli/internal/platformify/utils" +) + +const ( + composerJSONFile = "composer.json" +) + +func newLaravelPlatformifier(templates fs.FS, fileSystem FS) *laravelPlatformifier { + return &laravelPlatformifier{ + templates: templates, + fileSystem: fileSystem, + } +} + +type laravelPlatformifier struct { + templates fs.FS + fileSystem FS +} + +func (p *laravelPlatformifier) Platformify(ctx context.Context, input *UserInput) error { + // Check for the Laravel Bridge. + appRoot := filepath.Join(input.Root, input.ApplicationRoot) + composerJSONPaths := p.fileSystem.Find(appRoot, composerJSONFile, false) + for _, composerJSONPath := range composerJSONPaths { + _, required := utils.GetJSONValue([]string{"require", "platformsh/laravel-bridge"}, composerJSONPath, true) + if !required { + out, _, ok := colors.FromContext(ctx) + if !ok { + return fmt.Errorf("output context failed") + } + + var suggest = "\nPlease use composer to add the Laravel Bridge to your project:\n" + var composerRequire = "\n composer require platformsh/laravel-bridge\n" + fmt.Fprintln(out, colors.Colorize(colors.WarningCode, suggest+composerRequire)) + } + } + + return nil +} diff --git a/internal/platformify/platformifier/models.go b/internal/platformify/platformifier/models.go new file mode 100644 index 000000000..681626124 --- /dev/null +++ b/internal/platformify/platformifier/models.go @@ -0,0 +1,130 @@ +package platformifier + +import ( + "strings" +) + +var ( + databases = []string{ + "mariadb", + "mysql", + "oracle-mysql", + "postgresql", + } + caches = []string{ + "redis", + "redis-persistent", + "memcached", + } +) + +const ( + Generic Stack = iota + Django + Laravel + NextJS + Strapi + Flask + Express + Rails +) + +type Stack int + +func (s Stack) Name() string { + switch s { + case Generic: + return "generic" + case Django: + return "django" + case Rails: + return "rails" + case Laravel: + return "laravel" + case NextJS: + return "nextjs" + case Strapi: + return "strapi" + case Flask: + return "flask" + case Express: + return "express" + default: + return "" + } +} + +type Relationship struct { + Service string + Endpoint string +} + +// UserInput contains the configuration from user input. +type UserInput struct { + Stack Stack + Root string + ApplicationRoot string + Name string + Type string + Runtime string + Environment map[string]string + BuildSteps []string + WebCommand string + SocketFamily string + DeployCommand []string + DependencyManagers []string + Locations map[string]map[string]interface{} + Dependencies map[string]map[string]string + BuildFlavor string + Disk string + Mounts map[string]map[string]string + Services []Service + Relationships map[string]Relationship + WorkingDirectory string + HasGit bool +} + +// Service contains the configuration for a service needed by the application. +type Service struct { + Name string + Type string + TypeVersions []string + Disk string + DiskSizes []string +} + +// Database returns the first service that is a database. +func (ui *UserInput) Database() string { + for _, service := range ui.Services { + for _, db := range databases { + if strings.Contains(service.Type, db) { + return service.Name + } + } + } + + return "" +} + +// DatabaseUpper returns the uppercase slug for the first service that is a database. +func (ui *UserInput) DatabaseUpper() string { + return strings.ToUpper(strings.ReplaceAll(ui.Database(), "-", "_")) +} + +// Cache returns the first service that is a cache. +func (ui *UserInput) Cache() string { + for _, service := range ui.Services { + for _, cache := range caches { + if strings.Contains(service.Type, cache) { + return service.Name + } + } + } + + return "" +} + +// CacheUpper returns the uppercase slug for the first service that is a cache. +func (ui *UserInput) CacheUpper() string { + return strings.ToUpper(strings.ReplaceAll(ui.Cache(), "-", "_")) +} diff --git a/internal/platformify/platformifier/nextjs.go b/internal/platformify/platformifier/nextjs.go new file mode 100644 index 000000000..5fcc37525 --- /dev/null +++ b/internal/platformify/platformifier/nextjs.go @@ -0,0 +1,20 @@ +package platformifier + +import ( + "context" + "io/fs" +) + +func newNextJSPlatformifier(templates fs.FS) *nextJSPlatformifier { + return &nextJSPlatformifier{ + templates: templates, + } +} + +type nextJSPlatformifier struct { + templates fs.FS +} + +func (p *nextJSPlatformifier) Platformify(_ context.Context, _ *UserInput) error { + return nil +} diff --git a/internal/platformify/platformifier/platformifier.go b/internal/platformify/platformifier/platformifier.go new file mode 100644 index 000000000..c8e715cf0 --- /dev/null +++ b/internal/platformify/platformifier/platformifier.go @@ -0,0 +1,97 @@ +package platformifier + +import ( + "context" + "embed" + "io/fs" + + "github.com/upsun/cli/internal/platformify/vendorization" +) + +var ( + //go:embed templates/**/* + templatesFS embed.FS +) + +const ( + // Path names passed to open are UTF-8-encoded, + // unrooted, slash-separated sequences of path elements, like "x/y/z". + // Path names MUST NOT contain an element that is "." or ".." or the empty string, + // Paths MUST NOT start or end with a slash: "/x" and "x/" are invalid. + genericDir = "templates/generic" + upsunDir = "templates/upsun" + djangoDir = "templates/django" + laravelDir = "templates/laravel" + nextjsDir = "templates/nextjs" +) + +// A platformifier handles the business logic of a given runtime to platformify. +// +//go:generate mockgen -destination=platformifier_mock_test.go -package=platformifier -source=platformifier.go +type platformifier interface { + // Platformify loads and writes the templates to the user's system. + Platformify(ctx context.Context, input *UserInput) error +} + +type templateData struct { + *UserInput + Assets *vendorization.VendorAssets +} + +// New creates Platformifier with the appropriate platformifier stack based on UserInput. +func New(input *UserInput, flavor string, fileSystems ...FS) *Platformifier { + var fileSystem FS + if len(fileSystems) > 0 { + fileSystem = fileSystems[0] + } else { + fileSystem = NewOSFileSystem(input.WorkingDirectory) + } + + // fs.Sub(...) returns an error only if the given path name is invalid. + // Since we determine the path name ourselves in advance, + // there is no need to check for errors in this path name. + stacks := []platformifier{} + templatesDir := genericDir + if flavor == "upsun" { + templatesDir = upsunDir + } + + templates, _ := fs.Sub(templatesFS, templatesDir) + stacks = append(stacks, newGenericPlatformifier(templates, fileSystem)) + + switch input.Stack { + case Django: + // No need to check for errors (see the comment above) + templates, _ := fs.Sub(templatesFS, djangoDir) + stacks = append(stacks, newDjangoPlatformifier(templates, fileSystem)) + case Laravel: + // No need to check for errors (see the comment above) + templates, _ := fs.Sub(templatesFS, laravelDir) + stacks = append(stacks, newLaravelPlatformifier(templates, fileSystem)) + case NextJS: + // No need to check for errors (see the comment above) + templates, _ := fs.Sub(templatesFS, nextjsDir) + stacks = append(stacks, newNextJSPlatformifier(templates)) + } + + return &Platformifier{ + input: input, + stacks: stacks, + } +} + +// A Platformifier handles the business logic of a given runtime to platformify. +type Platformifier struct { + input *UserInput + stacks []platformifier +} + +func (p *Platformifier) Platformify(ctx context.Context) error { + for _, stack := range p.stacks { + err := stack.Platformify(ctx, p.input) + if err != nil { + return err + } + } + return nil +} diff --git a/internal/platformify/platformifier/platformifier_mock_test.go b/internal/platformify/platformifier/platformifier_mock_test.go new file mode 100644 index 000000000..82a4a678f --- /dev/null +++ b/internal/platformify/platformifier/platformifier_mock_test.go @@ -0,0 +1,49 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: platformifier.go + +// Package platformifier is a generated GoMock package. +package platformifier + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// Mockplatformifier is a mock of platformifier interface. +type Mockplatformifier struct { + ctrl *gomock.Controller + recorder *MockplatformifierMockRecorder +} + +// MockplatformifierMockRecorder is the mock recorder for Mockplatformifier. +type MockplatformifierMockRecorder struct { + mock *Mockplatformifier +} + +// NewMockplatformifier creates a new mock instance. +func NewMockplatformifier(ctrl *gomock.Controller) *Mockplatformifier { + mock := &Mockplatformifier{ctrl: ctrl} + mock.recorder = &MockplatformifierMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *Mockplatformifier) EXPECT() *MockplatformifierMockRecorder { + return m.recorder +} + +// Platformify mocks base method. +func (m *Mockplatformifier) Platformify(ctx context.Context, input *UserInput) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Platformify", ctx, input) + ret0, _ := ret[0].(error) + return ret0 +} + +// Platformify indicates an expected call of Platformify. +func (mr *MockplatformifierMockRecorder) Platformify(ctx, input interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Platformify", reflect.TypeOf((*Mockplatformifier)(nil).Platformify), ctx, input) +} diff --git a/internal/platformify/platformifier/platformifier_test.go b/internal/platformify/platformifier/platformifier_test.go new file mode 100644 index 000000000..5f8dfbf43 --- /dev/null +++ b/internal/platformify/platformifier/platformifier_test.go @@ -0,0 +1,595 @@ +package platformifier + +import ( + "context" + "errors" + "io/fs" + "os" + "reflect" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/upsun/cli/pkg/lint" +) + +var ( + djangoStack = &UserInput{ + Name: "django", + Type: "python:3.14", + Stack: Django, + Runtime: "python", + ApplicationRoot: "app", + Environment: map[string]string{ + "DJANGO_SETTINGS_MODULE": "app.settings", + "PYTHONUNBUFFERED": "1", + }, + Root: "app", + BuildFlavor: "none", + BuildSteps: []string{ + "pip install -r requirements.txt", + "# comment here", + "python manage.py collectstatic --noinput", + }, + WebCommand: "gunicorn app.wsgi", + SocketFamily: "unix", + DeployCommand: []string{ + "python manage.py migrate", + "# comment here", + }, + DependencyManagers: []string{"pip", "yarn"}, + Locations: map[string]map[string]interface{}{ + "/": { + "passthru": true, + }, + "/static": { + "root": "static", + "expires": "1h", + "allow": true, + }, + }, + Dependencies: map[string]map[string]string{ + "python": { + "poetry": "*", + "pip": ">=20.0.0", + }, + "nodejs": { + "yarn": "*", + "npm": ">=6.0.0", + }, + }, + Disk: "1024", + Mounts: map[string]map[string]string{ + "/.npm": { + "source": "local", + "source_path": "npm", + }, + "/.pip": { + "source": "local", + "source_path": "pip", + }, + }, + Relationships: map[string]Relationship{ + "db": {Service: "db", Endpoint: "postgresql"}, + "mysql": {Service: "mysql", Endpoint: "mysql"}, + }, + HasGit: true, + Services: []Service{ + { + Name: "db", + Type: "postgresql:17", + TypeVersions: []string{"17", "16", "15", "14", "13", "12"}, + Disk: "1024", + DiskSizes: []string{"1024", "2048"}, + }, + { + Name: "mysql", + Type: "mysql:11.8", + TypeVersions: []string{"11.0", "10.11", "10.6", "10.5", "10.4", "10.3"}, + Disk: "1024", + DiskSizes: []string{"1024", "2034"}, + }, + }, + } + genericStack = &UserInput{ + Name: "generic", + Type: "java:21", + Runtime: "java", + Stack: Generic, + Environment: map[string]string{ + "JAVA": "19", + }, + Root: "app", + BuildFlavor: "", + BuildSteps: []string{ + "mvn install", + }, + WebCommand: "tomcat", + SocketFamily: "tcp", + DeployCommand: []string{}, + DependencyManagers: []string{"mvn"}, + Locations: map[string]map[string]interface{}{ + "/": { + "passthru": true, + }, + }, + Dependencies: map[string]map[string]string{}, + Disk: "1024", + Mounts: map[string]map[string]string{ + "/.mvn": { + "source": "local", + "source_path": "maven", + }, + }, + Relationships: map[string]Relationship{ + "mysql": {Service: "mysql", Endpoint: "mysql"}, + }, + HasGit: true, + Services: []Service{ + { + Name: "mysql", + Type: "mysql:11.8", + TypeVersions: []string{"13", "14", "15"}, + Disk: "1024", + DiskSizes: []string{"1024", "2048"}, + }, + }, + } + laravelStack = &UserInput{ + Name: "laravel", + Type: "php:8.4", + Stack: Laravel, + Runtime: "php", + ApplicationRoot: "app", + Environment: map[string]string{}, + Root: "app", + BuildFlavor: "php", + BuildSteps: []string{}, + DeployCommand: []string{}, + DependencyManagers: []string{"composer"}, + Locations: map[string]map[string]interface{}{ + "/": { + "root": "index.php", + }, + }, + Dependencies: map[string]map[string]string{}, + Disk: "", + Mounts: map[string]map[string]string{}, + Relationships: map[string]Relationship{}, + HasGit: false, + Services: []Service{}, + } + nextJSStack = &UserInput{ + Name: "nextjs", + Type: "nodejs:24", + Runtime: "nodejs", + Stack: NextJS, + } + strapiStack = &UserInput{ + Name: "strapi", + Type: "nodejs:24", + Runtime: "nodejs", + Stack: Strapi, + } + flaskStack = &UserInput{ + Name: "flask", + Type: "python:3.14", + Runtime: "python", + Stack: Flask, + } + expressStack = &UserInput{ + Name: "express", + Type: "nodejs:24", + Runtime: "nodejs", + Stack: Express, + } +) + +func TestNewPlatformifier(t *testing.T) { + genericTemplates, err := fs.Sub(templatesFS, genericDir) + require.NoError(t, err) + djangoTemplates, err := fs.Sub(templatesFS, djangoDir) + require.NoError(t, err) + laravelTemplates, err := fs.Sub(templatesFS, laravelDir) + require.NoError(t, err) + nextjsTemplates, err := fs.Sub(templatesFS, nextjsDir) + require.NoError(t, err) + fileSystem := NewOSFileSystem("") + tests := []struct { + name string + stack Stack + platformifiers []platformifier + }{ + { + name: "generic", + stack: Generic, + platformifiers: []platformifier{ + &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, + }, + }, + { + name: "django", + stack: Django, + platformifiers: []platformifier{ + &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, + &djangoPlatformifier{templates: djangoTemplates, fileSystem: fileSystem}, + }, + }, + { + name: "laravel", + stack: Laravel, + platformifiers: []platformifier{ + &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, + &laravelPlatformifier{templates: laravelTemplates, fileSystem: fileSystem}, + }, + }, + { + name: "nextjs", + stack: NextJS, + platformifiers: []platformifier{ + &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, + &nextJSPlatformifier{templates: nextjsTemplates}, + }, + }, + { + name: "strapi", + stack: Strapi, + platformifiers: []platformifier{ + &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, + }, + }, + { + name: "flask", + stack: Flask, + platformifiers: []platformifier{ + &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, + }, + }, + { + name: "express", + stack: Express, + platformifiers: []platformifier{ + &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // GIVEN user input with given stack + input := &UserInput{Stack: tt.stack} + + // WHEN create new platformifier + pfier := New(input, "platform") + // THEN user input inside platformifier should be the same as given + assert.Equal(t, input, pfier.input) + // AND length of the platformifier's stack must be equal to the length of expected stacks + require.Len(t, pfier.stacks, len(tt.platformifiers)) + for i := range pfier.stacks { + // AND the type of each stack should be the same as expected + assert.IsType(t, tt.platformifiers[i], pfier.stacks[i]) + assert.True(t, reflect.DeepEqual(tt.platformifiers[i], pfier.stacks[i])) + } + }) + } +} + +type PlatformifySuiteTester struct { + suite.Suite + + generic *Mockplatformifier + django *Mockplatformifier + laravel *Mockplatformifier + nextjs *Mockplatformifier +} + +func (s *PlatformifySuiteTester) SetupTest() { + ctrl := gomock.NewController(s.T()) + + s.generic = NewMockplatformifier(ctrl) + s.django = NewMockplatformifier(ctrl) + s.laravel = NewMockplatformifier(ctrl) + s.nextjs = NewMockplatformifier(ctrl) +} + +func (s *PlatformifySuiteTester) TestSuccessfulPlatformifying() { + // GIVEN empty context + ctx := context.Background() + // AND user input is empty (because it doesn't matter if it's empty or not) + input := &UserInput{} + // AND platformifying generic stack returns no errors + s.generic.EXPECT(). + Platformify(gomock.Eq(ctx), gomock.Eq(input)). + Return(nil).AnyTimes() + // AND platformifying django stack returns no errors + s.django.EXPECT(). + Platformify(gomock.Eq(ctx), gomock.Eq(input)). + Return(nil).AnyTimes() + // AND platformifying laravel stack returns no errors + s.laravel.EXPECT(). + Platformify(gomock.Eq(ctx), gomock.Eq(input)). + Return(nil).AnyTimes() + // AND platformifying nextjs stack returns no errors + s.nextjs.EXPECT(). + Platformify(gomock.Eq(ctx), gomock.Eq(input)). + Return(nil).AnyTimes() + + tests := []struct { + name string + stacks []platformifier + }{ + { + name: "empty", + stacks: []platformifier{}, + }, + { + name: "generic", + stacks: []platformifier{s.generic}, + }, + { + name: "django", + stacks: []platformifier{s.django}, + }, + { + name: "laravel", + stacks: []platformifier{s.laravel}, + }, + { + name: "nextjs", + stacks: []platformifier{s.nextjs}, + }, + { + name: "generic+django", + stacks: []platformifier{s.generic, s.django}, + }, + { + name: "generic+laravel", + stacks: []platformifier{s.generic, s.laravel}, + }, + { + name: "generic+nextjs", + stacks: []platformifier{s.generic, s.nextjs}, + }, + { + name: "all", + stacks: []platformifier{s.generic, s.django, s.laravel, s.nextjs}, + }, + } + + for _, tt := range tests { + s.T().Run(tt.name, func(t *testing.T) { + // WHEN run platformifying of the given stack + p := Platformifier{ + input: input, + stacks: tt.stacks, + } + err := p.Platformify(ctx) + // THEN it doesn't return any errors + assert.NoError(t, err) + }) + } +} + +func (s *PlatformifySuiteTester) TestPlatformifyingError() { + // GIVEN empty context + ctx := context.Background() + // AND user input is empty (because it doesn't matter if it's empty or not) + input := &UserInput{} + // AND platformifying generic stack fails + s.generic.EXPECT(). + Platformify(gomock.Eq(ctx), gomock.Eq(input)). + Return(errors.New("")).AnyTimes() + // AND platformifying django stack fails + s.django.EXPECT(). + Platformify(gomock.Eq(ctx), gomock.Eq(input)). + Return(errors.New("")).AnyTimes() + // AND platformifying laravel stack fails + s.laravel.EXPECT(). + Platformify(gomock.Eq(ctx), gomock.Eq(input)). + Return(errors.New("")).AnyTimes() + // AND platformifying nextjs stack fails + s.nextjs.EXPECT(). + Platformify(gomock.Eq(ctx), gomock.Eq(input)). + Return(errors.New("")).AnyTimes() + + tests := []struct { + name string + stacks []platformifier + }{ + { + name: "generic", + stacks: []platformifier{s.generic}, + }, + { + name: "django", + stacks: []platformifier{s.django}, + }, + { + name: "laravel", + stacks: []platformifier{s.laravel}, + }, + { + name: "nextjs", + stacks: []platformifier{s.nextjs}, + }, + { + name: "generic+django", + stacks: []platformifier{s.generic, s.django}, + }, + { + name: "generic+laravel", + stacks: []platformifier{s.generic, s.laravel}, + }, + { + name: "generic+nextjs", + stacks: []platformifier{s.generic, s.nextjs}, + }, + { + name: "all", + stacks: []platformifier{s.generic, s.django, s.laravel, s.nextjs}, + }, + } + + for _, tt := range tests { + s.T().Run(tt.name, func(t *testing.T) { + // WHEN run platformifying of the given stack + p := Platformifier{ + input: input, + stacks: tt.stacks, + } + err := p.Platformify(ctx) + // THEN it fails + assert.Error(t, err) + }) + } +} + +func TestPlatformifySuite(t *testing.T) { + suite.Run(t, new(PlatformifySuiteTester)) +} + +func TestPlatformifier_Platformify(t *testing.T) { + type fields struct { + ui *UserInput + } + tests := []struct { + name string + fields fields + wantErr bool + }{ + { + name: "Django", + fields: fields{ui: djangoStack}, + }, + { + name: "Generic", + fields: fields{ui: genericStack}, + }, + { + name: "Laravel", + fields: fields{ui: laravelStack}, + }, + { + name: "Next.js", + fields: fields{ui: nextJSStack}, + }, + { + name: "Strapi", + fields: fields{ui: strapiStack}, + }, + { + name: "Flask", + fields: fields{ui: flaskStack}, + }, + { + name: "Express", + fields: fields{ui: expressStack}, + }, + } + + // Create a temporary directory to use as the output directory. + tempDir, err := os.MkdirTemp("", "yaml_tests") + if err != nil { + t.Fatalf("Failed to create temporary directory: %v", err) + } + defer os.RemoveAll(tempDir) + + ctx := context.Background() + for _, tt := range tests { + dir, err := os.MkdirTemp(tempDir, tt.name) + if err != nil { + t.Fatalf("Failed to create temporary %v directory: %v", tt.name, err) + } + tt.fields.ui.WorkingDirectory = dir + t.Run(tt.name, func(t *testing.T) { + if err := New(tt.fields.ui, "platform").Platformify(ctx); (err != nil) != tt.wantErr { + t.Errorf("Platformifier.Platformify() error = %v, wantErr %v", err, tt.wantErr) + } + + // Validate the config. + if err := lintConfig(dir); (err != nil) != tt.wantErr { + t.Errorf("Platformifier.Platformify() validation error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestPlatformifier_Upsunify(t *testing.T) { + type fields struct { + ui *UserInput + } + tests := []struct { + name string + fields fields + wantErr bool + }{ + { + name: "Django", + fields: fields{ui: djangoStack}, + }, + { + name: "Generic", + fields: fields{ui: genericStack}, + }, + { + name: "Laravel", + fields: fields{ui: laravelStack}, + }, + { + name: "Next.js", + fields: fields{ui: nextJSStack}, + }, + { + name: "Strapi", + fields: fields{ui: strapiStack}, + }, + { + name: "Flask", + fields: fields{ui: flaskStack}, + }, + { + name: "Express", + fields: fields{ui: expressStack}, + }, + } + + // Create a temporary directory to use as the output directory. + tempDir, err := os.MkdirTemp("", "yaml_tests") + if err != nil { + t.Fatalf("Failed to create temporary directory: %v", err) + } + defer os.RemoveAll(tempDir) + + ctx := context.Background() + for _, tt := range tests { + dir, err := os.MkdirTemp(tempDir, tt.name) + if err != nil { + t.Fatalf("Failed to create temporary %v directory: %v", tt.name, err) + } + tt.fields.ui.WorkingDirectory = dir + t.Run(tt.name, func(t *testing.T) { + if err := New(tt.fields.ui, "upsun").Platformify(ctx); (err != nil) != tt.wantErr { + t.Errorf("Platformifier.Platformify() error = %v, wantErr %v", err, tt.wantErr) + } + + // Validate the config. + if err := lintConfig(dir); (err != nil) != tt.wantErr { + t.Errorf("Platformifier.Platformify() validation error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// lintConfig checks the configuration in dir with the CLI's linter. +func lintConfig(dir string) error { + result, err := lint.CheckDir(dir) + if err != nil { + return err + } + if result.HasErrors() { + return errors.New(result.String()) + } + return nil +} diff --git a/internal/platformify/platformifier/templates/django/settings_psh.py b/internal/platformify/platformifier/templates/django/settings_psh.py new file mode 100644 index 000000000..f865eff29 --- /dev/null +++ b/internal/platformify/platformifier/templates/django/settings_psh.py @@ -0,0 +1,73 @@ +################################################################################# +# {{ .Assets.ServiceName }}-specific configuration + +import base64 +import json +import os +import sys +from urllib.parse import urlparse + +# This variable should always match the primary database relationship name +{{ .Assets.EnvPrefix }}_DB_RELATIONSHIP = "{{ .Database }}" + +# Helper function for decoding base64-encoded JSON variables. +def decode(variable): + """Decodes a {{ .Assets.ServiceName }} environment variable. + Args: + variable (string): + Base64-encoded JSON (the content of an environment variable). + Returns: + An dict (if representing a JSON object), or a scalar type. + Raises: + JSON decoding error. + """ + try: + return json.loads(base64.b64decode(variable)) + except json.decoder.JSONDecodeError: + print("Error decoding JSON, code %d", json.decoder.JSONDecodeError) + + +# Import some {{ .Assets.ServiceName }} settings from the environment. +# Read more on {{ .Assets.ServiceName }} variables at {{ .Assets.Docs.Variables }} +if os.getenv("{{ .Assets.EnvPrefix }}_APPLICATION_NAME"): + DEBUG = False + + if os.getenv("{{ .Assets.EnvPrefix }}_APP_DIR"): + STATIC_ROOT = os.path.join(os.getenv("{{ .Assets.EnvPrefix }}_APP_DIR"), "static") + + if os.getenv("{{ .Assets.EnvPrefix }}_PROJECT_ENTROPY"): + SECRET_KEY = os.getenv("{{ .Assets.EnvPrefix }}_PROJECT_ENTROPY") + + if os.getenv("{{ .Assets.EnvPrefix }}_ROUTES"): + {{ lower .Assets.EnvPrefix }}_routes = decode(os.getenv("{{ .Assets.EnvPrefix }}_ROUTES")) + ALLOWED_HOSTS = list(map( + lambda key: urlparse(key).hostname, + {{ lower .Assets.EnvPrefix }}_routes.keys(), + )) + + # Database service configuration, post-build only. + if os.getenv("{{ .Assets.EnvPrefix }}_RELATIONSHIPS") and {{ .Assets.EnvPrefix }}_DB_RELATIONSHIP: + {{ lower .Assets.EnvPrefix }}_relationships = decode(os.getenv("{{ .Assets.EnvPrefix }}_RELATIONSHIPS")) + if {{ .Assets.EnvPrefix }}_DB_RELATIONSHIP in {{ lower .Assets.EnvPrefix }}_relationships: + db_settings = {{ lower .Assets.EnvPrefix }}_relationships[{{ .Assets.EnvPrefix }}_DB_RELATIONSHIP][0] + engine = None + if ( + "mariadb" in db_settings["type"] + or "mysql" in db_settings["type"] + or "oracle-mysql" in db_settings["type"] + ): + engine = "django.db.backends.mysql" + elif "postgresql" in db_settings["type"]: + engine = "django.db.backends.postgresql" + + if engine: + DATABASES = { + "default": { + "ENGINE": engine, + "NAME": db_settings["path"], + "USER": db_settings["username"], + "PASSWORD": db_settings["password"], + "HOST": db_settings["host"], + "PORT": db_settings["port"], + }, + } diff --git a/internal/platformify/platformifier/templates/generic/.environment b/internal/platformify/platformifier/templates/generic/.environment new file mode 100644 index 000000000..63a6498ee --- /dev/null +++ b/internal/platformify/platformifier/templates/generic/.environment @@ -0,0 +1,59 @@ +export RELATIONSHIPS_JSON="$(echo ${{ .Assets.EnvPrefix }}_RELATIONSHIPS | base64 --decode)" +{{- if .Database }} + +# Set database environment variables +export DB_HOST="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Database }}[0].host')" +export DB_PORT="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Database }}[0].port')" +export DB_PATH="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Database }}[0].path')" +export DB_DATABASE="$DB_PATH" +export DB_USERNAME="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Database }}[0].username')" +export DB_PASSWORD="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Database }}[0].password')" +{{ if eq .Database "postgresql" }}export DB_SCHEME="postgresql" +{{- else if and (eq .Stack.Name "rails") (or (eq .Database "mariadb") (eq .Database "mysql")) }}export DB_SCHEME="mysql2" # mysql/mariadb rails adapter +{{- else }}export DB_SCHEME="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Database }}[0].scheme')"{{ end }} +{{- end -}} +{{- if eq .Stack.Name "laravel" }} + +# Set Laravel-specific environment variables +export DB_CONNECTION="$DB_SCHEME" +{{- end }} +export DATABASE_URL="${DB_SCHEME}://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_PATH}" +{{- if .Cache }} + +# Set Cache environment variables +export CACHE_HOST="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Cache }}[0].host')" +export CACHE_PORT="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Cache }}[0].port')" +export CACHE_PASSWORD="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Cache }}[0].password')" +export CACHE_SCHEME="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Cache }}[0].scheme')" +export CACHE_URL="${CACHE_SCHEME}://${CACHE_PASSWORD}@${CACHE_HOST}:${CACHE_PORT}" +{{- end -}} +{{- if or (eq .Cache "redis") (eq .Cache "redis_persistent") }} + +# Set Redis environment variables +export REDIS_URL="$CACHE_URL" +{{- end -}} +{{- if eq .Stack.Name "strapi" }} + +# Set Strapi-specific environment variables +export DATABASE_HOST="$DB_HOST" +export DATABASE_PORT="$DB_PORT" +export DATABASE_NAME="$DB_PATH" +export DATABASE_USERNAME="$DB_USERNAME" +export DATABASE_PASSWORD="$DB_PASSWORD" +export DATABASE_SCHEME="$DB_CONNECTION" + +# Set secrets needed by Strapi, if they are not set +# Prefer setting these as project secret variables with {{ .Assets.Binary }} variable:create env:SECRET_NAME --sensitive=true +if [ -z "$ADMIN_JWT_SECRET" ]; then + export ADMIN_JWT_SECRET="${{ .Assets.EnvPrefix }}_PROJECT_ENTROPY" +fi +if [ -z "$JWT_SECRET" ]; then + export JWT_SECRET="${{ .Assets.EnvPrefix }}_PROJECT_ENTROPY" +fi +if [ -z "$API_TOKEN_SALT" ]; then + export API_TOKEN_SALT="${{ .Assets.EnvPrefix }}_PROJECT_ENTROPY" +fi +if [ -z "$APP_KEYS" ]; then + export APP_KEYS="${{ .Assets.EnvPrefix }}_PROJECT_ENTROPY" +fi +{{- end -}} diff --git a/internal/platformify/platformifier/templates/generic/.platform.app.yaml b/internal/platformify/platformifier/templates/generic/.platform.app.yaml new file mode 100644 index 000000000..0c21624a2 --- /dev/null +++ b/internal/platformify/platformifier/templates/generic/.platform.app.yaml @@ -0,0 +1,229 @@ +# Complete list of all available properties: {{ .Assets.Docs.AppReference }} + +# A unique name for the app. Must be lowercase alphanumeric characters. Changing the name destroys data associated +# with the app. +name: "{{ .Name }}" + +# The runtime the application uses. +# Complete list of available runtimes: {{ .Assets.Docs.AppReference }}type/ +type: "{{ .Type }}" + +# How many resources to devote to the app. Defaults to AUTO in production environments. +# More information: {{ .Assets.Docs.AppReference }}sizes/ +# size: + +# The relationships of the application with services or other applications. +# The left-hand side is the name of the relationship as it will be exposed +# to the application in the {{ .Assets.EnvPrefix }}_RELATIONSHIPS variable. The right-hand +# side is in the form `:`. +# More information: {{ .Assets.Docs.AppReference }}relationships/ +{{ if .Relationships -}} +relationships: + {{ range $key, $value := .Relationships }} + {{- $key }}: + {{ end -}} +{{ else }} +# relationships: +# db: +{{ end }} + +# The size of the persistent disk of the application (in MB). Minimum value is 128. +{{ if .Disk -}} +disk: {{ .Disk }} +{{- else -}} +# disk: 512 +{{- end }} + +# Mounts define directories that are writable after the build is complete. If set as a local source, disk property is required. +# More information: {{ .Assets.Docs.AppReference }}mounts/ +{{ if .Mounts -}} +mounts: + {{ range $key, $value := .Mounts -}} + "{{ $key }}": + {{ range $key, $value := $value }} + {{- $key }}: "{{ $value }}" + {{ end }} + {{ end -}} +{{- else -}} +# mounts: +# "/.cache": # Represents the path in the app. +# source: "local" # "local" sources are unique to the app, while "service" sources can be shared among apps. +# source_path: "cache" # The subdirectory within the mounted disk (the source) where the mount should point. +{{- end }} + +# The web key configures the web server running in front of your app. +# More information: {{ .Assets.Docs.AppReference }}web/ +web: + # Commands are run once after deployment to start the application process. + # More information: {{ .Assets.Docs.AppReference }}web/commands/ + {{- if .WebCommand }} + commands: + # The command to launch your app. If it terminates, it’s restarted immediately. + # You can use the $PORT or the $SOCKET environment variable depending on the socket family of your upstream + start: {{ quote .WebCommand }} + {{- else }} + # commands: + # The command to launch your app. If it terminates, it’s restarted immediately. + # You can use the $PORT or the $SOCKET environment variable depending on the socket family of your upstream + {{- if eq "php" .Runtime }} + # PHP applications run PHP-fpm by default + # Read about alternative commands here: {{ .Assets.Docs.PHP }}alternate-start-commands/ + {{- end }} + # start: echo 'Put your start command here' + {{- end }} + # You can listen to a UNIX socket (unix) or a TCP port (tcp, default). + {{- if eq "php" .Runtime }} + # For PHP, the defaults are configured for PHP-FPM and shouldn't need adjustment. + {{- end }} + # Whether your app should speak to the webserver via TCP or Unix socket. Defaults to tcp + # More information: {{ .Assets.Docs.AppReference }}web/upstream/socket-family/ + {{- if .SocketFamily }} + upstream: + # Whether your app should speak to the webserver via TCP or Unix socket. Defaults to tcp + # More information: {{ .Assets.Docs.AppReference }}web/upstream/socket-family/ + socket_family: {{ .SocketFamily }} + {{- else }} + # upstream: + # socket_family: unix + {{- end }} + + {{- if .Locations }} + # Each key in locations is a path on your site with a leading /. + # More information: {{ .Assets.Docs.AppReference }}web/locations/ + locations: + {{ range $key, $value := .Locations -}} + "{{ $key }}": + {{ range $key, $value := $value }} + {{- $key }}: {{ if typeIs "string" $value }}{{ quote $value }}{{ else }}{{ $value }}{{ end }} + {{ end }} + {{ end -}} + {{ else }} + # The locations of the application. Use passthru or serve static files according to your application's needs. + locations: + "/": + passthru: true + # "/static": + # The directory to serve static assets for this location relative to the app’s root directory. Must be an + # actual directory inside the root directory. + # root: "static" + # The number of seconds whitelisted (static) content should be cached. + # expires: 1h + # Whether to allow serving files which don’t match a rule. + # allow: true + {{ end }} + +# Alternate copies of the application to run as background processes. +# More information: {{ .Assets.Docs.AppReference }}workers/ +# workers: + +# The timezone for crons to run. Format: a TZ database name. Defaults to UTC, which is the timezone used for all logs +# no matter the value here. More information: {{ .Assets.Docs.TimeZone }} +# timezone: + +# Access control for roles accessing app environments. +# More information: {{ .Assets.Docs.AppReference }}access/ +# access: + +# Variables to control the environment. More information: {{ .Assets.Docs.AppReference }}variables/ +{{ if .Environment -}} +variables: + env: + {{ range $key, $value := .Environment }} + {{- $key }}: "{{ $value }}" + {{ end -}} +{{ else -}} +# variables: +# env: +# # Add environment variables here that are static. +{{- if eq "python" .Runtime }} +# PYTHONUNBUFFERED: "1" +{{- else if eq "php" .Runtime }} +# XDEBUG_MODE: off +{{- else if eq "nodejs" .Runtime }} +# NODE_ENV: development +{{- else if eq "ruby" .Runtime }} +# RACK_ENV: production +{{- else }} +# ENV_VARIABLE_NAME: "value" +{{- end }} +{{- end }} + +# Outbound firewall rules for the application. More information: {{ .Assets.Docs.AppReference }}firewall/ +# firewall: + +# Specifies a default set of build tasks to run. Flavors are language-specific. +# More information: {{ .Assets.Docs.AppReference }}build/ +{{ if .BuildFlavor -}} +build: + flavor: {{ .BuildFlavor }} +{{- else -}} +# build: +# flavor: none +{{- end }} + +# Installs global dependencies as part of the build process. They’re independent of your app’s dependencies and +# are available in the PATH during the build process and in the runtime environment. They’re installed before +# the build hook runs using a package manager for the language. +# More information: {{ .Assets.Docs.AppReference }}dependencies/ +{{ if .Dependencies -}} +dependencies: + {{- range $depGroupKey, $depGroupValue := .Dependencies }} + {{ $depGroupKey }}: + {{ range $key, $value := $depGroupValue }} + {{ $key }}: "{{ $value }}" + {{- end }} + {{- end }} +{{ else -}} +# dependencies: + {{- if eq "python" .Runtime }} +# python3: # Specify one Python 3 package per line. +# numpy: '*' + {{- else if eq "php" .Runtime }} +# php: # Specify one Composer package per line. +# drush/drush: '8.0.0' +# composer/composer: '^2' + {{- else if eq "nodejs" .Runtime }} +# nodejs: # Specify one NPM package per line. +# pm2: '^4.5.0' + {{- else }} +# runtime: # Specify one package per line. +# package: 'version' + {{- end }} +{{- end }} + +# Hooks allow you to customize your code/environment as the project moves through the build and deploy stages +# More information: {{ .Assets.Docs.AppReference }}hooks/ +hooks: + # The build hook is run after any build flavor. + # More information: {{ .Assets.Docs.Hooks }}compare/build/ + build: | + set -eux + {{ range $step := .BuildSteps }} + {{- $step }} + {{ else }} + # Add build steps here. + # echo 'I am a build step' + {{ end }} + # The deploy hook is run after the app container has been started, but before it has started accepting requests. + # More information: {{ .Assets.Docs.Hooks }}compare/deploy/ + deploy: | + set -eux + {{ range $deploycmd := .DeployCommand }} + {{- $deploycmd }} + {{ else -}} + # echo 'Put your deploy command here' + {{ end }} + + # The post_deploy hook is run after the app container has been started and after it has started accepting requests. + # More information: {{ .Assets.Docs.Hooks }}compare/post-deploy/ + # post_deploy: | + +# Scheduled tasks for the app. +# More information: {{ .Assets.Docs.AppReference }}crons/ +# crons: + +# Customizations to your PHP or Lisp runtime. More information: {{ .Assets.Docs.AppReference }}runtime/ +# runtime: + +# More information: {{ .Assets.Docs.AppReference }}additional-hosts/ +# additional_hosts: diff --git a/internal/platformify/platformifier/templates/generic/.platform/routes.yaml b/internal/platformify/platformifier/templates/generic/.platform/routes.yaml new file mode 100644 index 000000000..5371492fc --- /dev/null +++ b/internal/platformify/platformifier/templates/generic/.platform/routes.yaml @@ -0,0 +1,14 @@ +# The routes of the project. +# +# Each route describes how an incoming URL is going +# to be processed by {{ .Assets.ServiceName }}. +# More information: {{ .Assets.Docs.Routes }} +"https://{{ "{default}" }}/": + type: upstream + upstream: "{{ .Name }}:http" + +# A basic redirect definition +# More information: {{ .Assets.Docs.Routes }}examples/redirect/ +"https://www.{{ "{default}" }}": + type: redirect + to: "https://{{ "{default}" }}/" diff --git a/internal/platformify/platformifier/templates/generic/.platform/services.yaml b/internal/platformify/platformifier/templates/generic/.platform/services.yaml new file mode 100644 index 000000000..7704303d1 --- /dev/null +++ b/internal/platformify/platformifier/templates/generic/.platform/services.yaml @@ -0,0 +1,21 @@ +# The services of the project. +# +# Each service listed will be deployed +# to power your {{ .Assets.ServiceName }} project. +# More information: {{ .Assets.Docs.Services }} +# Full list of available services: {{ .Assets.Docs.Services }}available/ +{{- if .Services -}} +{{- range $value := .Services }} +{{ $value.Name }}: + type: {{ $value.Type }} # All available versions are: {{ $value.TypeVersions | join ", "}} + {{- if $value.Disk }} + disk: {{ $value.Disk }} # All available sizes are: {{ $value.DiskSizes | join ", "}} + {{- end }} + +{{- end -}} +{{ else }} +# relationships +# db: +# type: postgresql:17 +# disk: 1024 +{{ end -}} diff --git a/internal/platformify/platformifier/templates/upsun/.environment b/internal/platformify/platformifier/templates/upsun/.environment new file mode 100644 index 000000000..6d8730df1 --- /dev/null +++ b/internal/platformify/platformifier/templates/upsun/.environment @@ -0,0 +1,55 @@ +{{- if .Database }}# Set database environment variables +export DB_HOST="${{ .DatabaseUpper }}_HOST" +export DB_PORT="${{ .DatabaseUpper }}_PORT" +export DB_PATH="${{ .DatabaseUpper }}_PATH" +export DB_DATABASE="$DB_PATH" +export DB_USERNAME="${{ .DatabaseUpper }}_USERNAME" +export DB_PASSWORD="${{ .DatabaseUpper }}_PASSWORD" +{{ if eq .Database "postgresql" }}export DB_SCHEME="postgresql" +{{- else if and (eq .Stack.Name "rails") (or (eq .Database "mariadb") (eq .Database "mysql")) }}export DB_SCHEME="mysql2" # mysql/mariadb rails adapter +{{- else }}export DB_SCHEME="${{ .DatabaseUpper }}_SCHEME"{{ end }} +{{- end -}} +{{- if eq .Stack.Name "laravel" }} + +# Set Laravel-specific environment variables +export DB_CONNECTION="$DB_SCHEME" +{{- end }} +export DATABASE_URL="${DB_SCHEME}://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_PATH}" +{{- if .Cache }} + +# Set Cache environment variables +export CACHE_HOST="${{ .CacheUpper }}_HOST" +export CACHE_PORT="${{ .CacheUpper }}_PORT" +export CACHE_SCHEME="${{ .CacheUpper }}_SCHEME" +export CACHE_URL="${CACHE_SCHEME}://${CACHE_HOST}:${CACHE_PORT}" +{{- end -}} +{{- if or (eq .Cache "redis") (eq .Cache "redis_persistent") }} + +# Set Redis environment variables +export REDIS_URL="$CACHE_URL" +{{- end -}} +{{- if eq .Stack.Name "strapi" }} + +# Set Strapi-specific environment variables +export DATABASE_HOST="$DB_HOST" +export DATABASE_PORT="$DB_PORT" +export DATABASE_NAME="$DB_PATH" +export DATABASE_USERNAME="$DB_USERNAME" +export DATABASE_PASSWORD="$DB_PASSWORD" +export DATABASE_SCHEME="$DB_SCHEME" + +# Set secrets needed by Strapi, if they are not set +# Prefer setting these as project secret variables with {{ .Assets.Binary }} variable:create env:SECRET_NAME --sensitive=true +if [ -z "$ADMIN_JWT_SECRET" ]; then + export ADMIN_JWT_SECRET="${{ .Assets.EnvPrefix }}_PROJECT_ENTROPY" +fi +if [ -z "$JWT_SECRET" ]; then + export JWT_SECRET="${{ .Assets.EnvPrefix }}_PROJECT_ENTROPY" +fi +if [ -z "$API_TOKEN_SALT" ]; then + export API_TOKEN_SALT="${{ .Assets.EnvPrefix }}_PROJECT_ENTROPY" +fi +if [ -z "$APP_KEYS" ]; then + export APP_KEYS="${{ .Assets.EnvPrefix }}_PROJECT_ENTROPY" +fi +{{- end -}} diff --git a/internal/platformify/platformifier/templates/upsun/.upsun/config.yaml b/internal/platformify/platformifier/templates/upsun/.upsun/config.yaml new file mode 100644 index 000000000..7ec361e42 --- /dev/null +++ b/internal/platformify/platformifier/templates/upsun/.upsun/config.yaml @@ -0,0 +1,258 @@ +# Complete list of all available properties: {{ .Assets.Docs.AppReference }} +applications: + {{ .Name }}: + # Application source code directory + source: + root: "{{ .ApplicationRoot }}" + + # The runtime the application uses. + # Complete list of available runtimes: {{ .Assets.Docs.AppReference }}type/ + type: "{{ .Type }}" + + # Choose which container profile (ratio CPU+RAM) your app will use. Default value comes from the image itself. + # More information: https://docs.upsun.com/anchors/resources/manage/configuration/profiles/adjust/ + # container_profile: + + # The relationships of the application with services or other applications. + # The left-hand side is the name of the relationship as it will be exposed + # to the application in the {{ .Assets.EnvPrefix }}_RELATIONSHIPS variable. The right-hand + # side is in the form `:`. + # More information: {{ .Assets.Docs.AppReference }}relationships/ + {{ if .Relationships -}} + relationships: + {{ range $key, $value := .Relationships }} + {{- $key }}: + {{ end -}} + {{ else }} + # relationships: + # db: + {{ end }} + + # Mounts define directories that are writable after the build is complete. + # More information: {{ .Assets.Docs.AppReference }}mounts/ + {{ if .Mounts -}} + mounts: + {{ range $key, $value := .Mounts -}} + "{{ $key }}": + {{ range $key, $value := $value }} + {{- if eq $key "source" -}}{{- $value = $value | replace "local" "storage" -}}{{- end -}} + {{- $key }}: "{{ $value }}" + {{ end }} + {{ end -}} + {{- else -}} + # mounts: + # "/var/uploads": # Represents the path in the app. + # source: "storage" # "storage" sources are unique to the app, but shared among instances of the app. "service" sources can be shared among apps. + # source_path: "var/uploads" # The subdirectory within the mounted disk (the source) where the mount should point. + {{- end }} + + # The web key configures the web server running in front of your app. + # More information: {{ .Assets.Docs.AppReference }}web/ + web: + # Commands are run once after deployment to start the application process. + # More information: {{ .Assets.Docs.AppReference }}web/commands/ + {{- if .WebCommand }} + commands: + # The command to launch your app. If it terminates, it’s restarted immediately. + # You can use the $PORT or the $SOCKET environment variable depending on the socket family of your upstream + start: {{ quote .WebCommand }} + {{- else }} + # commands: + # The command to launch your app. If it terminates, it’s restarted immediately. + # You can use the $PORT or the $SOCKET environment variable depending on the socket family of your upstream + {{- if eq "php" .Runtime }} + # PHP applications run PHP-fpm by default + # Read about alternative commands here: {{ .Assets.Docs.PHP }}alternate-start-commands/ + {{- end }} + # start: echo 'Put your start command here' + {{- end }} + # You can listen to a UNIX socket (unix) or a TCP port (tcp, default). + {{- if eq "php" .Runtime }} + # For PHP, the defaults are configured for PHP-FPM and shouldn't need adjustment. + {{- end }} + # Whether your app should speak to the webserver via TCP or Unix socket. Defaults to tcp + # More information: {{ .Assets.Docs.AppReference }}web/upstream/socket-family/ + {{- if .SocketFamily }} + upstream: + # Whether your app should speak to the webserver via TCP or Unix socket. Defaults to tcp + # More information: {{ .Assets.Docs.AppReference }}web/upstream/socket-family/ + socket_family: {{ .SocketFamily }} + {{- else }} + # upstream: + # socket_family: unix + {{- end }} + + {{- if .Locations }} + # Each key in locations is a path on your site with a leading /. + # More information: {{ .Assets.Docs.AppReference }}web/locations/ + locations: + {{ range $key, $value := .Locations -}} + "{{ $key }}": + {{ range $key, $value := $value }} + {{- $key }}: {{ if typeIs "string" $value }}{{ quote $value }}{{ else }}{{ $value }}{{ end }} + {{ end }} + {{ end -}} + {{ else }} + # The locations of the application. Use passthru or serve static files according to your application's needs. + locations: + "/": + passthru: true + # "/static": + # The directory to serve static assets for this location relative to the app’s root directory. Must be an + # actual directory inside the root directory. + # root: "static" + # The number of seconds whitelisted (static) content should be cached. + # expires: 1h + # Whether to allow serving files which don’t match a rule. + # allow: true + {{ end }} + + # Alternate copies of the application to run as background processes. + # More information: {{ .Assets.Docs.AppReference }}workers/ + # workers: + + # The timezone for crons to run. Format: a TZ database name. Defaults to UTC, which is the timezone used for all logs + # no matter the value here. More information: {{ .Assets.Docs.TimeZone }} + # timezone: + + # Access control for roles accessing app environments. + # More information: {{ .Assets.Docs.AppReference }}access/ + # access: + + # Variables to control the environment. More information: {{ .Assets.Docs.AppReference }}variables/ + {{ if .Environment -}} + variables: + env: + {{ range $key, $value := .Environment }} + {{- $key }}: "{{ $value }}" + {{ end -}} + {{ else -}} + # variables: + # env: + # # Add environment variables here that are static. + {{- if eq "python" .Runtime }} + # PYTHONUNBUFFERED: "1" + {{- else if eq "php" .Runtime }} + # XDEBUG_MODE: off + {{- else if eq "nodejs" .Runtime }} + # NODE_ENV: development + {{- else if eq "ruby" .Runtime }} + # RACK_ENV: production + {{- else }} + # ENV_VARIABLE_NAME: "value" + {{- end }} + {{- end }} + + # Outbound firewall rules for the application. More information: {{ .Assets.Docs.AppReference }}firewall/ + # firewall: + + # Specifies a default set of build tasks to run. Flavors are language-specific. + # More information: {{ .Assets.Docs.AppReference }}build/ + {{ if .BuildFlavor -}} + build: + flavor: {{ .BuildFlavor }} + {{- else -}} + # build: + # flavor: none + {{- end }} + + # Installs global dependencies as part of the build process. They’re independent of your app’s dependencies and + # are available in the PATH during the build process and in the runtime environment. They’re installed before + # the build hook runs using a package manager for the language. + # More information: {{ .Assets.Docs.AppReference }}dependencies/ + {{ if .Dependencies -}} + dependencies: + {{- range $key, $value := .Dependencies }} + {{ $key }}: + {{- range $key, $value := $value }} + {{ $key }}: "{{ $value }}" + {{- end }} + {{- end }} + {{- else -}} + # dependencies: + {{- if eq "python" .Runtime }} + # python3: # Specify one Python 3 package per line. + # numpy: '*' + {{- else if eq "php" .Runtime }} + # php: # Specify one Composer package per line. + # drush/drush: '8.0.0' + # composer/composer: '^2' + {{- else if eq "nodejs" .Runtime }} + # nodejs: # Specify one NPM package per line. + # pm2: '^4.5.0' + {{- else }} + # runtime: # Specify one package per line. + # package: 'version' + {{- end }} + {{- end }} + + # Hooks allow you to customize your code/environment as the project moves through the build and deploy stages + # More information: {{ .Assets.Docs.AppReference }}hooks/ + hooks: + # The build hook is run after any build flavor. + # More information: {{ .Assets.Docs.Hooks }}compare/build/ + build: | + set -eux + {{ range $step := .BuildSteps }} + {{- $step }} + {{ else }} + # Add build steps here. + # echo 'I am a build step' + {{ end }} + # The deploy hook is run after the app container has been started, but before it has started accepting requests. + # More information: {{ .Assets.Docs.Hooks }}compare/deploy/ + deploy: | + set -eux + {{ range $deploycmd := .DeployCommand }} + {{- $deploycmd }} + {{ else -}} + # echo 'Put your deploy command here' + {{ end }} + + # The post_deploy hook is run after the app container has been started and after it has started accepting requests. + # More information: {{ .Assets.Docs.Hooks }}compare/post-deploy/ + # post_deploy: | + + # Scheduled tasks for the app. + # More information: {{ .Assets.Docs.AppReference }}crons/ + # crons: + + # Customizations to your PHP or Lisp runtime. More information: {{ .Assets.Docs.AppReference }}runtime/ + # runtime: + + # More information: {{ .Assets.Docs.AppReference }}additional-hosts/ + # additional_hosts: + +# The services of the project. +# +# Each service listed will be deployed +# to power your {{ .Assets.ServiceName }} project. +# More information: {{ .Assets.Docs.Services }} +# Full list of available services: {{ .Assets.Docs.Services }}available/ +{{- if .Services }} +services: + {{ range $value := .Services }} + {{- $value.Name }}: + type: {{ $value.Type }} # All available versions are: {{ $value.TypeVersions | join ", "}} + + {{ end }} +{{- else }} +# services: +# db: +# type: postgresql:17 +{{ end }} + +# The routes of the project. +# +# Each route describes how an incoming URL is going +# to be processed by {{ .Assets.ServiceName }}. +# More information: {{ .Assets.Docs.Routes }} +routes: + "https://{{ "{default}" }}/": + type: upstream + upstream: "{{ .Name }}:http" + # A basic redirect definition + # More information: {{ .Assets.Docs.Routes }}examples/redirect/ + "https://www.{{ "{default}" }}": + type: redirect + to: "https://{{ "{default}" }}/" diff --git a/internal/platformify/platformify.go b/internal/platformify/platformify.go new file mode 100644 index 000000000..5264130c1 --- /dev/null +++ b/internal/platformify/platformify.go @@ -0,0 +1,87 @@ +package platformify + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/upsun/cli/internal/platformify/colors" + "github.com/upsun/cli/internal/platformify/platformifier" + "github.com/upsun/cli/internal/platformify/question" + "github.com/upsun/cli/internal/platformify/question/models" + "github.com/upsun/cli/internal/platformify/questionnaire" + "github.com/upsun/cli/internal/platformify/vendorization" +) + +type contextKey string + +var FlavorKey contextKey = "flavor" + +func NewPlatformifyCmd(assets *vendorization.VendorAssets) *cobra.Command { + 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 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, + ) + q := questionnaire.New( + &question.WorkingDirectory{}, + &question.FilesOverwrite{}, + &question.Welcome{}, + &question.Stack{}, + &question.Type{}, + &question.DependencyManager{}, + &question.Locations{}, + &question.Mounts{}, + &question.Name{}, + &question.ApplicationRoot{}, + &question.Environment{}, + &question.BuildSteps{}, + &question.DeployCommand{}, + &question.SocketFamily{}, + &question.WebCommand{}, + &question.AlmostDone{}, + &question.Services{}, + ) + err := q.AskQuestions(ctx) + if errors.Is(err, questionnaire.ErrSilent) { + return nil + } + + if err != nil { + fmt.Fprintln(stderr, colors.Colorize(colors.ErrorCode, err.Error())) + return err + } + + input := answers.ToUserInput() + + pfier := platformifier.New(input, assets.ConfigFlavor) + 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) + } + + done := question.Done{} + return done.Ask(ctx) +} diff --git a/internal/platformify/question/almost_done.go b/internal/platformify/question/almost_done.go new file mode 100644 index 000000000..11e91628a --- /dev/null +++ b/internal/platformify/question/almost_done.go @@ -0,0 +1,23 @@ +package question + +import ( + "context" + "fmt" + + "github.com/upsun/cli/internal/platformify/colors" +) + +type AlmostDone struct{} + +func (q *AlmostDone) Ask(ctx context.Context) error { + out, _, ok := colors.FromContext(ctx) + if !ok { + return nil + } + + fmt.Fprintln(out) + fmt.Fprintln(out, colors.Colorize(colors.AccentCode, " (\\_/)")) + fmt.Fprintln(out, colors.Colorize(colors.AccentCode, "We’re almost done... =(^.^)=")) + fmt.Fprintln(out) + return nil +} diff --git a/internal/platformify/question/application_root.go b/internal/platformify/question/application_root.go new file mode 100644 index 000000000..57e002c3e --- /dev/null +++ b/internal/platformify/question/application_root.go @@ -0,0 +1,51 @@ +package question + +import ( + "context" + "path" + "path/filepath" + + "github.com/upsun/cli/internal/platformify/question/models" + "github.com/upsun/cli/internal/platformify/utils" +) + +type ApplicationRoot struct{} + +func (q *ApplicationRoot) Ask(ctx context.Context) error { + answers, ok := models.FromContext(ctx) + if !ok { + return nil + } + + for _, dm := range answers.DependencyManagers { + switch dm { + case models.Composer: + if composerPath := utils.FindFile(answers.WorkingDirectory, "composer.json"); composerPath != "" { + answers.ApplicationRoot, _ = filepath.Rel(answers.WorkingDirectory, path.Dir(composerPath)) + return nil + } + case models.Npm, models.Yarn: + if packagePath := utils.FindFile(answers.WorkingDirectory, "package.json"); packagePath != "" { + answers.ApplicationRoot, _ = filepath.Rel(answers.WorkingDirectory, path.Dir(packagePath)) + return nil + } + case models.Poetry: + if pyProjectPath := utils.FindFile(answers.WorkingDirectory, "pyproject.toml"); pyProjectPath != "" { + answers.ApplicationRoot, _ = filepath.Rel(answers.WorkingDirectory, path.Dir(pyProjectPath)) + return nil + } + case models.Pipenv: + if pipfilePath := utils.FindFile(answers.WorkingDirectory, "Pipfile"); pipfilePath != "" { + answers.ApplicationRoot, _ = filepath.Rel(answers.WorkingDirectory, path.Dir(pipfilePath)) + return nil + } + case models.Pip: + if requirementsPath := utils.FindFile(answers.WorkingDirectory, "requirements.txt"); requirementsPath != "" { + answers.ApplicationRoot, _ = filepath.Rel(answers.WorkingDirectory, path.Dir(requirementsPath)) + return nil + } + } + } + + return nil +} diff --git a/internal/platformify/question/build_steps.go b/internal/platformify/question/build_steps.go new file mode 100644 index 000000000..5392ced42 --- /dev/null +++ b/internal/platformify/question/build_steps.go @@ -0,0 +1,143 @@ +package question + +import ( + "context" + "fmt" + "path" + "path/filepath" + "slices" + + "github.com/upsun/cli/internal/platformify/question/models" + "github.com/upsun/cli/internal/platformify/utils" + "github.com/upsun/cli/internal/platformify/vendorization" +) + +type BuildSteps struct{} + +func (q *BuildSteps) Ask(ctx context.Context) error { + answers, ok := models.FromContext(ctx) + if !ok { + return nil + } + + for _, dm := range answers.DependencyManagers { + switch dm { + case models.Poetry: + answers.BuildSteps = append( + answers.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 models.Pipenv: + answers.BuildSteps = append( + answers.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 models.Pip: + answers.BuildSteps = append( + answers.BuildSteps, + "pip install -r requirements.txt", + ) + case models.Yarn, models.Npm: + if answers.Type.Runtime != models.NodeJS { + if _, ok := answers.Dependencies["nodejs"]; !ok { + answers.Dependencies["nodejs"] = map[string]string{} + } + answers.Dependencies["nodejs"]["n"] = "*" + answers.Dependencies["nodejs"]["npx"] = "*" + answers.Environment["N_PREFIX"] = "/app/.global" + answers.BuildSteps = append( + answers.BuildSteps, + "n auto || n lts", + "hash -r", + ) + } + + if dm == models.Yarn { + answers.BuildSteps = append( + answers.BuildSteps, + "yarn", + ) + } else { + answers.BuildSteps = append( + answers.BuildSteps, + "npm i", + ) + } + if _, ok := utils.GetJSONValue( + []string{"scripts", "build"}, + path.Join(answers.WorkingDirectory, "package.json"), + true, + ); ok { + if dm == models.Yarn { + answers.BuildSteps = append(answers.BuildSteps, "yarn build") + } else { + answers.BuildSteps = append(answers.BuildSteps, "npm run build") + } + } + case models.Composer: + answers.BuildSteps = append( + answers.BuildSteps, + "composer --no-ansi --no-interaction install --no-progress --prefer-dist --optimize-autoloader --no-dev", + ) + case models.Bundler: + answers.BuildSteps = append( + answers.BuildSteps, + "bundle install", + ) + } + } + + switch answers.Stack { + case models.Django: + if managePyPath := utils.FindFile( + path.Join(answers.WorkingDirectory, answers.ApplicationRoot), + managePyFile, + ); managePyPath != "" { + prefix := "" + if slices.Contains(answers.DependencyManagers, models.Pipenv) { + prefix = "pipenv run " + } else if slices.Contains(answers.DependencyManagers, models.Poetry) { + prefix = "poetry run " + } + + managePyPath, _ = filepath.Rel(path.Join(answers.WorkingDirectory, answers.ApplicationRoot), managePyPath) + assets, _ := vendorization.FromContext(ctx) + answers.BuildSteps = append( + answers.BuildSteps, + fmt.Sprintf( + "# Collect static files so that they can be served by %s", + assets.ServiceName, + ), + fmt.Sprintf("%spython %s collectstatic --noinput", prefix, managePyPath), + ) + } + case models.NextJS: + // If there is no custom build script, fallback to next build for Next.js projects + if !slices.Contains(answers.BuildSteps, "yarn build") && !slices.Contains(answers.BuildSteps, "npm run build") { + cmd := "npm exec next build" + if slices.Contains(answers.DependencyManagers, models.Yarn) { + cmd = "yarn exec next build" + } + answers.BuildSteps = append(answers.BuildSteps, cmd) + } + case models.Rails: + answers.Environment["RAILS_ENV"] = "production" + answers.Environment["PIDFILE"] = "tmp/server.pid" + answers.BuildSteps = append( + answers.BuildSteps, + "bundle exec rails assets:precompile", + ) + } + + return nil +} diff --git a/internal/platformify/question/build_steps_test.go b/internal/platformify/question/build_steps_test.go new file mode 100644 index 000000000..be313c130 --- /dev/null +++ b/internal/platformify/question/build_steps_test.go @@ -0,0 +1,88 @@ +package question + +import ( + "context" + "reflect" + "testing" + + "github.com/upsun/cli/internal/platformify/question/models" +) + +func TestBuildSteps_Ask(t *testing.T) { + type args struct { + answers models.Answers + } + tests := []struct { + name string + q *BuildSteps + args args + buildSteps []string + wantErr bool + }{ + { + name: "Next.js fallback", + q: &BuildSteps{}, + args: args{models.Answers{ + Stack: models.NextJS, + Type: models.RuntimeType{Runtime: models.NodeJS, Version: "20.0"}, + Dependencies: map[string]map[string]string{}, + DependencyManagers: []models.DepManager{models.Yarn}, + Environment: map[string]string{}, + }}, + buildSteps: []string{"yarn", "yarn exec next build"}, + wantErr: false, + }, + { + name: "Next.js npm fallback", + q: &BuildSteps{}, + args: args{models.Answers{ + Stack: models.NextJS, + Type: models.RuntimeType{Runtime: models.NodeJS, Version: "20.0"}, + Dependencies: map[string]map[string]string{}, + DependencyManagers: []models.DepManager{models.Npm}, + Environment: map[string]string{}, + }}, + buildSteps: []string{"npm i", "npm exec next build"}, + wantErr: false, + }, + { + name: "Bundler", + q: &BuildSteps{}, + args: args{models.Answers{ + Stack: models.GenericStack, + Type: models.RuntimeType{Runtime: models.Ruby, Version: "3.3"}, + Dependencies: map[string]map[string]string{}, + DependencyManagers: []models.DepManager{models.Bundler}, + Environment: map[string]string{}, + }}, + buildSteps: []string{"bundle install"}, + wantErr: false, + }, + { + name: "Bundler with Rails", + q: &BuildSteps{}, + args: args{models.Answers{ + Stack: models.Rails, + Type: models.RuntimeType{Runtime: models.Ruby, Version: "3.3"}, + Dependencies: map[string]map[string]string{}, + DependencyManagers: []models.DepManager{models.Bundler}, + Environment: map[string]string{}, + }}, + buildSteps: []string{"bundle install", "bundle exec rails assets:precompile"}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q := &BuildSteps{} + ctx := models.ToContext(context.Background(), &tt.args.answers) + if err := q.Ask(ctx); (err != nil) != tt.wantErr { + t.Errorf("BuildSteps.Ask() error = %v, wantErr %v", err, tt.wantErr) + } + + if !reflect.DeepEqual(tt.args.answers.BuildSteps, tt.buildSteps) { + t.Errorf("BuildSteps.Ask() BuildSteps = %v, want %v", tt.args.answers.BuildSteps, tt.buildSteps) + } + }) + } +} diff --git a/internal/platformify/question/dependency_manager.go b/internal/platformify/question/dependency_manager.go new file mode 100644 index 000000000..2be339d22 --- /dev/null +++ b/internal/platformify/question/dependency_manager.go @@ -0,0 +1,86 @@ +package question + +import ( + "context" + "fmt" + + "github.com/upsun/cli/internal/platformify/colors" + "github.com/upsun/cli/internal/platformify/question/models" + "github.com/upsun/cli/internal/platformify/utils" +) + +const ( + npmLockFileName = "package-lock.json" + yarnLockFileName = "yarn.lock" + poetryLockFile = "poetry.lock" + pipenvLockFile = "Pipfile.lock" + pipLockFile = "requirements.txt" + composerLockFile = "composer.lock" + bundlerLockFile = "Gemfile.lock" +) + +type DependencyManager struct{} + +func (q *DependencyManager) Ask(ctx context.Context) error { + answers, ok := models.FromContext(ctx) + if !ok { + return nil + } + if len(answers.DependencyManagers) > 0 { + // Skip the step + return nil + } + + answers.Dependencies = map[string]map[string]string{} + answers.BuildFlavor = "none" + + defer func() { + _, stderr, ok := colors.FromContext(ctx) + if !ok { + return + } + + if len(answers.DependencyManagers) > 0 { + dependencyManagers := answers.DependencyManagers[0].Title() + for _, dm := range answers.DependencyManagers[1:] { + dependencyManagers = fmt.Sprintf("%s, %s", dependencyManagers, dm.Title()) + } + + fmt.Fprintf( + stderr, + "%s %s\n", + colors.Colorize(colors.GreenCode, "✓"), + colors.Colorize( + colors.BrandCode, + fmt.Sprintf("Detected dependency managers: %s", dependencyManagers), + ), + ) + } + }() + + 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) + } + + 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) + } + + if exists := utils.FileExists(answers.WorkingDirectory, bundlerLockFile); exists { + answers.DependencyManagers = append(answers.DependencyManagers, models.Bundler) + } + + return nil +} diff --git a/internal/platformify/question/deploy_command.go b/internal/platformify/question/deploy_command.go new file mode 100644 index 000000000..755d23afb --- /dev/null +++ b/internal/platformify/question/deploy_command.go @@ -0,0 +1,52 @@ +package question + +import ( + "context" + "fmt" + "path" + "path/filepath" + "slices" + + "github.com/upsun/cli/internal/platformify/question/models" + "github.com/upsun/cli/internal/platformify/utils" +) + +type DeployCommand struct{} + +func (q *DeployCommand) Ask(ctx context.Context) error { + answers, ok := models.FromContext(ctx) + if !ok { + return nil + } + + switch answers.Stack { + case models.Django: + managePyPath := utils.FindFile(path.Join(answers.WorkingDirectory, answers.ApplicationRoot), managePyFile) + if managePyPath != "" { + managePyPath, _ = filepath.Rel(path.Join(answers.WorkingDirectory, answers.ApplicationRoot), managePyPath) + prefix := "" + if slices.Contains(answers.DependencyManagers, models.Pipenv) { + prefix = "pipenv run " + } else if slices.Contains(answers.DependencyManagers, models.Poetry) { + prefix = "poetry run " + } + answers.DeployCommand = append(answers.DeployCommand, + fmt.Sprintf("%spython %s migrate", prefix, managePyPath), + ) + } + case models.Laravel: + answers.DeployCommand = append(answers.DeployCommand, + "mkdir -p storage/framework/sessions", + "mkdir -p storage/framework/cache", + "mkdir -p storage/framework/views", + "php artisan migrate --force", + "php artisan optimize:clear", + ) + case models.Rails: + answers.DeployCommand = append(answers.DeployCommand, + "bundle exec rake db:migrate", + ) + } + + return nil +} diff --git a/internal/platformify/question/done.go b/internal/platformify/question/done.go new file mode 100644 index 000000000..00553707d --- /dev/null +++ b/internal/platformify/question/done.go @@ -0,0 +1,89 @@ +package question + +import ( + "context" + "fmt" + + "github.com/upsun/cli/internal/platformify/colors" + "github.com/upsun/cli/internal/platformify/question/models" + "github.com/upsun/cli/internal/platformify/vendorization" +) + +type Done struct{} + +func (q *Done) Ask(ctx context.Context) error { + out, _, ok := colors.FromContext(ctx) + if !ok { + return nil + } + + answers, ok := models.FromContext(ctx) + if !ok { + return nil + } + + assets, _ := vendorization.FromContext(ctx) + fmt.Fprintln(out) + fmt.Fprintln(out, "┌───────────────────────────────────────────────────┐") + fmt.Fprintln(out, "│ CONGRATULATIONS! │") + fmt.Fprintln(out, "│ │") + fmt.Fprintln(out, "│ We have created the following files for your: │") + for _, f := range assets.ProprietaryFiles() { + fmt.Fprintf(out, "│ - %-44s│\n", f) + } + fmt.Fprintln(out, "│ │") + fmt.Fprintln(out, "│ We’re jumping for joy! ⍢ │") + fmt.Fprintln(out, "└───────────────────────────────────────────────────┘") + fmt.Fprintln(out, " │ /") + fmt.Fprintln(out, " │/") + fmt.Fprintln(out, " │") + fmt.Fprintln(out, " (\\ /)") + fmt.Fprintln(out, " ( . .)") + fmt.Fprintln(out, " o (_(“)(“)") + fmt.Fprintln(out) + if answers.HasGit { + fmt.Fprintln(out, colors.Colorize(colors.AccentCode, fmt.Sprintf( + "You can now deploy your application to %s!", + assets.ServiceName, + ))) + fmt.Fprintln( + out, + colors.Colorize( + colors.AccentCode, + fmt.Sprintf( + "To do so, commit your files and deploy your application using the %s CLI:", + assets.ServiceName, + ), + ), + ) + fmt.Fprintln(out, " $ git add .") + fmt.Fprintf(out, " $ git commit -m 'Add %s configuration files'\n", assets.ServiceName) + fmt.Fprintf(out, " $ %s project:set-remote\n", assets.Binary) + fmt.Fprintf(out, " $ %s push\n", assets.Binary) + fmt.Fprintln(out) + return nil + } + + fmt.Fprintln(out, colors.Colorize(colors.AccentCode, fmt.Sprintf( + "You can now deploy your application to %s!", + assets.ServiceName, + ))) + fmt.Fprintln( + out, + colors.Colorize( + colors.AccentCode, + fmt.Sprintf( + "To do so, you need to create a Git repository, commit your files and deploy your application using the %s CLI:", + assets.ServiceName, + ), + ), + ) + fmt.Fprintf(out, " $ git init %s\n", answers.WorkingDirectory) + fmt.Fprintln(out, " $ git add .") + fmt.Fprintf(out, " $ git commit -m 'Add %s configuration files'\n", assets.ServiceName) + fmt.Fprintf(out, " $ %s project:set-remote\n", assets.Binary) + fmt.Fprintf(out, " $ %s push\n", assets.Binary) + fmt.Fprintln(out) + + return nil +} diff --git a/internal/platformify/question/environment.go b/internal/platformify/question/environment.go new file mode 100644 index 000000000..6137f62b0 --- /dev/null +++ b/internal/platformify/question/environment.go @@ -0,0 +1,30 @@ +package question + +import ( + "context" + + "github.com/upsun/cli/internal/platformify/question/models" +) + +type Environment struct{} + +func (q *Environment) Ask(ctx context.Context) error { + answers, ok := models.FromContext(ctx) + if !ok { + 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" + } + } + + return nil +} diff --git a/internal/platformify/question/files_overwrite.go b/internal/platformify/question/files_overwrite.go new file mode 100644 index 000000000..6d74ffe79 --- /dev/null +++ b/internal/platformify/question/files_overwrite.go @@ -0,0 +1,73 @@ +package question + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/AlecAivazis/survey/v2" + + "github.com/upsun/cli/internal/platformify/colors" + "github.com/upsun/cli/internal/platformify/question/models" + "github.com/upsun/cli/internal/platformify/questionnaire" + "github.com/upsun/cli/internal/platformify/vendorization" +) + +type FilesOverwrite struct{} + +func (q *FilesOverwrite) Ask(ctx context.Context) error { + answers, ok := models.FromContext(ctx) + if !ok { + 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() { + if st, err := os.Stat(filepath.Join(answers.WorkingDirectory, p)); err == nil && !st.IsDir() { + existingFiles = append(existingFiles, p) + } + } + + if len(existingFiles) > 0 { + fmt.Fprintln( + stderr, + colors.Colorize( + colors.WarningCode, + fmt.Sprintf("You are reconfiguring the project at %s.", answers.WorkingDirectory), + ), + ) + fmt.Fprintln( + stderr, + colors.Colorize( + colors.WarningCode, + fmt.Sprintf( + "The following %s files already exist in this directory:", + assets.ServiceName, + ), + ), + ) + for _, p := range existingFiles { + fmt.Fprintln(stderr, colors.Colorize(colors.WarningCode, fmt.Sprintf(" - %s", p))) + } + proceed := false + if err := survey.AskOne(&survey.Confirm{ + Message: "Do you want to overwrite them?", + Default: proceed, + }, &proceed); err != nil { + return err + } + + if !proceed { + return questionnaire.ErrUserAborted + } + } + + return nil +} diff --git a/internal/platformify/question/locations.go b/internal/platformify/question/locations.go new file mode 100644 index 000000000..d8c8dc536 --- /dev/null +++ b/internal/platformify/question/locations.go @@ -0,0 +1,43 @@ +package question + +import ( + "context" + "path/filepath" + + "github.com/upsun/cli/internal/platformify/question/models" + "github.com/upsun/cli/internal/platformify/utils" +) + +type Locations struct{} + +func (q *Locations) Ask(ctx context.Context) error { + answers, ok := models.FromContext(ctx) + if !ok { + return nil + } + answers.Locations = make(map[string]map[string]interface{}) + switch answers.Stack { + case models.Django: + answers.Locations["/static"] = map[string]interface{}{ + "root": "static", + "expires": "1h", + "allow": true, + } + default: + if answers.Type.Runtime == models.PHP { + locations := map[string]interface{}{ + "passthru": "/index.php", + "root": "", + } + if indexPath := utils.FindFile(answers.WorkingDirectory, "index.php"); indexPath != "" { + indexRelPath, _ := filepath.Rel(answers.WorkingDirectory, indexPath) + if filepath.Dir(indexRelPath) != "." { + locations["root"] = filepath.Dir(indexRelPath) + } + } + answers.Locations["/"] = locations + } + } + + return nil +} diff --git a/internal/platformify/question/models/answer.go b/internal/platformify/question/models/answer.go new file mode 100644 index 000000000..c25155f14 --- /dev/null +++ b/internal/platformify/question/models/answer.go @@ -0,0 +1,179 @@ +package models + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/upsun/cli/internal/platformify/platformifier" +) + +type Answers struct { + Stack Stack `json:"stack"` + Flavor string `json:"flavor"` + Type RuntimeType `json:"type"` + Name string `json:"name"` + ApplicationRoot string `json:"application_root"` + Environment map[string]string `json:"environment"` + BuildSteps []string `json:"build_steps"` + WebCommand string `json:"web_command"` + SocketFamily SocketFamily `json:"socket_family"` + DeployCommand []string `json:"deploy_command"` + DependencyManagers []DepManager `json:"dependency_managers"` + Dependencies map[string]map[string]string `json:"dependencies"` + BuildFlavor string `json:"build_flavor"` + Disk string `json:"disk"` + Mounts map[string]map[string]string `json:"mounts"` + Services []Service `json:"services"` + WorkingDirectory string `json:"working_directory"` + HasGit bool `json:"has_git"` + FilesCreated []string `json:"files_created"` + Locations map[string]map[string]interface{} `json:"locations"` +} + +type Service struct { + Name string `json:"name"` + Type ServiceType `json:"type"` + TypeVersions []string `json:"type_versions"` + Disk ServiceDisk `json:"disk,omitempty"` + DiskSizes []ServiceDisk `json:"disk_sizes"` +} + +type RuntimeType struct { + Runtime Runtime + Version string +} + +func (t RuntimeType) String() string { + if t.Version != "" { + return t.Runtime.String() + ":" + t.Version + } + return t.Runtime.String() +} + +func (t RuntimeType) MarshalJSON() ([]byte, error) { + return json.Marshal(t.String()) +} + +type ServiceType struct { + Name string + Version string +} + +func (t ServiceType) String() string { + if t.Version != "" { + return t.Name + ":" + t.Version + } + return t.Name +} + +func (t ServiceType) MarshalJSON() ([]byte, error) { + return json.Marshal(t.String()) +} + +func NewAnswers() *Answers { + return &Answers{ + Environment: make(map[string]string), + BuildSteps: make([]string, 0), + Services: make([]Service, 0), + } +} + +func (a *Answers) ToUserInput() *platformifier.UserInput { + services := make([]platformifier.Service, 0, len(a.Services)) + for _, service := range a.Services { + diskSizes := make([]string, 0, len(service.DiskSizes)) + for _, size := range service.DiskSizes { + diskSizes = append(diskSizes, size.String()) + } + services = append(services, platformifier.Service{ + Name: service.Name, + Type: service.Type.String(), + TypeVersions: service.TypeVersions, + Disk: service.Disk.String(), + DiskSizes: diskSizes, + }) + } + + locations := map[string]map[string]interface{}{ + "/": { + "passthru": true, + }, + } + for key, value := range a.Locations { + locations[key] = value + } + + dependencyManagers := make([]string, len(a.DependencyManagers)) + for _, dm := range a.DependencyManagers { + dependencyManagers = append(dependencyManagers, dm.String()) + } + + return &platformifier.UserInput{ + Stack: getStack(a.Stack), + Root: "", + ApplicationRoot: filepath.Join(string(os.PathSeparator), a.ApplicationRoot), + Name: a.Name, + Type: a.Type.String(), + Runtime: a.Type.Runtime.String(), + Environment: a.Environment, + BuildSteps: a.BuildSteps, + WebCommand: a.WebCommand, + SocketFamily: a.SocketFamily.String(), + DependencyManagers: dependencyManagers, + DeployCommand: a.DeployCommand, + Locations: locations, + Dependencies: a.Dependencies, + BuildFlavor: a.BuildFlavor, + Disk: a.Disk, + Mounts: a.Mounts, + Services: services, + Relationships: getRelationships(a.Services), + WorkingDirectory: a.WorkingDirectory, + HasGit: a.HasGit, + } +} + +func getStack(answersStack Stack) platformifier.Stack { + switch answersStack { + case Django: + return platformifier.Django + case Laravel: + return platformifier.Laravel + case Rails: + return platformifier.Rails + case NextJS: + return platformifier.NextJS + case Strapi: + return platformifier.Strapi + case Flask: + return platformifier.Flask + case Express: + return platformifier.Express + default: + return platformifier.Generic + } +} + +// getRelationships returns a map of service names to their endpoint names. +func getRelationships(services []Service) map[string]platformifier.Relationship { + endpointRemap := map[string]string{ + "mariadb": "mysql", + "oracle-mysql": "mysql", + "chrome-headless": "http", + "redis-persistent": "redis", + } + relationships := make(map[string]platformifier.Relationship) + for _, service := range services { + endpoint := strings.Split(service.Type.Name, ":")[0] + if remappedEndpoint, ok := endpointRemap[endpoint]; ok { + endpoint = remappedEndpoint + } + relationships[service.Name] = platformifier.Relationship{ + Service: service.Name, + Endpoint: endpoint, + } + } + return relationships +} diff --git a/internal/platformify/question/models/answer_test.go b/internal/platformify/question/models/answer_test.go new file mode 100644 index 000000000..e317d7724 --- /dev/null +++ b/internal/platformify/question/models/answer_test.go @@ -0,0 +1,85 @@ +package models + +import ( + "reflect" + "testing" + + "github.com/upsun/cli/internal/platformify/platformifier" +) + +func Test_getRelationships(t *testing.T) { + type args struct { + services []Service + } + tests := []struct { + name string + args args + want map[string]platformifier.Relationship + }{ + { + name: "Simple", + args: args{ + services: []Service{ + { + Name: "mysql", + Type: ServiceType{ + Name: "mysql", + Version: "5.7", + }, + }, + }, + }, + want: map[string]platformifier.Relationship{ + "mysql": {Service: "mysql", Endpoint: "mysql"}, + }, + }, + { + name: "Remapped", + args: args{ + services: []Service{ + { + Name: "mariadb", + Type: ServiceType{ + Name: "mariadb", + Version: "14", + }, + }, + { + Name: "oracle-mysql", + Type: ServiceType{ + Name: "oracle-mysql", + Version: "14", + }, + }, + { + Name: "chrome-headless", + Type: ServiceType{ + Name: "chrome-headless", + Version: "14", + }, + }, + { + Name: "redis-persistent", + Type: ServiceType{ + Name: "redis-persistent", + Version: "14", + }, + }, + }, + }, + want: map[string]platformifier.Relationship{ + "mariadb": {Service: "mariadb", Endpoint: "mysql"}, + "oracle-mysql": {Service: "oracle-mysql", Endpoint: "mysql"}, + "chrome-headless": {Service: "chrome-headless", Endpoint: "http"}, + "redis-persistent": {Service: "redis-persistent", Endpoint: "redis"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getRelationships(tt.args.services); !reflect.DeepEqual(got, tt.want) { + t.Errorf("getRelationships() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/platformify/question/models/context.go b/internal/platformify/question/models/context.go new file mode 100644 index 000000000..e24cba0d9 --- /dev/null +++ b/internal/platformify/question/models/context.go @@ -0,0 +1,24 @@ +package models + +import ( + "context" +) + +// key is an unexported type for keys defined in this package. +// This prevents collisions with keys defined in other packages. +type key int + +// answerKey is the key for answers values in Contexts. It is unexported. +// Clients use models.ToContext and models.FromContext instead of using this key directly. +var answerKey key + +// ToContext returns a new Context that carries answers value. +func ToContext(ctx context.Context, answers *Answers) context.Context { + return context.WithValue(ctx, answerKey, answers) +} + +// FromContext returns the answers value stored in ctx, if any. +func FromContext(ctx context.Context) (*Answers, bool) { + answers, ok := ctx.Value(answerKey).(*Answers) + return answers, ok +} diff --git a/internal/platformify/question/models/dependency_manager.go b/internal/platformify/question/models/dependency_manager.go new file mode 100644 index 000000000..04262545d --- /dev/null +++ b/internal/platformify/question/models/dependency_manager.go @@ -0,0 +1,41 @@ +package models + +const ( + GenericDepManager DepManager = "generic" + Pip DepManager = "pip" + Poetry DepManager = "poetry" + Pipenv DepManager = "pipenv" + Composer DepManager = "composer" + Yarn DepManager = "yarn" + Npm DepManager = "npm" + Bundler DepManager = "bundler" +) + +type DepManager string + +func (m DepManager) String() string { + return string(m) +} + +func (m DepManager) Title() string { + switch m { + case GenericDepManager: + return "Other" + case Pip: + return "Pip" + case Poetry: + return "Poetry" + case Pipenv: + return "Pipenv" + case Composer: + return "Composer" + case Yarn: + return "Yarn" + case Npm: + return "Npm" + case Bundler: + return "Bundler" + default: + return "" + } +} diff --git a/internal/platformify/question/models/generate_versions.go b/internal/platformify/question/models/generate_versions.go new file mode 100644 index 000000000..9a54d7623 --- /dev/null +++ b/internal/platformify/question/models/generate_versions.go @@ -0,0 +1,288 @@ +//go:build ignore + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "go/format" + "io" + "log" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "text/template" +) + +const registryURL = "https://raw.githubusercontent.com/platformsh/platformsh-docs/main/shared/data/registry.json" + +type Registry map[string]Service + +type Service struct { + Description string `json:"description"` + Disk bool `json:"disk"` + Runtime bool `json:"runtime"` + Type string `json:"type"` + Versions map[string][]string `json:"versions"` +} + +// Mapping from registry service names to our ServiceName constants +var serviceMapping = map[string]string{ + "chrome-headless": "ChromeHeadless", + "influxdb": "InfluxDB", + "kafka": "Kafka", + "mariadb": "MariaDB", + "memcached": "Memcached", + "mysql": "MySQL", + "network-storage": "NetworkStorage", + "opensearch": "OpenSearch", + "oracle-mysql": "OracleMySQL", + "postgresql": "PostgreSQL", + "rabbitmq": "RabbitMQ", + "redis": "Redis", + "solr": "Solr", + "varnish": "Varnish", + "vault-kms": "VaultKMS", +} + +// Mapping from registry runtime names to our Runtime constants +var runtimeMapping = map[string]string{ + "dotnet": "DotNet", + "elixir": "Elixir", + "golang": "Golang", + "java": "Java", + "nodejs": "NodeJS", + "php": "PHP", + "python": "Python", + "ruby": "Ruby", + "rust": "Rust", +} + +const versionTemplate = `//go:generate go run generate_versions.go + +package models + +var ( + LanguageTypeVersions = map[Runtime][]string{ +{{- range .Languages }} + {{ .Name }}: {{ .Versions }}, +{{- end }} + } + + ServiceTypeVersions = map[ServiceName][]string{ +{{- range .Services }} + {{ .Name }}: {{ .Versions }}, +{{- end }} + } +) + +func DefaultVersionForRuntime(r Runtime) string { + versions := LanguageTypeVersions[r] + if len(versions) == 0 { + return "" + } + return versions[0] +} +` + +type TemplateData struct { + Languages []TypeVersion + Services []TypeVersion +} + +type TypeVersion struct { + Name string + Versions string +} + +func main() { + // Fetch the registry data + resp, err := http.Get(registryURL) + if err != nil { + log.Fatalf("Failed to fetch registry: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatalf("Failed to read response: %v", err) + } + + var registry Registry + if err := json.Unmarshal(body, ®istry); err != nil { + log.Fatalf("Failed to parse JSON: %v", err) + } + + var languages []TypeVersion + var services []TypeVersion + + // Process runtimes (languages) + for serviceName, service := range registry { + if !service.Runtime { + continue + } + + constantName, exists := runtimeMapping[serviceName] + if !exists { + continue // Skip runtimes we don't support + } + + supported := service.Versions["supported"] + if len(supported) == 0 { + continue + } + + versions := formatVersionSlice(supported) + languages = append(languages, TypeVersion{ + Name: constantName, + Versions: versions, + }) + } + + // Process services + for serviceName, service := range registry { + if service.Runtime { + continue + } + + constantName, exists := serviceMapping[serviceName] + if !exists { + continue // Skip services we don't support + } + + supported := service.Versions["supported"] + if len(supported) == 0 { + continue + } + + versions := formatVersionSlice(supported) + services = append(services, TypeVersion{ + Name: constantName, + Versions: versions, + }) + + // Add RedisPersistent right after Redis + if serviceName == "redis" { + services = append(services, TypeVersion{ + Name: "RedisPersistent", + Versions: versions, + }) + } + } + + // Sort for consistent output + sort.Slice(languages, func(i, j int) bool { + return languages[i].Name < languages[j].Name + }) + sort.Slice(services, func(i, j int) bool { + return services[i].Name < services[j].Name + }) + + // Generate the Go code + tmpl, err := template.New("version").Parse(versionTemplate) + if err != nil { + log.Fatalf("Failed to parse template: %v", err) + } + + var buf bytes.Buffer + data := TemplateData{ + Languages: languages, + Services: services, + } + + if err := tmpl.Execute(&buf, data); err != nil { + log.Fatalf("Failed to execute template: %v", err) + } + + // Format the generated Go code + formatted, err := format.Source(buf.Bytes()) + if err != nil { + log.Fatalf("Failed to format Go code: %v", err) + } + + // Write to version.go file + dir, err := os.Getwd() + if err != nil { + log.Fatalf("Failed to get working directory: %v", err) + } + + outputPath := filepath.Join(dir, "version.go") + if err := os.WriteFile(outputPath, formatted, 0644); err != nil { + log.Fatalf("Failed to write version.go: %v", err) + } + + fmt.Printf("Successfully generated %s\n", outputPath) +} + +func formatVersionSlice(versions []string) string { + if len(versions) == 0 { + return "{}" + } + + // Sort versions from latest to oldest + sortedVersions := make([]string, len(versions)) + copy(sortedVersions, versions) + + sort.Slice(sortedVersions, func(i, j int) bool { + return compareVersions(sortedVersions[i], sortedVersions[j]) + }) + + quoted := make([]string, len(sortedVersions)) + for i, v := range sortedVersions { + quoted[i] = fmt.Sprintf(`"%s"`, v) + } + + return fmt.Sprintf("{%s}", strings.Join(quoted, ", ")) +} + +// compareVersions returns true if version a is greater than version b +func compareVersions(a, b string) bool { + // Parse version parts + aParts := parseVersion(a) + bParts := parseVersion(b) + + // Compare each part + maxLen := len(aParts) + if len(bParts) > maxLen { + maxLen = len(bParts) + } + + for i := 0; i < maxLen; i++ { + aVal := 0 + bVal := 0 + + if i < len(aParts) { + aVal = aParts[i] + } + if i < len(bParts) { + bVal = bParts[i] + } + + if aVal > bVal { + return true + } + if aVal < bVal { + return false + } + } + + return false // versions are equal +} + +// parseVersion parses a version string into numeric parts +func parseVersion(version string) []int { + parts := strings.Split(version, ".") + nums := make([]int, 0, len(parts)) + + for _, part := range parts { + // Handle cases like "25.05" or "1.0" or just "1" + num := 0 + fmt.Sscanf(part, "%d", &num) + nums = append(nums, num) + } + + return nums +} diff --git a/internal/platformify/question/models/runtime.go b/internal/platformify/question/models/runtime.go new file mode 100644 index 000000000..599cba53c --- /dev/null +++ b/internal/platformify/question/models/runtime.go @@ -0,0 +1,81 @@ +package models + +import ( + "fmt" +) + +const ( + DotNet Runtime = "dotnet" + Elixir Runtime = "elixir" + Golang Runtime = "golang" + Java Runtime = "java" + NodeJS Runtime = "nodejs" + PHP Runtime = "php" + Python Runtime = "python" + Ruby Runtime = "ruby" + Rust Runtime = "rust" +) + +var ( + Runtimes = RuntimeList{ + DotNet, + Elixir, + Golang, + Java, + NodeJS, + PHP, + Python, + Ruby, + Rust, + } +) + +type Runtime string + +func (r Runtime) String() string { + return string(r) +} + +func (r Runtime) Title() string { + switch r { + case DotNet: + return "C#/.Net Core" + case Elixir: + return "Elixir" + case Golang: + return "Go" + case Java: + return "Java" + case NodeJS: + return "JavaScript/Node.js" + case PHP: + return "PHP" + case Python: + return "Python" + case Ruby: + return "Ruby" + case Rust: + return "Rust" + default: + return "" + } +} + +type RuntimeList []Runtime + +func (r RuntimeList) AllTitles() []string { + titles := make([]string, 0, len(r)) + for _, runtime := range r { + titles = append(titles, runtime.Title()) + } + return titles +} + +func (r RuntimeList) RuntimeByTitle(title string) (Runtime, error) { + for _, runtime := range r { + if runtime.Title() == title { + return runtime, nil + } + } + return "", fmt.Errorf("runtime by title is not found") +} diff --git a/internal/platformify/question/models/service_disk.go b/internal/platformify/question/models/service_disk.go new file mode 100644 index 000000000..5e3f253e8 --- /dev/null +++ b/internal/platformify/question/models/service_disk.go @@ -0,0 +1,42 @@ +package models + +const ( + D1024 ServiceDisk = "1024" + D2048 ServiceDisk = "2048" + D3072 ServiceDisk = "3072" + D4096 ServiceDisk = "4096" + D5120 ServiceDisk = "5120" +) + +var ( + ServiceDisks = []ServiceDisk{ + D1024, + D2048, + D3072, + D4096, + D5120, + } +) + +type ServiceDisk string + +func (s ServiceDisk) String() string { + return string(s) +} + +func (s ServiceDisk) Title() string { + switch s { + case D1024: + return "1GB" + case D2048: + return "2GB" + case D3072: + return "3GB" + case D4096: + return "4GB" + case D5120: + return "5GB" + default: + return "" + } +} diff --git a/internal/platformify/question/models/service_name.go b/internal/platformify/question/models/service_name.go new file mode 100644 index 000000000..f42c82123 --- /dev/null +++ b/internal/platformify/question/models/service_name.go @@ -0,0 +1,137 @@ +package models + +import ( + "fmt" + + "github.com/AlecAivazis/survey/v2" +) + +const ( + ChromeHeadless ServiceName = "chrome-headless" + ClickHouse ServiceName = "clickhouse" + InfluxDB ServiceName = "influxdb" + Kafka ServiceName = "kafka" + MariaDB ServiceName = "mariadb" + Memcached ServiceName = "memcached" + MySQL ServiceName = "mysql" + NetworkStorage ServiceName = "network-storage" + OpenSearch ServiceName = "opensearch" + OracleMySQL ServiceName = "oracle-mysql" + PostgreSQL ServiceName = "postgresql" + RabbitMQ ServiceName = "rabbitmq" + Redis ServiceName = "redis" + RedisPersistent ServiceName = "redis-persistent" + Solr ServiceName = "solr" + Varnish ServiceName = "varnish" + VaultKMS ServiceName = "vault-kms" +) + +var ( + ServiceNames = ServiceNameList{ + MariaDB, + MySQL, + PostgreSQL, + Redis, + RedisPersistent, + Memcached, + OpenSearch, + Solr, + Varnish, + Kafka, + VaultKMS, + RabbitMQ, + InfluxDB, + ChromeHeadless, + NetworkStorage, + OracleMySQL, + } +) + +type ServiceName string + +func (s ServiceName) String() string { + return string(s) +} + +func (s ServiceName) Title() string { + switch s { + case ChromeHeadless: + return "Chrome Headless" + case InfluxDB: + return "InfluxDB" + case Kafka: + return "Kafka" + case MariaDB: + return "MariaDB" + case Memcached: + return "Memcached" + case MySQL: + return "MySQL" + case NetworkStorage: + return "Network Storage" + case OpenSearch: + return "OpenSearch" + case OracleMySQL: + return "Oracle MySQL" + case PostgreSQL: + return "PostgreSQL" + case RabbitMQ: + return "RabbitMQ" + case Redis: + return "Redis" + case RedisPersistent: + return "Redis Persistent" + case Solr: + return "Solr" + case Varnish: + return "Varnish" + case VaultKMS: + return "Vault KMS" + default: + return "" + } +} + +func (s ServiceName) IsPersistent() bool { + switch s { + case ChromeHeadless, Memcached, Redis: + return false + default: + return true + } +} + +type ServiceNameList []ServiceName + +func (s *ServiceNameList) WriteAnswer(_ string, value interface{}) error { + switch answer := value.(type) { + case []survey.OptionAnswer: // MultiSelect + for _, item := range answer { + service, err := ServiceNames.ServiceByTitle(item.Value) + if err != nil { + return err + } + *s = append(*s, service) + } + default: + return fmt.Errorf("unsupported type") + } + return nil +} + +func (s *ServiceNameList) AllTitles() []string { + titles := make([]string, 0, len(*s)) + for _, service := range *s { + titles = append(titles, service.Title()) + } + return titles +} + +func (s *ServiceNameList) ServiceByTitle(title string) (ServiceName, error) { + for _, service := range *s { + if service.Title() == title { + return service, nil + } + } + return "", fmt.Errorf("service name by title is not found") +} diff --git a/internal/platformify/question/models/socket_family.go b/internal/platformify/question/models/socket_family.go new file mode 100644 index 000000000..f40688c98 --- /dev/null +++ b/internal/platformify/question/models/socket_family.go @@ -0,0 +1,23 @@ +package models + +const ( + TCP SocketFamily = "tcp" + UnixSocket SocketFamily = "unix" +) + +type SocketFamily string + +func (i SocketFamily) String() string { + return string(i) +} + +func (i SocketFamily) Title() string { + switch i { + case TCP: + return "TCP Port" + case UnixSocket: + return "Unix-socket" + default: + return "" + } +} diff --git a/internal/platformify/question/models/stack.go b/internal/platformify/question/models/stack.go new file mode 100644 index 000000000..06783f499 --- /dev/null +++ b/internal/platformify/question/models/stack.go @@ -0,0 +1,104 @@ +package models + +import ( + "fmt" + + "github.com/AlecAivazis/survey/v2" +) + +const ( + GenericStack Stack = iota + Django + Laravel + NextJS + Strapi + Flask + Express + Rails +) + +var ( + Stacks = StackList{ + GenericStack, + Django, + Laravel, + NextJS, + Strapi, + Flask, + Express, + Rails, + } +) + +type Stack int + +func (s Stack) Title() string { + switch s { + case GenericStack: + return "Other" + case Django: + return "Django" + case Rails: + return "Rails" + case Laravel: + return "Laravel" + case NextJS: + return "Next.js" + case Strapi: + return "Strapi" + case Flask: + return "Flask" + case Express: + return "Express" + default: + return "" + } +} + +func (s *Stack) WriteAnswer(_ string, value interface{}) error { + switch answer := value.(type) { + case survey.OptionAnswer: // Select + stack, err := Stacks.StackByTitle(answer.Value) + if err != nil { + return err + } + *s = stack + default: + return fmt.Errorf("unsupported type") + } + return nil +} + +type StackList []Stack + +func (s StackList) AllTitles() []string { + titles := make([]string, 0, len(s)) + for _, stack := range s { + titles = append(titles, stack.Title()) + } + return titles +} + +func (s StackList) StackByTitle(title string) (Stack, error) { + for _, stack := range s { + if stack.Title() == title { + return stack, nil + } + } + return GenericStack, fmt.Errorf("stack by title is not found") +} + +func RuntimeForStack(stack Stack) Runtime { + switch stack { + case Django, Flask: + return Python + case Rails: + return Ruby + case Laravel: + return PHP + case NextJS, Strapi, Express: + return NodeJS + default: + return "" + } +} diff --git a/internal/platformify/question/models/version.go b/internal/platformify/question/models/version.go new file mode 100644 index 000000000..963030de2 --- /dev/null +++ b/internal/platformify/question/models/version.go @@ -0,0 +1,45 @@ +//go:generate go run generate_versions.go + +package models + +var ( + LanguageTypeVersions = map[Runtime][]string{ + DotNet: {"8.0", "7.0", "6.0"}, + Elixir: {"1.18", "1.15", "1.14"}, + Golang: {"1.25", "1.24", "1.23", "1.22", "1.21", "1.20"}, + Java: {"21", "19", "18", "17", "11", "8"}, + NodeJS: {"24", "22", "20"}, + PHP: {"8.4", "8.3", "8.2", "8.1"}, + Python: {"3.13", "3.12", "3.11", "3.10", "3.9", "3.8"}, + Ruby: {"3.4", "3.3", "3.2", "3.1", "3.0"}, + Rust: {"1"}, + } + + ServiceTypeVersions = map[ServiceName][]string{ + ChromeHeadless: {"120", "113", "95", "91"}, + ClickHouse: {"25.3", "24.3", "23.8"}, + InfluxDB: {"2.7", "2.3"}, + Kafka: {"3.7", "3.6", "3.4", "3.2"}, + MariaDB: {"11.8", "11.4", "10.11", "10.6"}, + Memcached: {"1.6", "1.5", "1.4"}, + MySQL: {"11.8", "11.4", "10.11", "10.6"}, + NetworkStorage: {"1.0"}, + OpenSearch: {"3", "2"}, + OracleMySQL: {"8.0", "5.7"}, + PostgreSQL: {"18", "17", "16", "15", "14", "13", "12"}, + RabbitMQ: {"4.1", "4.0", "3.13", "3.12"}, + Redis: {"8.0", "7.2"}, + RedisPersistent: {"8.0", "7.2"}, + Solr: {"9.9", "9.6", "9.4", "9.2", "9.1", "8.11"}, + Varnish: {"7.6", "7.3", "7.2", "6.0"}, + VaultKMS: {"1.12"}, + } +) + +func DefaultVersionForRuntime(r Runtime) string { + versions := LanguageTypeVersions[r] + if len(versions) == 0 { + return "" + } + return versions[0] +} diff --git a/internal/platformify/question/mounts.go b/internal/platformify/question/mounts.go new file mode 100644 index 000000000..5d1bdc166 --- /dev/null +++ b/internal/platformify/question/mounts.go @@ -0,0 +1,85 @@ +package question + +import ( + "context" + + "github.com/upsun/cli/internal/platformify/question/models" +) + +type Mounts struct{} + +func (q *Mounts) Ask(ctx context.Context) error { + answers, ok := models.FromContext(ctx) + if !ok { + return nil + } + + switch answers.Stack { + case models.Rails: + answers.Disk = "2048" // in MB + answers.Mounts = map[string]map[string]string{ + "storage": { + "source": "local", + "source_path": "storage", + }, + "tmp": { + "source": "tmp", + "source_path": "tmp", + }, + "log": { + "source": "tmp", + "source_path": "tmp", + }, + } + case models.Laravel: + answers.Disk = "2048" // in MB + answers.Mounts = map[string]map[string]string{ + "storage": { + "source": "local", + "source_path": "storage", + }, + "bootstrap/cache": { + "source": "local", + "source_path": "cache", + }, + "/.config": { + "source": "local", + "source_path": "config", + }, + } + case models.NextJS: + answers.Disk = "512" // in MB + answers.Mounts = map[string]map[string]string{ + "/.npm": { + "source": "local", + "source_path": "npm", + }, + } + case models.Strapi: + answers.Disk = "1024" // in MB + answers.Mounts = map[string]map[string]string{ + "/.cache": { + "source": "local", + "source_path": "cache", + }, + "/.tmp": { + "source": "local", + "source_path": "app", + }, + "database": { + "source": "local", + "source_path": "database", + }, + "extensions": { + "source": "local", + "source_path": "extensions", + }, + "public/uploads": { + "source": "local", + "source_path": "uploads", + }, + } + } + + return nil +} diff --git a/internal/platformify/question/name.go b/internal/platformify/question/name.go new file mode 100644 index 000000000..20a35a940 --- /dev/null +++ b/internal/platformify/question/name.go @@ -0,0 +1,63 @@ +package question + +import ( + "context" + "fmt" + "path" + "regexp" + "strings" + + "github.com/AlecAivazis/survey/v2" + + "github.com/upsun/cli/internal/platformify/question/models" +) + +type Name struct{} + +var ( + invalidChars = regexp.MustCompile("[^a-z0-9_-]") + consecutiveDashes = regexp.MustCompile("-+") +) + +func (q *Name) Ask(ctx context.Context) error { + answers, ok := models.FromContext(ctx) + if !ok { + return nil + } + if answers.Name != "" { + // Skip the step + return nil + } + + question := &survey.Input{ + Message: "Tell us your project's application name:", Default: slugify(path.Base(answers.WorkingDirectory)), + } + + var name string + err := survey.AskOne(question, &name, survey.WithValidator(survey.Required), survey.WithValidator(validSlug)) + if err != nil { + return err + } + + answers.Name = name + + return nil +} + +func slugify(s string) string { + return consecutiveDashes.ReplaceAllLiteralString( + invalidChars.ReplaceAllLiteralString(strings.ToLower( + strings.TrimSpace(s), + ), "-"), "-") +} + +func validSlug(val interface{}) error { + if val.(string) != slugify(val.(string)) { + return fmt.Errorf( + "%s: the name can only contain lowercase alphanumeric characters, dashes, or underscores", + val.(string), + ) + } + + return nil +} diff --git a/internal/platformify/question/services.go b/internal/platformify/question/services.go new file mode 100644 index 000000000..1389fe667 --- /dev/null +++ b/internal/platformify/question/services.go @@ -0,0 +1,91 @@ +package question + +import ( + "context" + "fmt" + "strings" + + "github.com/AlecAivazis/survey/v2" + + "github.com/upsun/cli/internal/platformify/colors" + "github.com/upsun/cli/internal/platformify/question/models" +) + +type Services struct{} + +func (q *Services) Ask(ctx context.Context) error { + answers, ok := models.FromContext(ctx) + if !ok { + return nil + } + if len(answers.Services) != 0 { + // Skip the step + return nil + } + + out, _, ok := colors.FromContext(ctx) + if !ok { + return nil + } + fmt.Fprintln( + out, + colors.Colorize( + colors.AccentCode, + "Last but not least, unless you’re creating a static website, your project uses services. Let’s define them:", + ), + ) + fmt.Fprintln(out) + + question := &survey.MultiSelect{ + Message: "Select all the services you are using:", + Options: models.ServiceNames.AllTitles(), + } + + var services models.ServiceNameList + for { + if err := survey.AskOne(question, &services, survey.WithKeepFilter(true)); err != nil { + return err + } + + if len(services) > 0 { + break + } + + confirmQuestion := &survey.Confirm{ + Message: "You have not selected any service, would you like to proceed anyway?", + Default: false, + } + proceed := false + if err := survey.AskOne(confirmQuestion, &proceed); err != nil { + return err + } + + if proceed { + break + } + } + + for _, serviceName := range services { + versions, ok := models.ServiceTypeVersions[serviceName] + if !ok || len(versions) == 0 { + return nil + } + + service := models.Service{ + Name: strings.ReplaceAll(serviceName.String(), "_", "-"), + Type: models.ServiceType{ + Name: serviceName.String(), + Version: versions[0], + }, + TypeVersions: versions, + } + if serviceName.IsPersistent() { + service.Disk = models.ServiceDisks[0] + service.DiskSizes = models.ServiceDisks + } + + answers.Services = append(answers.Services, service) + } + + return nil +} diff --git a/internal/platformify/question/socket_family.go b/internal/platformify/question/socket_family.go new file mode 100644 index 000000000..6953c40ef --- /dev/null +++ b/internal/platformify/question/socket_family.go @@ -0,0 +1,27 @@ +package question + +import ( + "context" + + "github.com/upsun/cli/internal/platformify/question/models" +) + +type SocketFamily struct{} + +func (q *SocketFamily) Ask(ctx context.Context) error { + answers, ok := models.FromContext(ctx) + if !ok { + return nil + } + + switch answers.Type.Runtime { + case models.PHP: + return nil + case models.Ruby, models.Python: + answers.SocketFamily = models.UnixSocket + return nil + default: + answers.SocketFamily = models.TCP + return nil + } +} diff --git a/internal/platformify/question/stack.go b/internal/platformify/question/stack.go new file mode 100644 index 000000000..98878a04f --- /dev/null +++ b/internal/platformify/question/stack.go @@ -0,0 +1,218 @@ +package question + +import ( + "context" + "fmt" + "os" + "slices" + "strings" + + "github.com/AlecAivazis/survey/v2" + + "github.com/upsun/cli/internal/platformify/colors" + "github.com/upsun/cli/internal/platformify/question/models" + "github.com/upsun/cli/internal/platformify/questionnaire" + "github.com/upsun/cli/internal/platformify/utils" + "github.com/upsun/cli/internal/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 { + answers, ok := models.FromContext(ctx) + if !ok { + return nil + } + + defer func() { + _, stderr, ok := colors.FromContext(ctx) + if !ok { + return + } + if answers.Stack != models.GenericStack { + fmt.Fprintf( + stderr, + "%s %s\n", + colors.Colorize(colors.GreenCode, "✓"), + colors.Colorize( + colors.BrandCode, + fmt.Sprintf("Detected stack: %s", answers.Stack.Title()), + ), + ) + } + }() + + answers.Stack = models.GenericStack + + hasSettingsPy := utils.FileExists(answers.WorkingDirectory, settingsPyFile) + hasManagePy := utils.FileExists(answers.WorkingDirectory, managePyFile) + if hasSettingsPy && hasManagePy { + answers.Stack = models.Django + return nil + } + + rackPath := utils.FindFile(answers.WorkingDirectory, rackFile) + if rackPath != "" { + f, err := os.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 := os.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([]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([]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([]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([]string{"dependencies", "next"}, packageJSONPath, true); ok { + answers.Stack = models.NextJS + return nil + } + + if _, ok := utils.GetJSONValue([]string{"dependencies", "@strapi/strapi"}, packageJSONPath, true); ok { + answers.Stack = models.Strapi + return nil + } + + if _, ok := utils.GetJSONValue([]string{"dependencies", "strapi"}, packageJSONPath, true); ok { + answers.Stack = models.Strapi + return nil + } + + if _, ok := utils.GetJSONValue([]string{"dependencies", "express"}, packageJSONPath, true); ok { + answers.Stack = models.Express + return nil + } + } + + hasSymfonyLock := utils.FileExists(answers.WorkingDirectory, symfonyLockFile) + hasSymfonyBundle := false + hasIbexaDependencies := false + hasShopwareDependencies := false + for _, composerJSONPath := range composerJSONPaths { + if _, ok := utils.GetJSONValue([]string{"autoload", "psr-0", "shopware"}, composerJSONPath, true); ok { + hasShopwareDependencies = true + break + } + if _, ok := utils.GetJSONValue([]string{"autoload", "psr-4", "shopware\\core\\"}, composerJSONPath, true); ok { + hasShopwareDependencies = true + break + } + if _, ok := utils.GetJSONValue([]string{"autoload", "psr-4", "shopware\\appbundle\\"}, composerJSONPath, true); ok { + hasShopwareDependencies = true + break + } + + if keywords, ok := utils.GetJSONValue([]string{"keywords"}, composerJSONPath, true); ok { + if keywordsVal, ok := keywords.([]string); ok && slices.Contains(keywordsVal, "shopware") { + hasShopwareDependencies = true + break + } + } + if requirements, ok := utils.GetJSONValue([]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 + } + } + } + } + } + + 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, + ), + ), + ) + return questionnaire.ErrSilent + } + } + + return nil +} diff --git a/internal/platformify/question/type.go b/internal/platformify/question/type.go new file mode 100644 index 000000000..ff206b1fe --- /dev/null +++ b/internal/platformify/question/type.go @@ -0,0 +1,66 @@ +package question + +import ( + "context" + "fmt" + + "github.com/AlecAivazis/survey/v2" + + "github.com/upsun/cli/internal/platformify/colors" + "github.com/upsun/cli/internal/platformify/question/models" +) + +type Type struct{} + +func (q *Type) Ask(ctx context.Context) error { + answers, ok := models.FromContext(ctx) + if !ok { + return nil + } + if answers.Type.String() != "" { + // Skip the step + return nil + } + + defer func() { + _, stderr, ok := colors.FromContext(ctx) + if !ok { + return + } + + if answers.Stack != models.GenericStack { + fmt.Fprintf( + stderr, + "%s %s\n", + colors.Colorize(colors.GreenCode, "✓"), + colors.Colorize( + colors.BrandCode, + fmt.Sprintf("Detected runtime: %s", answers.Type.Runtime.Title()), + ), + ) + } + }() + + runtime := models.RuntimeForStack(answers.Stack) + if runtime == "" { + question := &survey.Select{ + Message: "What language is your project using? We support the following:", + Options: models.Runtimes.AllTitles(), + } + + var title string + err := survey.AskOne(question, &title, survey.WithPageSize(len(question.Options))) + if err != nil { + return err + } + + runtime, err = models.Runtimes.RuntimeByTitle(title) + if err != nil { + return err + } + } + answers.Type.Runtime = runtime + answers.Type.Version = models.DefaultVersionForRuntime(runtime) + + return nil +} diff --git a/internal/platformify/question/web_command.go b/internal/platformify/question/web_command.go new file mode 100644 index 000000000..7cf827f0a --- /dev/null +++ b/internal/platformify/question/web_command.go @@ -0,0 +1,165 @@ +package question + +import ( + "context" + "fmt" + "os" + "path" + "path/filepath" + "slices" + "strings" + + "github.com/upsun/cli/internal/platformify/question/models" + "github.com/upsun/cli/internal/platformify/utils" + "github.com/upsun/cli/internal/platformify/vendorization" +) + +type WebCommand struct{} + +func (q *WebCommand) Ask(ctx context.Context) error { + answers, ok := models.FromContext(ctx) + if !ok { + return nil + } + if answers.WebCommand != "" { + // Skip the step + return nil + } + + // Do not ask the command for PHP applications + if answers.Type.Runtime == models.PHP { + return nil + } + + assets, _ := vendorization.FromContext(ctx) + + //nolint:lll + answers.WebCommand = fmt.Sprintf( + "echo 'Put your web server command in here! You need to listen to \"$UNIX\" unix socket. Read more about it here: %s#web-commands'; sleep 60", + assets.Docs().AppReference, + ) + + if answers.SocketFamily == models.TCP { + //nolint:lll + answers.WebCommand = fmt.Sprintf( + "echo 'Put your web server command in here! You need to listen to \"$PORT\" port. Read more about it here: %s#web-commands'; sleep 60", + + assets.Docs().AppReference, + ) + } + + switch answers.Stack { + case models.Django: + prefix := "" + pythonPath := "" + wsgi := "app.wsgi" + // try to find the wsgi.py file to change the default command + wsgiPath := utils.FindFile(path.Join(answers.WorkingDirectory, answers.ApplicationRoot), "wsgi.py") + if wsgiPath != "" { + wsgiParentDir := path.Base(path.Dir(wsgiPath)) + wsgi = fmt.Sprintf("%s.wsgi", wsgiParentDir) + + // add the pythonpath if the wsgi.py file is not in the root of the app + wsgiRel, _ := filepath.Rel( + path.Join(answers.WorkingDirectory, answers.ApplicationRoot), + path.Dir(path.Dir(wsgiPath)), + ) + if wsgiRel != "." { + pythonPath = "--pythonpath=" + path.Base(path.Dir(path.Dir(wsgiPath))) + } + } + + if slices.Contains(answers.DependencyManagers, models.Pipenv) { + prefix = "pipenv run " + } else if slices.Contains(answers.DependencyManagers, models.Poetry) { + prefix = "poetry run " + } + if answers.SocketFamily == models.TCP { + answers.WebCommand = fmt.Sprintf("%sgunicorn %s -b 0.0.0.0:$PORT %s --log-file -", prefix, pythonPath, wsgi) + return nil + } + + answers.WebCommand = fmt.Sprintf("%sgunicorn %s -b unix:$SOCKET %s --log-file -", prefix, pythonPath, wsgi) + return nil + case models.Rails: + if answers.SocketFamily == models.TCP { + answers.WebCommand = "bundle exec rails server" + return nil + } + + answers.WebCommand = "bundle exec puma -b unix://$SOCKET" + return nil + case models.NextJS: + answers.WebCommand = "npx next start -p $PORT" + return nil + case models.Strapi: + if _, ok := utils.GetJSONValue( + []string{"scripts", "start"}, + path.Join(answers.WorkingDirectory, "package.json"), + true, + ); ok { + if slices.Contains(answers.DependencyManagers, models.Yarn) { + answers.WebCommand = "NODE_ENV=production yarn start" + } else { + answers.WebCommand = "NODE_ENV=production npm start" + } + } + case models.Express: + if _, ok := utils.GetJSONValue( + []string{"scripts", "start"}, + path.Join(answers.WorkingDirectory, "package.json"), + true, + ); ok { + if slices.Contains(answers.DependencyManagers, models.Yarn) { + answers.WebCommand = "NODE_ENV=production yarn start" + } else { + answers.WebCommand = "NODE_ENV=production npm start" + } + return nil + } + + if mainPath, ok := utils.GetJSONValue( + []string{"main"}, + path.Join(answers.WorkingDirectory, "package.json"), + true, + ); ok { + answers.WebCommand = fmt.Sprintf("node %s", mainPath.(string)) + return nil + } + + if indexFile := utils.FindFile(answers.WorkingDirectory, "index.js"); indexFile != "" { + indexFile, _ = filepath.Rel(answers.WorkingDirectory, indexFile) + answers.WebCommand = fmt.Sprintf("node %s", indexFile) + return nil + } + case models.Flask: + appPath := "" + // try to find the app.py, api.py or server.py files + for _, name := range []string{"app.py", "server.py", "api.py"} { + if _, err := os.Stat(path.Join(answers.WorkingDirectory, name)); err == nil { + appPath = fmt.Sprintf("'%s:app'", strings.TrimSuffix(name, ".py")) + break + } + } + if appPath == "" { + return nil + } + + prefix := "" + if slices.Contains(answers.DependencyManagers, models.Pipenv) { + prefix = "pipenv run " + } else if slices.Contains(answers.DependencyManagers, models.Poetry) { + prefix = "poetry run " + } + + if answers.SocketFamily == models.TCP { + answers.WebCommand = fmt.Sprintf("%sgunicorn -b 0.0.0.0:$PORT %s --log-file -", prefix, appPath) + return nil + } + + answers.WebCommand = fmt.Sprintf("%sgunicorn -b unix:$SOCKET %s --log-file -", prefix, appPath) + return nil + } + + return nil +} diff --git a/internal/platformify/question/welcome.go b/internal/platformify/question/welcome.go new file mode 100644 index 000000000..5546f6b7d --- /dev/null +++ b/internal/platformify/question/welcome.go @@ -0,0 +1,29 @@ +package question + +import ( + "context" + "fmt" + + "github.com/upsun/cli/internal/platformify/colors" + "github.com/upsun/cli/internal/platformify/vendorization" +) + +type Welcome struct{} + +func (q *Welcome) Ask(ctx context.Context) error { + out, _, ok := colors.FromContext(ctx) + if !ok { + return nil + } + + assets, _ := vendorization.FromContext(ctx) + fmt.Fprintln( + out, + colors.Colorize(colors.BrandCode, fmt.Sprintf("Welcome to %s!", assets.ServiceName)), + ) + fmt.Fprintln(out, colors.Colorize(colors.BrandCode, "Let's get started with a few questions.")) + fmt.Fprintln(out) + fmt.Fprintln(out, "We need to know a bit more about your project. This will only take a minute!") + fmt.Fprintln(out) + return nil +} diff --git a/internal/platformify/question/working_directory.go b/internal/platformify/question/working_directory.go new file mode 100644 index 000000000..3044037ae --- /dev/null +++ b/internal/platformify/question/working_directory.go @@ -0,0 +1,93 @@ +package question + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path" + + "github.com/AlecAivazis/survey/v2" + + "github.com/upsun/cli/internal/platformify/colors" + "github.com/upsun/cli/internal/platformify/question/models" + "github.com/upsun/cli/internal/platformify/vendorization" +) + +type WorkingDirectory struct{} + +func (q *WorkingDirectory) Ask(ctx context.Context) error { + _, stderr, ok := colors.FromContext(ctx) + if !ok { + return nil + } + + cwd, err := os.Getwd() + if err != nil { + return err + } + answers, ok := models.FromContext(ctx) + if !ok { + return nil + } + answers.WorkingDirectory = cwd + answers.HasGit = false + + var outBuf, errBuf bytes.Buffer + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-dir") + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + err = cmd.Run() + if err != nil { + fmt.Fprintln( + stderr, + colors.Colorize( + colors.WarningCode, + "It seems like you're not inside a Git repository.", + ), + ) + assets, _ := vendorization.FromContext(ctx) + fmt.Fprintln( + stderr, + colors.Colorize( + colors.WarningCode, + fmt.Sprintf( + "You'll need to create a Git repository to deploy your application to %s", + assets.ServiceName, + ), + ), + ) + return nil + } + + gitRepoAbsPath := path.Dir(outBuf.String()) + answers.HasGit = gitRepoAbsPath == "." + if !answers.HasGit { + fmt.Fprintln( + stderr, + colors.Colorize( + colors.WarningCode, + "Project configuration should be run at the root of a Git repository.", + ), + ) + msg := fmt.Sprintf( + "Would you like to change directory to %s instead?", + gitRepoAbsPath, + ) + proceed := true + if err := survey.AskOne(&survey.Confirm{ + Message: msg, + Default: proceed, + }, &proceed); err != nil { + return nil + } + + if proceed { + answers.WorkingDirectory = gitRepoAbsPath + answers.HasGit = true + } + } + + return nil +} diff --git a/internal/platformify/questionnaire/errors.go b/internal/platformify/questionnaire/errors.go new file mode 100644 index 000000000..779ff13b5 --- /dev/null +++ b/internal/platformify/questionnaire/errors.go @@ -0,0 +1,11 @@ +package questionnaire + +import ( + "errors" + "fmt" +) + +var ( + ErrSilent = errors.New("silent error") + ErrUserAborted = fmt.Errorf("user aborted: %w", ErrSilent) +) diff --git a/internal/platformify/questionnaire/questionnaire.go b/internal/platformify/questionnaire/questionnaire.go new file mode 100644 index 000000000..6292a3079 --- /dev/null +++ b/internal/platformify/questionnaire/questionnaire.go @@ -0,0 +1,29 @@ +package questionnaire + +import ( + "context" +) + +type Asker interface { + Ask(ctx context.Context) error +} + +type Questionnaire struct { + questions []Asker +} + +func New(questions ...Asker) *Questionnaire { + return &Questionnaire{ + questions: questions, + } +} + +func (i *Questionnaire) AskQuestions(ctx context.Context) error { + for _, question := range i.questions { + err := question.Ask(ctx) + if err != nil { + return err + } + } + return nil +} diff --git a/internal/platformify/utils/utils.go b/internal/platformify/utils/utils.go new file mode 100644 index 000000000..d83b378ea --- /dev/null +++ b/internal/platformify/utils/utils.go @@ -0,0 +1,173 @@ +package utils + +import ( + "bufio" + "bytes" + "cmp" + "encoding/json" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +var skipDirs = []string{ + "vendor", + "node_modules", + ".next", + ".git", +} + +// FileExists checks if the file exists +func FileExists(searchPath, name string) bool { + return FindFile(searchPath, name) != "" +} + +// FindFile searches for the file inside the path recursively +// and returns the full path of the file if found +// If multiple files exist, tries to return the one closest to root +func FindFile(searchPath, name string) string { + files := FindAllFiles(searchPath, name) + if len(files) == 0 { + return "" + } + + slices.SortFunc(files, func(a, b string) int { + return cmp.Compare(strings.Count(a, string(os.PathSeparator)), strings.Count(b, string(os.PathSeparator))) + }) + return files[0] +} + +// FindAllFiles searches for the file inside the path recursively and returns all matches +func FindAllFiles(searchPath, name string) []string { + found := make([]string, 0) + _ = filepath.WalkDir(searchPath, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + // Skip vendor directories + if slices.Contains(skipDirs, d.Name()) { + return filepath.SkipDir + } + return nil + } + + if d.Name() == name { + found = append(found, p) + } + + return nil + }) + + return found +} + +func GetMapValue(keyPath []string, data map[string]interface{}) (value interface{}, ok bool) { + if len(keyPath) == 0 { + return data, true + } + + for _, key := range keyPath[:len(keyPath)-1] { + if value, ok = data[key]; !ok { + return nil, false + } + + if data, ok = value.(map[string]interface{}); !ok { + return nil, false + } + } + + if value, ok = data[keyPath[len(keyPath)-1]]; !ok { + return nil, false + } + + return value, true +} + +// GetJSONValue gets a value from a JSON file, by traversing the path given +func GetJSONValue(keyPath []string, filePath string, caseInsensitive bool) (value interface{}, ok bool) { + fin, err := os.Open(filePath) + if err != nil { + return nil, false + } + defer fin.Close() + + rawData, err := io.ReadAll(fin) + if err != nil { + return nil, false + } + + if caseInsensitive { + rawData = bytes.ToLower(rawData) + for i := range keyPath { + keyPath[i] = strings.ToLower(keyPath[i]) + } + } + + var data map[string]interface{} + err = json.Unmarshal(rawData, &data) + if err != nil { + return nil, false + } + + return GetMapValue(keyPath, data) +} + +// ContainsStringInFile checks if the given file contains the given string +func ContainsStringInFile(file io.Reader, target string, caseInsensitive bool) (bool, error) { + if caseInsensitive { + target = strings.ToLower(target) + } + scanner := bufio.NewScanner(file) + for scanner.Scan() { + if caseInsensitive { + if strings.Contains(strings.ToLower(scanner.Text()), target) { + return true, nil + } + } else { + if strings.Contains(scanner.Text(), target) { + return true, nil + } + } + } + + if err := scanner.Err(); err != nil { + return false, err + } + + return false, nil +} + +// GetTOMLValue gets a value from a TOML file, by traversing the path given +func GetTOMLValue(keyPath []string, filePath string, caseInsensitive bool) (value interface{}, ok bool) { + fin, err := os.Open(filePath) + if err != nil { + return nil, false + } + defer fin.Close() + + rawData, err := io.ReadAll(fin) + if err != nil { + return nil, false + } + + if caseInsensitive { + rawData = bytes.ToLower(rawData) + for i := range keyPath { + keyPath[i] = strings.ToLower(keyPath[i]) + } + } + + var data map[string]interface{} + err = toml.Unmarshal(rawData, &data) + if err != nil { + return nil, false + } + + return GetMapValue(keyPath, data) +} diff --git a/internal/platformify/vendorization/vendorization.go b/internal/platformify/vendorization/vendorization.go new file mode 100644 index 000000000..55de75b4c --- /dev/null +++ b/internal/platformify/vendorization/vendorization.go @@ -0,0 +1,87 @@ +package vendorization + +import ( + "context" + "fmt" +) + +type vendorAssetsKey string + +var key vendorAssetsKey = "vendorAssets" + +type Docs struct { + AppReference string + GettingStarted string + Hooks string + PHP string + Routes string + Services string + SymfonyCLI string + TimeZone string + Variables string +} + +type VendorAssets struct { + Binary string + ConfigFlavor string + DocsBaseURL string + EnvPrefix string + ServiceName string + Use string +} + +func (va *VendorAssets) ProprietaryFiles() []string { + if va.ConfigFlavor == "upsun" { + return []string{ + ".environment", + ".upsun/config.yaml", + } + } + + return []string{ + ".environment", + ".platform.app.yaml", + ".platform/services.yaml", + ".platform/routes.yaml", + ".platform/applications.yaml", + } +} + +func (va *VendorAssets) Docs() *Docs { + return &Docs{ + AppReference: fmt.Sprintf("%s/app/reference/", va.DocsBaseURL), + GettingStarted: fmt.Sprintf("%s/stacks/symfony/", va.DocsBaseURL), + Hooks: fmt.Sprintf("%s/app/hooks/compare/", va.DocsBaseURL), + PHP: fmt.Sprintf("%s/languages/php/", va.DocsBaseURL), + Routes: fmt.Sprintf("%s/routes/", va.DocsBaseURL), + Services: fmt.Sprintf("%s/services/", va.DocsBaseURL), + SymfonyCLI: fmt.Sprintf("%s/stacks/symfony/cli/tips/", va.DocsBaseURL), + TimeZone: fmt.Sprintf("%s/app/timezone/", va.DocsBaseURL), + Variables: fmt.Sprintf("%s/variables/use/provided/", va.DocsBaseURL), + } +} + +func defaults() *VendorAssets { + // Return all values as DEFAULT VALUE key + return &VendorAssets{ + Binary: "DEFAULT VALUE BINARY", + ConfigFlavor: "DEFAULT VALUE CONFIGFLAVOR", + DocsBaseURL: "DEFAULT VALUE DOCS BASE URL", + EnvPrefix: "DEFAULT VALUE ENVPREFIX", + ServiceName: "DEFAULT VALUE SERVICENAME", + Use: "DEFAULT VALUE USE", + } +} + +func FromContext(ctx context.Context) (*VendorAssets, bool) { + assets, ok := ctx.Value(key).(*VendorAssets) + if !ok { + return defaults(), false + } + + return assets, ok +} + +func WithVendorAssets(ctx context.Context, assets *VendorAssets) context.Context { + return context.WithValue(ctx, key, assets) +} From 9a23163c24236d1066cbbf5f3bfae87e69c7f9df Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Sun, 18 Oct 2026 14:46:41 +0100 Subject: [PATCH 2/4] refactor(init): simplify the guided wizard into one package Merge the imported Platformify packages into internal/platformify, and pass the wizard's state explicitly instead of through context values. - Replace vendorization with values from the CLI config, which removes the vendor assets from the root command. - Write to the Git root that init already finds, instead of asking to change directory there. Drop the message for projects outside Git, as init requires a repository. - Render templates into a map of files and write them afterwards, which replaces the file system interface and the generated mocks. - Take runtime and service versions from pkg/registry, instead of a generated list from the docs registry. - Remove the no-op Next.js generator and its missing template paths, and the unused dependency managers in the template input. Fixes: - Keep the case of values read from package.json, e.g. "main". - Detect the "shopware" composer keyword, which was never matched. - Skip a service without known versions instead of dropping the rest. - Use $SOCKET in the placeholder web command, not $UNIX. - Use a slash-separated application root on Windows. Template output is unchanged for the existing fixtures (compared before and after). New tests cover stack detection and generated files, which are checked with pkg/lint for Flex and Fixed configuration. Refs CLI-184. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +- commands/auth_test.go | 6 +- commands/completion_test.go | 4 +- commands/init.go | 13 +- commands/lint_test.go | 4 +- commands/list_models_test.go | 2 +- commands/root.go | 19 +- go.mod | 1 - go.sum | 14 - internal/platformify/colors.go | 35 ++ internal/platformify/colors/colors.go | 48 -- internal/platformify/colors/context.go | 33 - internal/platformify/detect.go | 463 ++++++++++++++ internal/platformify/detect_test.go | 115 ++++ internal/platformify/files.go | 105 ++++ internal/platformify/fixtures_test.go | 114 ++++ internal/platformify/generate.go | 215 +++++++ internal/platformify/generate_test.go | 81 +++ internal/platformify/messages.go | 122 ++++ internal/platformify/models.go | 193 ++++++ internal/platformify/platformifier/django.go | 93 --- .../platformify/platformifier/django_test.go | 123 ---- internal/platformify/platformifier/fs.go | 90 --- .../platformify/platformifier/fs_mock_test.go | 80 --- internal/platformify/platformifier/generic.go | 65 -- .../platformify/platformifier/generic_test.go | 173 ----- internal/platformify/platformifier/laravel.go | 48 -- internal/platformify/platformifier/models.go | 130 ---- internal/platformify/platformifier/nextjs.go | 20 - .../platformifier/platformifier.go | 97 --- .../platformifier/platformifier_mock_test.go | 49 -- .../platformifier/platformifier_test.go | 595 ------------------ internal/platformify/platformify.go | 285 +++++++-- internal/platformify/question/almost_done.go | 23 - .../platformify/question/application_root.go | 51 -- internal/platformify/question/build_steps.go | 143 ----- .../platformify/question/build_steps_test.go | 88 --- .../question/dependency_manager.go | 86 --- .../platformify/question/deploy_command.go | 52 -- internal/platformify/question/done.go | 89 --- internal/platformify/question/environment.go | 30 - .../platformify/question/files_overwrite.go | 73 --- internal/platformify/question/locations.go | 43 -- .../platformify/question/models/answer.go | 179 ------ .../question/models/answer_test.go | 85 --- .../platformify/question/models/context.go | 24 - .../question/models/dependency_manager.go | 41 -- .../question/models/generate_versions.go | 288 --------- .../platformify/question/models/runtime.go | 81 --- .../question/models/service_disk.go | 42 -- .../question/models/service_name.go | 137 ---- .../question/models/socket_family.go | 23 - internal/platformify/question/models/stack.go | 104 --- .../platformify/question/models/version.go | 45 -- internal/platformify/question/mounts.go | 85 --- internal/platformify/question/name.go | 63 -- internal/platformify/question/services.go | 91 --- .../platformify/question/socket_family.go | 27 - internal/platformify/question/stack.go | 218 ------- internal/platformify/question/type.go | 66 -- internal/platformify/question/web_command.go | 165 ----- internal/platformify/question/welcome.go | 29 - .../platformify/question/working_directory.go | 93 --- internal/platformify/questionnaire/errors.go | 11 - .../questionnaire/questionnaire.go | 29 - .../platformify/{init_colors.go => survey.go} | 10 +- .../templates/django/settings_psh.py | 0 .../generic => templates/fixed}/.environment | 0 .../fixed}/.platform.app.yaml | 0 .../fixed}/.platform/routes.yaml | 0 .../fixed}/.platform/services.yaml | 0 .../upsun => templates/flex}/.environment | 0 .../flex}/.upsun/config.yaml | 0 internal/platformify/utils/utils.go | 173 ----- .../vendorization/vendorization.go | 87 --- 75 files changed, 1689 insertions(+), 4626 deletions(-) create mode 100644 internal/platformify/colors.go delete mode 100644 internal/platformify/colors/colors.go delete mode 100644 internal/platformify/colors/context.go create mode 100644 internal/platformify/detect.go create mode 100644 internal/platformify/detect_test.go create mode 100644 internal/platformify/files.go create mode 100644 internal/platformify/fixtures_test.go create mode 100644 internal/platformify/generate.go create mode 100644 internal/platformify/generate_test.go create mode 100644 internal/platformify/messages.go create mode 100644 internal/platformify/models.go delete mode 100644 internal/platformify/platformifier/django.go delete mode 100644 internal/platformify/platformifier/django_test.go delete mode 100644 internal/platformify/platformifier/fs.go delete mode 100644 internal/platformify/platformifier/fs_mock_test.go delete mode 100644 internal/platformify/platformifier/generic.go delete mode 100644 internal/platformify/platformifier/generic_test.go delete mode 100644 internal/platformify/platformifier/laravel.go delete mode 100644 internal/platformify/platformifier/models.go delete mode 100644 internal/platformify/platformifier/nextjs.go delete mode 100644 internal/platformify/platformifier/platformifier.go delete mode 100644 internal/platformify/platformifier/platformifier_mock_test.go delete mode 100644 internal/platformify/platformifier/platformifier_test.go delete mode 100644 internal/platformify/question/almost_done.go delete mode 100644 internal/platformify/question/application_root.go delete mode 100644 internal/platformify/question/build_steps.go delete mode 100644 internal/platformify/question/build_steps_test.go delete mode 100644 internal/platformify/question/dependency_manager.go delete mode 100644 internal/platformify/question/deploy_command.go delete mode 100644 internal/platformify/question/done.go delete mode 100644 internal/platformify/question/environment.go delete mode 100644 internal/platformify/question/files_overwrite.go delete mode 100644 internal/platformify/question/locations.go delete mode 100644 internal/platformify/question/models/answer.go delete mode 100644 internal/platformify/question/models/answer_test.go delete mode 100644 internal/platformify/question/models/context.go delete mode 100644 internal/platformify/question/models/dependency_manager.go delete mode 100644 internal/platformify/question/models/generate_versions.go delete mode 100644 internal/platformify/question/models/runtime.go delete mode 100644 internal/platformify/question/models/service_disk.go delete mode 100644 internal/platformify/question/models/service_name.go delete mode 100644 internal/platformify/question/models/socket_family.go delete mode 100644 internal/platformify/question/models/stack.go delete mode 100644 internal/platformify/question/models/version.go delete mode 100644 internal/platformify/question/mounts.go delete mode 100644 internal/platformify/question/name.go delete mode 100644 internal/platformify/question/services.go delete mode 100644 internal/platformify/question/socket_family.go delete mode 100644 internal/platformify/question/stack.go delete mode 100644 internal/platformify/question/type.go delete mode 100644 internal/platformify/question/web_command.go delete mode 100644 internal/platformify/question/welcome.go delete mode 100644 internal/platformify/question/working_directory.go delete mode 100644 internal/platformify/questionnaire/errors.go delete mode 100644 internal/platformify/questionnaire/questionnaire.go rename internal/platformify/{init_colors.go => survey.go} (84%) rename internal/platformify/{platformifier => }/templates/django/settings_psh.py (100%) rename internal/platformify/{platformifier/templates/generic => templates/fixed}/.environment (100%) rename internal/platformify/{platformifier/templates/generic => templates/fixed}/.platform.app.yaml (100%) rename internal/platformify/{platformifier/templates/generic => templates/fixed}/.platform/routes.yaml (100%) rename internal/platformify/{platformifier/templates/generic => templates/fixed}/.platform/services.yaml (100%) rename internal/platformify/{platformifier/templates/upsun => templates/flex}/.environment (100%) rename internal/platformify/{platformifier/templates/upsun => templates/flex}/.upsun/config.yaml (100%) delete mode 100644 internal/platformify/utils/utils.go delete mode 100644 internal/platformify/vendorization/vendorization.go diff --git a/CLAUDE.md b/CLAUDE.md index f29fbf478..50b91b45b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,9 +114,9 @@ The CLI operates as a wrapper around a legacy PHP CLI: - The `csv`, `tsv` and `plain` formats match the legacy output, with ANSI escape sequences removed - The `table` format wraps cells to the terminal width (a port of the legacy `AdaptiveTable`), and supports ANSI-styled cells -**Project Initialization**: `internal/init/` -- AI-powered project configuration generation -- Integrates with whatsun library for codebase analysis +**Project Initialization**: `internal/init/` and `internal/platformify/` +- `init` generates a starter configuration with AI (`internal/init`, using the whatsun library for codebase analysis), or with a guided wizard (`internal/platformify`, formerly the Platformify project), which also supports Fixed configuration +- The wizard detects the stack, asks a few questions, and renders `internal/platformify/templates` (`flex` or `fixed`, by `project_config_flavor`). Runtime and service versions come from `pkg/registry`, and tests check the output with `pkg/lint` ### Build System diff --git a/commands/auth_test.go b/commands/auth_test.go index b5022fca8..7da9123d6 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -9,8 +9,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/upsun/cli/internal/platformify/vendorization" ) func TestAuthCommands_Disabled(t *testing.T) { @@ -27,7 +25,7 @@ func TestAuthCommands_Disabled(t *testing.T) { func TestAuthCommands_LegacyGlobalFlags(t *testing.T) { t.Setenv("TEST_GO_AUTH", "1") - root := newRootCommand(testConfig(), &vendorization.VendorAssets{}) + root := newRootCommand(testConfig()) for _, args := range [][]string{ {"logout", "-n"}, {"auth:token", "-W", "--no-ansi"}, @@ -42,7 +40,7 @@ func TestAuthCommands_LegacyGlobalFlags(t *testing.T) { func TestAuthCommands_GoAuthDisabled(t *testing.T) { t.Setenv("TEST_GO_AUTH", "") assert.Empty(t, authCommands(testConfig())) - root := newRootCommand(testConfig(), &vendorization.VendorAssets{}) + root := newRootCommand(testConfig()) c, _, err := root.Find([]string{"auth:internal"}) require.NoError(t, err) assert.Equal(t, root, c, "auth:internal must not be registered") diff --git a/commands/completion_test.go b/commands/completion_test.go index 6cc1b96bc..950a4861a 100644 --- a/commands/completion_test.go +++ b/commands/completion_test.go @@ -11,8 +11,6 @@ import ( "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/upsun/cli/internal/platformify/vendorization" ) // TestCompletionScriptShellOption checks that the generated completion scripts @@ -78,7 +76,7 @@ func TestCompleteCommandPassesArgsThrough(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { cnf := testConfig() - root := newRootCommand(cnf, &vendorization.VendorAssets{Binary: cnf.Application.Executable}) + root := newRootCommand(cnf) root.SetOut(io.Discard) root.SetErr(io.Discard) diff --git a/commands/init.go b/commands/init.go index 2fe0c8861..0eeb5d02b 100644 --- a/commands/init.go +++ b/commands/init.go @@ -21,7 +21,6 @@ import ( "gopkg.in/yaml.v3" "github.com/upsun/cli/internal/platformify" - "github.com/upsun/cli/internal/platformify/vendorization" "github.com/upsun/cli/internal/legacy" @@ -31,7 +30,7 @@ import ( _init "github.com/upsun/cli/internal/init" ) -func newInitCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cobra.Command { +func newInitCommand(cnf *config.Config) *cobra.Command { var ( useAI bool initOptions = &_init.Options{} @@ -49,7 +48,7 @@ func newInitCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob " " + cnf.Application.Executable + " init --ai --context='Use PostgreSQL for the database'", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return runInitCommand(cmd, args, useAI, initOptions, assets) + return runInitCommand(cmd, args, useAI, initOptions) }, } @@ -82,7 +81,7 @@ func initCommandHelp(cnf *config.Config, short bool) string { // runInitCommand handles the main logic for the init command. func runInitCommand( - cmd *cobra.Command, args []string, useAI bool, initOptions *_init.Options, assets *vendorization.VendorAssets, + cmd *cobra.Command, args []string, useAI bool, initOptions *_init.Options, ) error { var path = "." if len(args) > 0 { @@ -105,12 +104,12 @@ func runInitCommand( return yaml.NewEncoder(cmd.OutOrStdout()).Encode(dg) } + cnf := config.FromContext(cmd.Context()) + runNonAIConfig := func() error { - return platformify.Platformify(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), assets) + return platformify.Run(cnf, platformify.Options{Dir: gitRoot, Stdout: cmd.OutOrStdout(), Stderr: cmd.ErrOrStderr()}) } - cnf := config.FromContext(cmd.Context()) - ia, err := newInitAuth(cmd, cnf) if err != nil { return err diff --git a/commands/lint_test.go b/commands/lint_test.go index 8a9171cd6..001ccda6c 100644 --- a/commands/lint_test.go +++ b/commands/lint_test.go @@ -14,8 +14,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/upsun/cli/internal/platformify/vendorization" - "github.com/upsun/cli/internal/config" "github.com/upsun/cli/pkg/lint" ) @@ -208,7 +206,7 @@ func TestLintCommand_Registration(t *testing.T) { cnf := testConfig() cnf.Service.ProjectConfigDir = c.configDir cnf.Service.AppConfigFile = c.appFile - root := newRootCommand(cnf, &vendorization.VendorAssets{}) + root := newRootCommand(cnf) cmd, _, err := root.Find([]string{"lint"}) require.NoError(t, err) assert.Equal(t, c.want, cmd != root) diff --git a/commands/list_models_test.go b/commands/list_models_test.go index 3cbab96f9..cabcf8a73 100644 --- a/commands/list_models_test.go +++ b/commands/list_models_test.go @@ -38,7 +38,7 @@ func TestHelpPageExamples(t *testing.T) { }, { name: "init", - cmd: commandFromCobra(cnf, newInitCommand(cnf, nil)), + cmd: commandFromCobra(cnf, newInitCommand(cnf)), expected: `Examples: Create the starter YAML file(s) for your project: upsun init diff --git a/commands/root.go b/commands/root.go index 729d7f8a3..e9cf09542 100644 --- a/commands/root.go +++ b/commands/root.go @@ -17,8 +17,6 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/upsun/cli/internal/platformify/vendorization" - "github.com/upsun/cli/internal" "github.com/upsun/cli/internal/config" "github.com/upsun/cli/internal/config/alt" @@ -27,17 +25,8 @@ import ( // Execute is the main entrypoint to run the CLI. func Execute(cnf *config.Config) error { - assets := &vendorization.VendorAssets{ - Use: "project:init", - Binary: cnf.Application.Executable, - ConfigFlavor: cnf.Service.ProjectConfigFlavor, - EnvPrefix: strings.TrimSuffix(cnf.Service.EnvPrefix, "_"), - ServiceName: cnf.Service.Name, - DocsBaseURL: cnf.Service.DocsURL, - } - - ctx := vendorization.WithVendorAssets(config.ToContext(context.Background(), cnf), assets) - cmd := newRootCommand(cnf, assets) + ctx := config.ToContext(context.Background(), cnf) + cmd := newRootCommand(cnf) if args, ok, err := expandAbbreviation(cmd, enabledLegacyCommands(cnf, legacy.Commands), os.Args[1:]); err != nil { debugLogf("Failed to load the legacy command index: %s", err) } else if ok { @@ -72,7 +61,7 @@ func waitForUpdateCheck() { updateCheck.Wait(updateCheckWait) } -func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cobra.Command { +func newRootCommand(cnf *config.Config) *cobra.Command { versionCommand := newVersionCommand(cnf) cmd := &cobra.Command{ Use: cnf.Application.Executable, @@ -183,7 +172,7 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob newConfigInstallCommand(), newCompletionCommand(cnf), newHelpCommand(cnf), - useLegacyStyleHelp(cnf, newInitCommand(cnf, assets)), + useLegacyStyleHelp(cnf, newInitCommand(cnf)), newListCommand(cnf), versionCommand, ) diff --git a/go.mod b/go.mod index 0d3763b32..1403abbdd 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,6 @@ require ( github.com/go-playground/validator/v10 v10.30.5 github.com/godbus/dbus/v5 v5.2.3 github.com/gofrs/flock v0.13.1 - github.com/golang/mock v1.6.0 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/oklog/ulid/v2 v2.1.2 github.com/pelletier/go-toml/v2 v2.3.0 diff --git a/go.sum b/go.sum index 9f81c961d..2d7af14ff 100644 --- a/go.sum +++ b/go.sum @@ -130,8 +130,6 @@ github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= -github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= -github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/google/cel-go v0.31.0 h1:H0bhpFTqOvmHrBGrWKp7ZlhBm5Hh8PYUEXnwxT1LL7A= github.com/google/cel-go v0.31.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -300,7 +298,6 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= @@ -314,21 +311,17 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= @@ -336,19 +329,15 @@ golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -368,12 +357,9 @@ golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo= google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw= google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= diff --git a/internal/platformify/colors.go b/internal/platformify/colors.go new file mode 100644 index 000000000..e496a2bed --- /dev/null +++ b/internal/platformify/colors.go @@ -0,0 +1,35 @@ +package platformify + +import ( + "os" + + "github.com/AlecAivazis/survey/v2/core" + "github.com/mgutz/ansi" +) + +// Colors, as names for survey templates and as ANSI codes. +var ( + colorBrand = "210" + colorAccent = "33" + colorSecondary = "247" + colorError = "red" + colorDefault = "default" + + brandCode = ansi.ColorCode(colorBrand) + accentCode = ansi.ColorCode(colorAccent) + greenCode = ansi.ColorCode("green") + warningCode = ansi.ColorCode("yellow") + resetCode = ansi.ColorCode("reset") +) + +func init() { + if v := os.Getenv("NO_COLOR"); v != "" && v != "0" { + brandCode, accentCode, greenCode, warningCode, resetCode = "", "", "", "", "" + core.DisableColor = true + } +} + +// colorize wraps s in the given ANSI color code. +func colorize(code, s string) string { + return code + s + resetCode +} diff --git a/internal/platformify/colors/colors.go b/internal/platformify/colors/colors.go deleted file mode 100644 index 459e6d1d8..000000000 --- a/internal/platformify/colors/colors.go +++ /dev/null @@ -1,48 +0,0 @@ -package colors - -import ( - "fmt" - "os" - - "github.com/AlecAivazis/survey/v2/core" - "github.com/mgutz/ansi" -) - -var ( - Brand = "210" - Accent = "33" - Secondary = "247" - Green = "green" - Warning = "yellow" - Error = "red" - Default = "default" - - BrandCode = ansi.ColorCode(Brand) - AccentCode = ansi.ColorCode(Accent) - SecondaryCode = ansi.ColorCode(Secondary) - GreenCode = ansi.ColorCode(Green) - WarningCode = ansi.ColorCode(Warning) - ErrorCode = ansi.ColorCode(Error) - DefaultCode = ansi.ColorCode(Default) - resetCode = ansi.ColorCode("reset") -) - -func init() { - // If NO_COLOR environment variable is set and is not 0, reset colors to "" - if os.Getenv("NO_COLOR") != "" && os.Getenv("NO_COLOR") != "0" { - BrandCode = "" - AccentCode = "" - SecondaryCode = "" - GreenCode = "" - WarningCode = "" - ErrorCode = "" - DefaultCode = "" - resetCode = "" - core.DisableColor = true - } -} - -// Colorize the given string with the given color code -func Colorize(code, str string) string { - return fmt.Sprintf("%s%s%s", code, str, resetCode) -} diff --git a/internal/platformify/colors/context.go b/internal/platformify/colors/context.go deleted file mode 100644 index 01fea1eef..000000000 --- a/internal/platformify/colors/context.go +++ /dev/null @@ -1,33 +0,0 @@ -package colors - -import ( - "context" - "io" -) - -// key is an unexported type for keys defined in this package. -// This prevents collisions with keys defined in other packages. -type key int - -const ( - outKey key = iota - errKey -) - -// ToContext returns a new Context that carries answers value. -func ToContext(ctx context.Context, out, err io.Writer) context.Context { - return context.WithValue(context.WithValue(ctx, outKey, out), errKey, err) -} - -// FromContext returns the out and err writers stored in ctx, if any. -func FromContext(ctx context.Context) (out, err io.Writer, ok bool) { - out, ok = ctx.Value(outKey).(io.Writer) - if !ok { - return nil, nil, false - } - err, ok = ctx.Value(errKey).(io.Writer) - if !ok { - return nil, nil, false - } - return out, err, true -} diff --git a/internal/platformify/detect.go b/internal/platformify/detect.go new file mode 100644 index 000000000..ca6c12390 --- /dev/null +++ b/internal/platformify/detect.go @@ -0,0 +1,463 @@ +package platformify + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" +) + +const ( + settingsPyFile = "settings.py" + managePyFile = "manage.py" + composerJSON = "composer.json" + packageJSON = "package.json" +) + +// detectStack sets the stack from the project's files. +func (w *wizard) detectStack() error { + w.answers.stack = w.findStack() + if w.answers.stack != genericStack { + w.detected("stack", w.answers.stack.Title()) + } + if w.answers.stack == genericStack && w.isSymfony() { + return w.suggestSymfonyCLI() + } + return nil +} + +func (w *wizard) findStack() stack { + dir := w.dir + if fileExists(dir, settingsPyFile) && fileExists(dir, managePyFile) { + return django + } + if p := findFile(dir, "config.ru"); p != "" && fileContains(p, "Rails.application.load_server") { + return rails + } + if p := findFile(dir, "requirements.txt"); p != "" && fileContains(p, "flask") { + return flask + } + if p := findFile(dir, "pyproject.toml"); p != "" { + if _, ok := tomlValue(p, "tool", "poetry", "dependencies", "flask"); ok { + return flask + } + } + if p := findFile(dir, "Pipfile"); p != "" { + if _, ok := tomlValue(p, "packages", "flask"); ok { + return flask + } + } + for _, p := range findAllFiles(dir, composerJSON) { + if _, ok := jsonValue(p, "require", "laravel/framework"); ok { + return laravel + } + } + for _, p := range findAllFiles(dir, packageJSON) { + if _, ok := jsonValue(p, "dependencies", "next"); ok { + return nextJS + } + if _, ok := jsonValue(p, "dependencies", "@strapi/strapi"); ok { + return strapi + } + if _, ok := jsonValue(p, "dependencies", "strapi"); ok { + return strapi + } + if _, ok := jsonValue(p, "dependencies", "express"); ok { + return express + } + } + return genericStack +} + +// isSymfony reports whether the project uses Symfony full-stack, and not Ibexa or Shopware. +func (w *wizard) isSymfony() bool { + isSymfony := fileExists(w.dir, "symfony.lock") + for _, p := range findAllFiles(w.dir, composerJSON) { + if _, ok := jsonValue(p, "autoload", "psr-0", "shopware"); ok { + return false + } + if _, ok := jsonValue(p, "autoload", "psr-4", `shopware\core\`); ok { + return false + } + if _, ok := jsonValue(p, "autoload", "psr-4", `shopware\appbundle\`); ok { + return false + } + if keywords, ok := jsonValue(p, "keywords"); ok { + if k, ok := keywords.([]any); ok && slices.ContainsFunc(k, func(v any) bool { + s, _ := v.(string) + return strings.EqualFold(s, "shopware") + }) { + return false + } + } + if require, ok := jsonValue(p, "require"); ok { + if r, ok := require.(map[string]any); ok { + if _, ok := r["symfony/framework-bundle"]; ok { + isSymfony = true + } + for name := range r { + if strings.HasPrefix(name, "shopware/") || strings.HasPrefix(name, "ibexa/") || + strings.HasPrefix(name, "ezsystems/") { + return false + } + } + } + } + } + return isSymfony +} + +// detectDependencyManagers sets the dependency managers from lock files. +func (w *wizard) detectDependencyManagers() error { + a := w.answers + a.dependencies = map[string]map[string]string{} + a.buildFlavor = "none" + + switch { + case fileExists(w.dir, "poetry.lock"): + a.dependencyManagers = append(a.dependencyManagers, poetry) + case fileExists(w.dir, "Pipfile.lock"): + a.dependencyManagers = append(a.dependencyManagers, pipenv) + case fileExists(w.dir, "requirements.txt"): + a.dependencyManagers = append(a.dependencyManagers, pip) + } + if fileExists(w.dir, "composer.lock") { + a.dependencyManagers = append(a.dependencyManagers, composer) + a.dependencies[php] = map[string]string{"composer/composer": "^2"} + } + switch { + case fileExists(w.dir, "yarn.lock"): + a.dependencyManagers = append(a.dependencyManagers, yarn) + a.dependencies[nodejs] = map[string]string{"yarn": "^1.22.0"} + case fileExists(w.dir, "package-lock.json"): + a.dependencyManagers = append(a.dependencyManagers, npm) + } + if fileExists(w.dir, "Gemfile.lock") { + a.dependencyManagers = append(a.dependencyManagers, bundler) + } + + if len(a.dependencyManagers) > 0 { + names := make([]string, len(a.dependencyManagers)) + for i, dm := range a.dependencyManagers { + names[i] = depManagerTitles[dm] + } + w.detected("dependency managers", strings.Join(names, ", ")) + } + return nil +} + +// detectApplicationRoot sets the application root to the directory of the first dependency manager's file. +func (w *wizard) detectApplicationRoot() error { + files := map[string]string{ + composer: composerJSON, + npm: packageJSON, + yarn: packageJSON, + poetry: "pyproject.toml", + pipenv: "Pipfile", + pip: "requirements.txt", + } + for _, dm := range w.answers.dependencyManagers { + name, ok := files[dm] + if !ok { + continue + } + if p := findFile(w.dir, name); p != "" { + w.answers.applicationRoot, _ = filepath.Rel(w.dir, filepath.Dir(p)) + return nil + } + } + return nil +} + +// appDir returns the absolute path of the application root. +func (w *wizard) appDir() string { + return filepath.Join(w.dir, w.answers.applicationRoot) +} + +// setEnvironment sets environment variables for the dependency managers. +func (w *wizard) setEnvironment() error { + a := w.answers + a.environment = map[string]string{} + for _, dm := range a.dependencyManagers { + switch dm { + case poetry: + a.environment["POETRY_VERSION"] = "1.8.4" + a.environment["POETRY_VIRTUALENVS_IN_PROJECT"] = "true" + case pipenv: + a.environment["PIPENV_TOOL_VERSION"] = "2024.2.0" + a.environment["PIPENV_VENV_IN_PROJECT"] = "1" + } + } + return nil +} + +// setBuildSteps sets the build hook commands. +func (w *wizard) setBuildSteps() error { + a := w.answers + for _, dm := range a.dependencyManagers { + switch dm { + case poetry: + a.buildSteps = append(a.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: + a.buildSteps = append(a.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: + a.buildSteps = append(a.buildSteps, "pip install -r requirements.txt") + case yarn, npm: + if a.runtime != nodejs { + if _, ok := a.dependencies[nodejs]; !ok { + a.dependencies[nodejs] = map[string]string{} + } + a.dependencies[nodejs]["n"] = "*" + a.dependencies[nodejs]["npx"] = "*" + a.environment["N_PREFIX"] = "/app/.global" + a.buildSteps = append(a.buildSteps, "n auto || n lts", "hash -r") + } + _, hasBuild := jsonValue(filepath.Join(w.dir, packageJSON), "scripts", "build") + if dm == yarn { + a.buildSteps = append(a.buildSteps, "yarn") + if hasBuild { + a.buildSteps = append(a.buildSteps, "yarn build") + } + } else { + a.buildSteps = append(a.buildSteps, "npm i") + if hasBuild { + a.buildSteps = append(a.buildSteps, "npm run build") + } + } + case composer: + a.buildSteps = append(a.buildSteps, + "composer --no-ansi --no-interaction install --no-progress --prefer-dist --optimize-autoloader --no-dev", + ) + case bundler: + a.buildSteps = append(a.buildSteps, "bundle install") + } + } + + switch a.stack { + case django: + if managePy := w.managePyPath(); managePy != "" { + a.buildSteps = append(a.buildSteps, + "# Collect static files so that they can be served by "+w.assets.ServiceName, + fmt.Sprintf("%spython %s collectstatic --noinput", a.pythonRunPrefix(), managePy), + ) + } + case nextJS: + // Fall back to "next build" if there is no build script. + if !slices.Contains(a.buildSteps, "yarn build") && !slices.Contains(a.buildSteps, "npm run build") { + if slices.Contains(a.dependencyManagers, yarn) { + a.buildSteps = append(a.buildSteps, "yarn exec next build") + } else { + a.buildSteps = append(a.buildSteps, "npm exec next build") + } + } + case rails: + a.environment["RAILS_ENV"] = "production" + a.environment["PIDFILE"] = "tmp/server.pid" + a.buildSteps = append(a.buildSteps, "bundle exec rails assets:precompile") + } + return nil +} + +// managePyPath returns the path of Django's manage.py relative to the application root, or "". +func (w *wizard) managePyPath() string { + p := findFile(w.appDir(), managePyFile) + if p == "" { + return "" + } + rel, _ := filepath.Rel(w.appDir(), p) + return filepath.ToSlash(rel) +} + +// setDeployCommand sets the deploy hook commands. +func (w *wizard) setDeployCommand() error { + a := w.answers + switch a.stack { + case django: + if managePy := w.managePyPath(); managePy != "" { + a.deployCommand = append(a.deployCommand, fmt.Sprintf("%spython %s migrate", a.pythonRunPrefix(), managePy)) + } + case laravel: + a.deployCommand = append(a.deployCommand, + "mkdir -p storage/framework/sessions", + "mkdir -p storage/framework/cache", + "mkdir -p storage/framework/views", + "php artisan migrate --force", + "php artisan optimize:clear", + ) + case rails: + a.deployCommand = append(a.deployCommand, "bundle exec rake db:migrate") + } + return nil +} + +// setSocketFamily sets how the web server listens: on a Unix socket or a TCP port. +func (w *wizard) setSocketFamily() error { + switch w.answers.runtime { + case php: + case ruby, python: + w.answers.socketFamily = unix + default: + w.answers.socketFamily = tcp + } + return nil +} + +// setWebCommand sets the command to start the web server. +func (w *wizard) setWebCommand() error { + a := w.answers + if a.runtime == php { + return nil + } + listen := `listen to the "$SOCKET" Unix socket` + if a.socketFamily == tcp { + listen = `listen on the "$PORT" port` + } + a.webCommand = fmt.Sprintf( + "echo 'Put your web server command in here! You need to %s. Read more about it here: %s#web-commands'; sleep 60", + listen, w.assets.Docs().AppReference, + ) + + packageJSONPath := filepath.Join(w.dir, packageJSON) + npmStart := func() string { + if slices.Contains(a.dependencyManagers, yarn) { + return "NODE_ENV=production yarn start" + } + return "NODE_ENV=production npm start" + } + switch a.stack { + case django: + pythonPath := "" + wsgi := "app.wsgi" + if wsgiPath := findFile(w.appDir(), "wsgi.py"); wsgiPath != "" { + wsgiDir := filepath.Dir(wsgiPath) + wsgi = filepath.Base(wsgiDir) + ".wsgi" + // Add the Python path if the WSGI module's package is not directly in the application root. + if rel, _ := filepath.Rel(w.appDir(), filepath.Dir(wsgiDir)); rel != "." { + pythonPath = "--pythonpath=" + filepath.Base(filepath.Dir(wsgiDir)) + } + } + bind := "unix:$SOCKET" + if a.socketFamily == tcp { + bind = "0.0.0.0:$PORT" + } + a.webCommand = fmt.Sprintf("%sgunicorn %s -b %s %s --log-file -", a.pythonRunPrefix(), pythonPath, bind, wsgi) + case rails: + if a.socketFamily == tcp { + a.webCommand = "bundle exec rails server" + } else { + a.webCommand = "bundle exec puma -b unix://$SOCKET" + } + case nextJS: + a.webCommand = "npx next start -p $PORT" + case strapi: + if _, ok := jsonValue(packageJSONPath, "scripts", "start"); ok { + a.webCommand = npmStart() + } + case express: + if _, ok := jsonValue(packageJSONPath, "scripts", "start"); ok { + a.webCommand = npmStart() + } else if main, ok := jsonValue(packageJSONPath, "main"); ok { + if s, ok := main.(string); ok { + a.webCommand = "node " + s + } + } else if index := findFile(w.dir, "index.js"); index != "" { + rel, _ := filepath.Rel(w.dir, index) + a.webCommand = "node " + filepath.ToSlash(rel) + } + case flask: + for _, name := range []string{"app.py", "server.py", "api.py"} { + if _, err := os.Stat(filepath.Join(w.dir, name)); err != nil { + continue + } + app := fmt.Sprintf("'%s:app'", strings.TrimSuffix(name, ".py")) + bind := "unix:$SOCKET" + if a.socketFamily == tcp { + bind = "0.0.0.0:$PORT" + } + a.webCommand = fmt.Sprintf("%sgunicorn -b %s %s --log-file -", a.pythonRunPrefix(), bind, app) + break + } + } + return nil +} + +// setLocations sets the web locations. +func (w *wizard) setLocations() error { + a := w.answers + a.locations = map[string]map[string]any{} + switch { + case a.stack == django: + a.locations["/static"] = map[string]any{ + "root": "static", + "expires": "1h", + "allow": true, + } + case a.runtime == php: + location := map[string]any{ //nolint:gosec // Not a credential. + "passthru": "/index.php", + "root": "", + } + if index := findFile(w.dir, "index.php"); index != "" { + if rel, _ := filepath.Rel(w.dir, filepath.Dir(index)); rel != "." { + location["root"] = filepath.ToSlash(rel) + } + } + a.locations["/"] = location + } + return nil +} + +// setMounts sets the disk and mounts for the stack. +func (w *wizard) setMounts() error { + a := w.answers + local := func(sourcePath string) map[string]string { + return map[string]string{"source": "local", "source_path": sourcePath} + } + switch a.stack { + case rails: + a.disk = "2048" + tmp := map[string]string{"source": "tmp", "source_path": "tmp"} + a.mounts = map[string]map[string]string{ + "storage": local("storage"), + "tmp": tmp, + "log": tmp, + } + case laravel: + a.disk = "2048" + a.mounts = map[string]map[string]string{ + "storage": local("storage"), + "bootstrap/cache": local("cache"), + "/.config": local("config"), + } + case nextJS: + a.disk = "512" + a.mounts = map[string]map[string]string{ + "/.npm": local("npm"), + } + case strapi: + a.disk = "1024" + a.mounts = map[string]map[string]string{ + "/.cache": local("cache"), + "/.tmp": local("app"), + "database": local("database"), + "extensions": local("extensions"), + "public/uploads": local("uploads"), + } + } + return nil +} diff --git a/internal/platformify/detect_test.go b/internal/platformify/detect_test.go new file mode 100644 index 000000000..8b65294c1 --- /dev/null +++ b/internal/platformify/detect_test.go @@ -0,0 +1,115 @@ +package platformify + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeTree creates files in a temporary directory and returns its path. +func writeTree(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, content := range files { + p := filepath.Join(dir, filepath.FromSlash(name)) + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755)) + require.NoError(t, os.WriteFile(p, []byte(content), 0o600)) + } + return dir +} + +func TestFindStack(t *testing.T) { + cases := []struct { + name string + files map[string]string + want stack + }{ + {"empty", nil, genericStack}, + {"django", map[string]string{"manage.py": "", "mysite/settings.py": ""}, django}, + {"rails", map[string]string{"config.ru": "Rails.application.load_server"}, rails}, + {"flask requirements", map[string]string{"requirements.txt": "Flask==3.0"}, flask}, + {"flask poetry", map[string]string{"pyproject.toml": "[tool.poetry.dependencies]\nFlask = \"^3\"\n"}, flask}, + {"laravel", map[string]string{"composer.json": `{"require": {"laravel/framework": "^11"}}`}, laravel}, + {"nextjs", map[string]string{"package.json": `{"dependencies": {"next": "^15"}}`}, nextJS}, + {"strapi", map[string]string{"package.json": `{"dependencies": {"@strapi/strapi": "^5"}}`}, strapi}, + {"express", map[string]string{"package.json": `{"dependencies": {"express": "^5"}}`}, express}, + {"node_modules ignored", map[string]string{ + "node_modules/x/package.json": `{"dependencies": {"next": "1"}}`, + }, genericStack}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + w := &wizard{dir: writeTree(t, c.files)} + assert.Equal(t, c.want, w.findStack()) + }) + } +} + +func TestIsSymfony(t *testing.T) { + cases := []struct { + name string + composer string + want bool + }{ + {"symfony", `{"require": {"symfony/framework-bundle": "^7"}}`, true}, + {"not symfony", `{"require": {"monolog/monolog": "^3"}}`, false}, + {"ibexa", `{"require": {"symfony/framework-bundle": "^7", "ibexa/core": "^4"}}`, false}, + {"shopware keyword", `{"keywords": ["Shopware"], "require": {"symfony/framework-bundle": "^7"}}`, false}, + {"shopware autoload", `{"autoload": {"psr-4": {"Shopware\\Core\\": "src/"}}, + "require": {"symfony/framework-bundle": "^7"}}`, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + w := &wizard{dir: writeTree(t, map[string]string{"composer.json": c.composer})} + assert.Equal(t, c.want, w.isSymfony()) + }) + } +} + +func TestSetBuildSteps(t *testing.T) { + cases := []struct { + name string + answers answers + want []string + }{ + { + name: "Next.js with Yarn", + answers: answers{stack: nextJS, runtime: nodejs, dependencyManagers: []string{yarn}}, + want: []string{"yarn", "yarn exec next build"}, + }, + { + name: "Next.js with npm", + answers: answers{stack: nextJS, runtime: nodejs, dependencyManagers: []string{npm}}, + want: []string{"npm i", "npm exec next build"}, + }, + { + name: "Bundler", + answers: answers{runtime: ruby, dependencyManagers: []string{bundler}}, + want: []string{"bundle install"}, + }, + { + name: "Rails", + answers: answers{stack: rails, runtime: ruby, dependencyManagers: []string{bundler}}, + want: []string{"bundle install", "bundle exec rails assets:precompile"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + c.answers.environment = map[string]string{} + c.answers.dependencies = map[string]map[string]string{} + w := &wizard{dir: t.TempDir(), assets: &assets{}, answers: &c.answers} + require.NoError(t, w.setBuildSteps()) + assert.Equal(t, c.want, c.answers.buildSteps) + }) + } +} + +func TestSetWebCommandExpressMain(t *testing.T) { + dir := writeTree(t, map[string]string{"package.json": `{"main": "src/Server.js"}`}) + w := &wizard{dir: dir, assets: &assets{}, answers: &answers{stack: express, runtime: nodejs, socketFamily: tcp}} + require.NoError(t, w.setWebCommand()) + assert.Equal(t, "node src/Server.js", w.answers.webCommand) +} diff --git a/internal/platformify/files.go b/internal/platformify/files.go new file mode 100644 index 000000000..601d93872 --- /dev/null +++ b/internal/platformify/files.go @@ -0,0 +1,105 @@ +package platformify + +import ( + "bytes" + "cmp" + "encoding/json" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +// skipDirs are not searched for project files. +var skipDirs = []string{"vendor", "node_modules", ".next", ".git"} + +// findAllFiles returns the paths of files with the given name under dir. +func findAllFiles(dir, name string) []string { + var found []string + _ = filepath.WalkDir(dir, func(p 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 + } + if d.Name() == name { + found = append(found, p) + } + return nil + }) + return found +} + +// findFile returns the path of the file with the given name under dir that is closest to dir, or "". +func findFile(dir, name string) string { + files := findAllFiles(dir, name) + if len(files) == 0 { + return "" + } + return slices.MinFunc(files, func(a, b string) int { + return cmp.Compare(strings.Count(a, string(os.PathSeparator)), strings.Count(b, string(os.PathSeparator))) + }) +} + +// fileExists reports whether a file with the given name exists under dir. +func fileExists(dir, name string) bool { + return findFile(dir, name) != "" +} + +// fileContains reports whether the file contains s, ignoring case. +func fileContains(path, s string) bool { + b, err := os.ReadFile(path) + return err == nil && bytes.Contains(bytes.ToLower(b), []byte(strings.ToLower(s))) +} + +// jsonValue returns the value at keyPath in a JSON file, matching keys case-insensitively. +func jsonValue(path string, keyPath ...string) (any, bool) { + return decodedValue(path, json.Unmarshal, keyPath) +} + +// tomlValue returns the value at keyPath in a TOML file, matching keys case-insensitively. +func tomlValue(path string, keyPath ...string) (any, bool) { + return decodedValue(path, toml.Unmarshal, keyPath) +} + +func decodedValue(path string, unmarshal func([]byte, any) error, keyPath []string) (any, bool) { + b, err := os.ReadFile(path) + if err != nil { + return nil, false + } + var value any + if err := unmarshal(b, &value); err != nil { + return nil, false + } + for _, key := range keyPath { + m, ok := value.(map[string]any) + if !ok { + return nil, false + } + value, ok = lookupFold(m, key) + if !ok { + return nil, false + } + } + return value, true +} + +// lookupFold returns the value of a key in m, matched case-insensitively. +func lookupFold(m map[string]any, key string) (any, bool) { + if v, ok := m[key]; ok { + return v, true + } + for k, v := range m { + if strings.EqualFold(k, key) { + return v, true + } + } + return nil, false +} diff --git a/internal/platformify/fixtures_test.go b/internal/platformify/fixtures_test.go new file mode 100644 index 000000000..42958693d --- /dev/null +++ b/internal/platformify/fixtures_test.go @@ -0,0 +1,114 @@ +package platformify + +// testInputs returns template inputs for each stack, without assets. +func testInputs() map[string]*templateInput { + return map[string]*templateInput{ + "django": { + Name: "django", + Type: "python:3.14", + Stack: django, + Runtime: "python", + ApplicationRoot: "app", + Environment: map[string]string{ + "DJANGO_SETTINGS_MODULE": "app.settings", + "PYTHONUNBUFFERED": "1", + }, + BuildFlavor: "none", + BuildSteps: []string{ + "pip install -r requirements.txt", + "# comment here", + "python manage.py collectstatic --noinput", + }, + WebCommand: "gunicorn app.wsgi", + SocketFamily: "unix", + DeployCommand: []string{ + "python manage.py migrate", + "# comment here", + }, + Locations: map[string]map[string]any{ + "/": {"passthru": true}, + "/static": {"root": "static", "expires": "1h", "allow": true}, + }, + Dependencies: map[string]map[string]string{ + "python": {"poetry": "*", "pip": ">=20.0.0"}, + "nodejs": {"yarn": "*", "npm": ">=6.0.0"}, + }, + Disk: "1024", + Mounts: map[string]map[string]string{ + "/.npm": {"source": "local", "source_path": "npm"}, + "/.pip": {"source": "local", "source_path": "pip"}, + }, + Relationships: map[string]relationship{ + "db": {Service: "db", Endpoint: "postgresql"}, + "mysql": {Service: "mysql", Endpoint: "mysql"}, + }, + Services: []service{ + { + Name: "db", + Type: "postgresql:17", + TypeVersions: []string{"17", "16", "15", "14", "13", "12"}, + Disk: "1024", + DiskSizes: []string{"1024", "2048"}, + }, + { + Name: "mysql", + Type: "mysql:11.8", + TypeVersions: []string{"11.0", "10.11", "10.6", "10.5", "10.4", "10.3"}, + Disk: "1024", + DiskSizes: []string{"1024", "2034"}, + }, + }, + }, + "generic": { + Name: "generic", + Type: "java:21", + Runtime: "java", + Stack: genericStack, + Environment: map[string]string{ + "JAVA": "19", + }, + BuildSteps: []string{"mvn install"}, + WebCommand: "tomcat", + SocketFamily: "tcp", + Locations: map[string]map[string]any{ + "/": {"passthru": true}, + }, + Dependencies: map[string]map[string]string{}, + Disk: "1024", + Mounts: map[string]map[string]string{ + "/.mvn": {"source": "local", "source_path": "maven"}, + }, + Relationships: map[string]relationship{ + "mysql": {Service: "mysql", Endpoint: "mysql"}, + }, + Services: []service{ + { + Name: "mysql", + Type: "mysql:11.8", + TypeVersions: []string{"13", "14", "15"}, + Disk: "1024", + DiskSizes: []string{"1024", "2048"}, + }, + }, + }, + "laravel": { + Name: "laravel", + Type: "php:8.4", + Stack: laravel, + Runtime: "php", + ApplicationRoot: "app", + Environment: map[string]string{}, + BuildFlavor: "php", + Locations: map[string]map[string]any{ + "/": {"root": "index.php"}, + }, + Dependencies: map[string]map[string]string{}, + Mounts: map[string]map[string]string{}, + Relationships: map[string]relationship{}, + }, + "nextjs": {Name: "nextjs", Type: "nodejs:24", Runtime: "nodejs", Stack: nextJS}, + "strapi": {Name: "strapi", Type: "nodejs:24", Runtime: "nodejs", Stack: strapi}, + "flask": {Name: "flask", Type: "python:3.14", Runtime: "python", Stack: flask}, + "express": {Name: "express", Type: "nodejs:24", Runtime: "nodejs", Stack: express}, + } +} diff --git a/internal/platformify/generate.go b/internal/platformify/generate.go new file mode 100644 index 000000000..8e937cba5 --- /dev/null +++ b/internal/platformify/generate.go @@ -0,0 +1,215 @@ +package platformify + +import ( + "bytes" + "embed" + "io/fs" + "maps" + "os" + "path" + "path/filepath" + "strings" + "text/template" + + "github.com/Masterminds/sprig/v3" + + "github.com/upsun/cli/internal/config" +) + +//go:embed all:templates +var templatesFS embed.FS + +// importSettingsLine imports the generated settings_psh.py into Django's settings.py. +const importSettingsLine = "from .settings_psh import *" + +func newAssets(cnf *config.Config) *assets { + return &assets{ + Binary: cnf.Application.Executable, + EnvPrefix: strings.TrimSuffix(cnf.Service.EnvPrefix, "_"), + ServiceName: cnf.Service.Name, + docsBaseURL: cnf.Service.DocsURL, + flex: cnf.Service.ProjectConfigFlavor == "upsun", + } +} + +// templateInput is the data passed to templates. +type templateInput struct { + Assets *assets + Stack stack + Name string + Type string // e.g. "python:3.14" + Runtime string // e.g. "python" + ApplicationRoot string + Environment map[string]string + BuildSteps []string + WebCommand string + SocketFamily string + DeployCommand []string + Locations map[string]map[string]any + Dependencies map[string]map[string]string + BuildFlavor string + Disk string + Mounts map[string]map[string]string + Services []service + Relationships map[string]relationship +} + +type relationship struct { + Service string + Endpoint string +} + +func (w *wizard) templateInput() *templateInput { + a := w.answers + typ := a.runtime + if a.version != "" { + typ += ":" + a.version + } + locations := map[string]map[string]any{"/": {"passthru": true}} + maps.Copy(locations, a.locations) + return &templateInput{ + Assets: w.assets, + Stack: a.stack, + Name: a.name, + Type: typ, + Runtime: a.runtime, + ApplicationRoot: path.Join("/", filepath.ToSlash(a.applicationRoot)), + Environment: a.environment, + BuildSteps: a.buildSteps, + WebCommand: a.webCommand, + SocketFamily: a.socketFamily, + DeployCommand: a.deployCommand, + Locations: locations, + Dependencies: a.dependencies, + BuildFlavor: a.buildFlavor, + Disk: a.disk, + Mounts: a.mounts, + Services: a.services, + Relationships: relationships(a.services), + } +} + +// relationships returns the relationship for each service, by service name. +func relationships(services []service) map[string]relationship { + endpoints := map[string]string{ + "mariadb": "mysql", + "oracle-mysql": "mysql", + "chrome-headless": "http", + "redis-persistent": "redis", + } + r := make(map[string]relationship, len(services)) + for _, s := range services { + endpoint, _, _ := strings.Cut(s.Type, ":") + if e, ok := endpoints[endpoint]; ok { + endpoint = e + } + r[s.Name] = relationship{Service: s.Name, Endpoint: endpoint} + } + return r +} + +// Database returns the name of the first database service. +func (t *templateInput) Database() string { + return t.firstService("mariadb", "mysql", "oracle-mysql", "postgresql") +} + +// DatabaseUpper returns Database as an environment variable name. +func (t *templateInput) DatabaseUpper() string { + return envName(t.Database()) +} + +// Cache returns the name of the first cache service. +func (t *templateInput) Cache() string { + return t.firstService("redis", "redis-persistent", "memcached") +} + +// CacheUpper returns Cache as an environment variable name. +func (t *templateInput) CacheUpper() string { + return envName(t.Cache()) +} + +func (t *templateInput) firstService(types ...string) string { + for _, s := range t.Services { + for _, typ := range types { + if strings.Contains(s.Type, typ) { + return s.Name + } + } + } + return "" +} + +func envName(s string) string { + return strings.ToUpper(strings.ReplaceAll(s, "-", "_")) +} + +// generate renders the configuration files for the project in dir. +// It returns their contents by slash-separated path relative to dir. +func generate(dir string, input *templateInput) (map[string][]byte, error) { + templatesDir := "templates/fixed" + if input.Assets.flex { + templatesDir = "templates/flex" + } + files := map[string][]byte{} + err := fs.WalkDir(templatesFS, templatesDir, func(name string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + content, err := render(name, input) + if err != nil { + return err + } + if len(bytes.TrimSpace(content)) > 0 { + files[strings.TrimPrefix(name, templatesDir+"/")] = content + } + return nil + }) + if err != nil { + return nil, err + } + if input.Stack == django { + if err := generateDjango(dir, input, files); err != nil { + return nil, err + } + } + return files, nil +} + +// generateDjango adds a settings_psh.py file next to settings.py, and imports it there. +func generateDjango(dir string, input *templateInput, files map[string][]byte) error { + settingsPath := findFile(filepath.Join(dir, filepath.FromSlash(input.ApplicationRoot)), settingsPyFile) + if settingsPath == "" { + return nil + } + rel, err := filepath.Rel(dir, filepath.Dir(settingsPath)) + if err != nil { + return err + } + content, err := render("templates/django/settings_psh.py", input) + if err != nil { + return err + } + files[path.Join(filepath.ToSlash(rel), "settings_psh.py")] = content + + settings, err := os.ReadFile(settingsPath) + if err != nil { + return err + } + if !bytes.Contains(settings, []byte(importSettingsLine)) { + settingsRel, _ := filepath.Rel(dir, settingsPath) + files[filepath.ToSlash(settingsRel)] = append(settings, []byte("\n\n"+importSettingsLine+"\n")...) + } + return nil +} + +func render(name string, input *templateInput) ([]byte, error) { + tpl, err := template.New(path.Base(name)).Funcs(sprig.FuncMap()).ParseFS(templatesFS, name) + if err != nil { + return nil, err + } + var buf bytes.Buffer + if err := tpl.Execute(&buf, input); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/platformify/generate_test.go b/internal/platformify/generate_test.go new file mode 100644 index 000000000..76febcef8 --- /dev/null +++ b/internal/platformify/generate_test.go @@ -0,0 +1,81 @@ +package platformify + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/pkg/lint" +) + +func TestGenerate(t *testing.T) { + for _, flex := range []bool{true, false} { + a := &assets{Binary: "upsun", EnvPrefix: "PLATFORM", ServiceName: "Upsun", docsBaseURL: "https://docs.upsun.com", + flex: flex} + for name, in := range testInputs() { + t.Run(name, func(t *testing.T) { + in.Assets = a + dir := t.TempDir() + files, err := generate(dir, in) + require.NoError(t, err) + + for f := range files { + assert.Contains(t, a.configFiles(), f) + } + require.NoError(t, writeFiles(dir, files)) + result, err := lint.CheckDir(dir) + require.NoError(t, err) + assert.False(t, result.HasErrors(), result.String()) + }) + } + } +} + +func TestGenerateDjango(t *testing.T) { + dir := t.TempDir() + settings := filepath.Join(dir, "app", "mysite", "settings.py") + require.NoError(t, os.MkdirAll(filepath.Dir(settings), 0o755)) + require.NoError(t, os.WriteFile(settings, []byte("DEBUG = False\n"), 0o600)) + + in := testInputs()["django"] + in.Assets = &assets{flex: true} + in.ApplicationRoot = "/app" + files, err := generate(dir, in) + require.NoError(t, err) + assert.Contains(t, files, "app/mysite/settings_psh.py") + assert.Equal(t, "DEBUG = False\n\n\n"+importSettingsLine+"\n", string(files["app/mysite/settings.py"])) + + // The import is not added twice. + require.NoError(t, writeFiles(dir, files)) + files, err = generate(dir, in) + require.NoError(t, err) + assert.Contains(t, files, "app/mysite/settings_psh.py") + assert.NotContains(t, files, "app/mysite/settings.py") +} + +func TestRelationships(t *testing.T) { + services := []service{ + {Name: "mysql", Type: "mysql:5.7"}, + {Name: "mariadb", Type: "mariadb:11.8"}, + {Name: "oracle-mysql", Type: "oracle-mysql:8.4"}, + {Name: "chrome-headless", Type: "chrome-headless:120"}, + {Name: "redis-persistent", Type: "redis-persistent:8.0"}, + } + assert.Equal(t, map[string]relationship{ + "mysql": {Service: "mysql", Endpoint: "mysql"}, + "mariadb": {Service: "mariadb", Endpoint: "mysql"}, + "oracle-mysql": {Service: "oracle-mysql", Endpoint: "mysql"}, + "chrome-headless": {Service: "chrome-headless", Endpoint: "http"}, + "redis-persistent": {Service: "redis-persistent", Endpoint: "redis"}, + }, relationships(services)) +} + +func TestVersions(t *testing.T) { + for _, c := range slices.Concat(runtimeChoices, serviceChoices) { + assert.NotEmpty(t, versions(c.typ), c.typ) + } +} diff --git a/internal/platformify/messages.go b/internal/platformify/messages.go new file mode 100644 index 000000000..10feda174 --- /dev/null +++ b/internal/platformify/messages.go @@ -0,0 +1,122 @@ +package platformify + +import ( + "fmt" +) + +// detected prints a detected property of the project. +func (w *wizard) detected(what, value string) { + fmt.Fprintf(w.stderr, "%s %s\n", colorize(greenCode, "✓"), colorize(brandCode, "Detected "+what+": "+value)) +} + +func (w *wizard) welcome() error { + fmt.Fprintln(w.stdout, colorize(brandCode, fmt.Sprintf("Welcome to %s!", w.assets.ServiceName))) + fmt.Fprintln(w.stdout, colorize(brandCode, "Let's get started with a few questions.")) + fmt.Fprintln(w.stdout) + fmt.Fprintln(w.stdout, "We need to know a bit more about your project. This will only take a minute!") + fmt.Fprintln(w.stdout) + return nil +} + +func (w *wizard) almostDone() error { + fmt.Fprintln(w.stdout) + fmt.Fprintln(w.stdout, colorize(accentCode, " (\\_/)")) + fmt.Fprintln(w.stdout, colorize(accentCode, "We’re almost done... =(^.^)=")) + fmt.Fprintln(w.stdout) + return nil +} + +func (w *wizard) done() { + out := w.stdout + fmt.Fprintln(out) + fmt.Fprintln(out, "┌───────────────────────────────────────────────────┐") + fmt.Fprintln(out, "│ CONGRATULATIONS! │") + fmt.Fprintln(out, "│ │") + fmt.Fprintln(out, "│ We have created the following files for your: │") + for _, f := range w.assets.configFiles() { + fmt.Fprintf(out, "│ - %-44s│\n", f) + } + fmt.Fprintln(out, "│ │") + fmt.Fprintln(out, "│ We’re jumping for joy! ⍢ │") + fmt.Fprintln(out, "└───────────────────────────────────────────────────┘") + fmt.Fprintln(out, " │ /") + fmt.Fprintln(out, " │/") + fmt.Fprintln(out, " │") + fmt.Fprintln(out, " (\\ /)") + fmt.Fprintln(out, " ( . .)") + fmt.Fprintln(out, " o (_(“)(“)") + fmt.Fprintln(out) + fmt.Fprintln(out, colorize(accentCode, "You can now deploy your application to "+w.assets.ServiceName+"!")) + fmt.Fprintln(out, colorize(accentCode, fmt.Sprintf( + "To do so, commit your files and deploy your application using the %s CLI:", w.assets.ServiceName))) + fmt.Fprintln(out, " $ git add .") + fmt.Fprintf(out, " $ git commit -m 'Add %s configuration files'\n", w.assets.ServiceName) + fmt.Fprintf(out, " $ %s project:set-remote\n", w.assets.Binary) + fmt.Fprintf(out, " $ %s push\n", w.assets.Binary) + fmt.Fprintln(out) +} + +// suggestLaravelBridge suggests installing the Laravel Bridge, if it is not required. +func (w *wizard) suggestLaravelBridge() { + composerFiles := findAllFiles(w.appDir(), composerJSON) + for _, p := range composerFiles { + if _, ok := jsonValue(p, "require", "platformsh/laravel-bridge"); ok { + return + } + } + if len(composerFiles) == 0 { + return + } + fmt.Fprintln(w.stdout, colorize(warningCode, + "\nPlease use composer to add the Laravel Bridge to your project:\n\n"+ + " composer require platformsh/laravel-bridge\n")) +} + +// assets are the vendor-specific values used in messages and templates. +type assets struct { + Binary string + EnvPrefix string + ServiceName string + docsBaseURL string + flex bool +} + +// docs are documentation links used in templates. +type docs struct { + AppReference string + Hooks string + PHP string + Routes string + Services string + SymfonyCLI string + TimeZone string + Variables string +} + +// Docs returns documentation links. +func (a *assets) Docs() docs { + return docs{ + AppReference: a.docsBaseURL + "/app/reference/", + Hooks: a.docsBaseURL + "/app/hooks/compare/", + PHP: a.docsBaseURL + "/languages/php/", + Routes: a.docsBaseURL + "/routes/", + Services: a.docsBaseURL + "/services/", + SymfonyCLI: a.docsBaseURL + "/stacks/symfony/cli/tips/", + TimeZone: a.docsBaseURL + "/app/timezone/", + Variables: a.docsBaseURL + "/variables/use/provided/", + } +} + +// configFiles returns the paths of the files that the wizard creates. +func (a *assets) configFiles() []string { + if a.flex { + return []string{".environment", ".upsun/config.yaml"} + } + return []string{ + ".environment", + ".platform.app.yaml", + ".platform/services.yaml", + ".platform/routes.yaml", + ".platform/applications.yaml", + } +} diff --git a/internal/platformify/models.go b/internal/platformify/models.go new file mode 100644 index 000000000..290ed33d4 --- /dev/null +++ b/internal/platformify/models.go @@ -0,0 +1,193 @@ +package platformify + +import ( + "github.com/upsun/cli/pkg/registry" +) + +// stack is a framework that the wizard detects and configures. +type stack int + +const ( + genericStack stack = iota + django + laravel + nextJS + strapi + flask + express + rails +) + +// Name returns the stack's identifier, used in templates. +func (s stack) Name() string { + return [...]string{"generic", "django", "laravel", "nextjs", "strapi", "flask", "express", "rails"}[s] +} + +// Title returns the stack's display name. +func (s stack) Title() string { + return [...]string{"Other", "Django", "Laravel", "Next.js", "Strapi", "Flask", "Express", "Rails"}[s] +} + +// runtime returns the runtime type for the stack, or "" if it can be any. +func (s stack) runtime() string { + switch s { + case django, flask: + return python + case rails: + return ruby + case laravel: + return php + case nextJS, strapi, express: + return nodejs + default: + return "" + } +} + +// Runtime types. +const ( + nodejs = "nodejs" + php = "php" + python = "python" + ruby = "ruby" +) + +// choice is an image type offered by the wizard, with its display title. +type choice struct { + typ, title string +} + +var runtimeChoices = []choice{ + {"dotnet", "C#/.Net Core"}, + {"elixir", "Elixir"}, + {"golang", "Go"}, + {"java", "Java"}, + {nodejs, "JavaScript/Node.js"}, + {php, "PHP"}, + {python, "Python"}, + {ruby, "Ruby"}, + {"rust", "Rust"}, +} + +var serviceChoices = []choice{ + {"mariadb", "MariaDB"}, + {"mysql", "MySQL"}, + {"postgresql", "PostgreSQL"}, + {"redis", "Redis"}, + {"redis-persistent", "Redis Persistent"}, + {"memcached", "Memcached"}, + {"opensearch", "OpenSearch"}, + {"solr", "Solr"}, + {"varnish", "Varnish"}, + {"kafka", "Kafka"}, + {"vault-kms", "Vault KMS"}, + {"rabbitmq", "RabbitMQ"}, + {"influxdb", "InfluxDB"}, + {"chrome-headless", "Chrome Headless"}, + {"network-storage", "Network Storage"}, + {"oracle-mysql", "Oracle MySQL"}, +} + +// ephemeralServices are service types that have no disk. +var ephemeralServices = []string{"chrome-headless", "memcached", "redis"} + +// diskSizes are the disk sizes offered for services, in MB. +var diskSizes = []string{"1024", "2048", "3072", "4096", "5120"} + +// titles returns the titles of the choices. +func titles(choices []choice) []string { + t := make([]string, len(choices)) + for i, c := range choices { + t[i] = c.title + } + return t +} + +// typeByTitle returns the type of the choice with the given title. +func typeByTitle(choices []choice, title string) string { + for _, c := range choices { + if c.title == title { + return c.typ + } + } + return "" +} + +// versions returns the supported versions of an image type, newest first. +func versions(typ string) []string { + reg, err := registry.Load() + if err != nil { + return nil + } + return reg[typ].Versions.Supported +} + +// Dependency managers. +const ( + pip = "pip" + poetry = "poetry" + pipenv = "pipenv" + composer = "composer" + yarn = "yarn" + npm = "npm" + bundler = "bundler" +) + +var depManagerTitles = map[string]string{ + pip: "Pip", + poetry: "Poetry", + pipenv: "Pipenv", + composer: "Composer", + yarn: "Yarn", + npm: "Npm", + bundler: "Bundler", +} + +// Socket families. +const ( + tcp = "tcp" + unix = "unix" +) + +// answers holds what the wizard detects or asks. +type answers struct { + stack stack + runtime string // e.g. "python" + version string // e.g. "3.14" + name string + applicationRoot string + environment map[string]string + buildSteps []string + webCommand string + socketFamily string + deployCommand []string + dependencyManagers []string + dependencies map[string]map[string]string + buildFlavor string + disk string + mounts map[string]map[string]string + services []service + locations map[string]map[string]any +} + +// service is a service needed by the application. +type service struct { + Name string + Type string // e.g. "postgresql:18" + TypeVersions []string + Disk string + DiskSizes []string +} + +// pythonRunPrefix returns the command prefix for the Python dependency manager, if it uses a virtualenv. +func (a *answers) pythonRunPrefix() string { + for _, dm := range a.dependencyManagers { + switch dm { + case pipenv: + return "pipenv run " + case poetry: + return "poetry run " + } + } + return "" +} diff --git a/internal/platformify/platformifier/django.go b/internal/platformify/platformifier/django.go deleted file mode 100644 index 3c43b852a..000000000 --- a/internal/platformify/platformifier/django.go +++ /dev/null @@ -1,93 +0,0 @@ -package platformifier - -import ( - "context" - "fmt" - "io/fs" - "os" - "path/filepath" - "text/template" - - "github.com/Masterminds/sprig/v3" - - "github.com/upsun/cli/internal/platformify/colors" - "github.com/upsun/cli/internal/platformify/utils" - "github.com/upsun/cli/internal/platformify/vendorization" -) - -const ( - settingsPyFile = "settings.py" - settingsPshPyFile = "settings_psh.py" - importSettingsPshLine = "from .settings_psh import *" -) - -func newDjangoPlatformifier(templates fs.FS, fileSystem FS) *djangoPlatformifier { - return &djangoPlatformifier{ - templates: templates, - fileSystem: fileSystem, - } -} - -type djangoPlatformifier struct { - templates fs.FS - fileSystem FS -} - -func (p *djangoPlatformifier) Platformify(ctx context.Context, input *UserInput) error { - appRoot := filepath.Join(input.Root, input.ApplicationRoot) - if settingsPath := p.fileSystem.Find(appRoot, settingsPyFile, true); len(settingsPath) > 0 { - pshSettingsPath := filepath.Join(filepath.Dir(settingsPath[0]), settingsPshPyFile) - tpl, parseErr := template.New(settingsPshPyFile).Funcs(sprig.FuncMap()). - ParseFS(p.templates, settingsPshPyFile) - if parseErr != nil { - return fmt.Errorf("could not parse template: %w", parseErr) - } - pshSettingsFile, err := p.fileSystem.Create(pshSettingsPath) - if err != nil { - return err - } - defer pshSettingsFile.Close() - - assets, _ := vendorization.FromContext(ctx) - err = tpl.Execute(pshSettingsFile, templateData{input, assets}) - if err != nil { - return err - } - - // append from .settings_psh import * to the bottom of settings.py - settingsFile, err := p.fileSystem.Open(settingsPath[0], os.O_APPEND|os.O_RDWR, 0o644) - if err != nil { - return nil - } - defer settingsFile.Close() - - // Check if there is an import line in the file - found, err := utils.ContainsStringInFile(settingsFile, importSettingsPshLine, false) - if err != nil { - return err - } - - if !found { - if _, err = settingsFile.Write([]byte("\n\n" + importSettingsPshLine + "\n")); err != nil { - out, _, ok := colors.FromContext(ctx) - if !ok { - return nil - } - - fmt.Fprintf( - out, - colors.Colorize( - colors.WarningCode, - "We have created a %s file for you. Please add the following line to your %s file:\n", - ), - settingsPshPyFile, - settingsPyFile, - ) - fmt.Fprint(out, colors.Colorize(colors.WarningCode, " "+importSettingsPshLine+"\n")) - return nil - } - } - } - - return nil -} diff --git a/internal/platformify/platformifier/django_test.go b/internal/platformify/platformifier/django_test.go deleted file mode 100644 index 50631f86e..000000000 --- a/internal/platformify/platformifier/django_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package platformifier - -import ( - "context" - "embed" - "errors" - "io/fs" - "os" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" - - "github.com/upsun/cli/internal/platformify/utils" -) - -const ( - djangoTemplatesDir = "templates/django" -) - -var ( - //go:embed templates/django/* - testDjangoTemplatesFS embed.FS -) - -type PlatformifyDjangoSuiteTester struct { - suite.Suite - - cwd string - templates fs.FS - fileSystem *MockFS -} - -func (s *PlatformifyDjangoSuiteTester) SetupTest() { - ctrl := gomock.NewController(s.T()) - - s.fileSystem = NewMockFS(ctrl) - - cwd, err := os.Getwd() - require.NoError(s.T(), err) - s.cwd = cwd - - templates, err := fs.Sub(testDjangoTemplatesFS, djangoTemplatesDir) - require.NoError(s.T(), err) - s.templates = templates -} - -func (s *PlatformifyDjangoSuiteTester) TestSuccessfulFileCreation() { - // GIVEN mock buffers to store settings and PSH settings files - settingsBuff, settingsPSHBuff := &MockBuffer{}, &MockBuffer{} - // AND working directory is a current directory - input := &UserInput{WorkingDirectory: s.cwd} - // AND the settings.py file exists - s.fileSystem.EXPECT(). - Find("", settingsPyFile, true). - Return([]string{settingsPyFile}).Times(1) - s.fileSystem.EXPECT(). - Open(gomock.Eq(settingsPyFile), gomock.Any(), gomock.Any()). - Return(settingsBuff, nil).Times(1) - // AND creation of the PSH settings file returns no errors - s.fileSystem.EXPECT(). - Create(gomock.Eq(settingsPshPyFile)). - Return(settingsPSHBuff, nil).Times(1) - - // WHEN run config files creation - p := newDjangoPlatformifier(s.templates, s.fileSystem) - err := p.Platformify(context.Background(), input) - // THEN it doesn't return any errors - assert.NoError(s.T(), err) - // AND the buffer contains settings file - assert.NotEmpty(s.T(), settingsPSHBuff) - - // WHEN check if settings file contains the line that imported psh settings file - found, err := utils.ContainsStringInFile(settingsBuff, importSettingsPshLine, false) - // THEN it doesn't return any errors - assert.NoError(s.T(), err) - // AND the line is found - assert.True(s.T(), found) -} - -func (s *PlatformifyDjangoSuiteTester) TestSettingsFileNotFound() { - // GIVEN mock buffer to store PSH settings file - buff := &MockBuffer{} - // AND working directory is a current directory - input := &UserInput{WorkingDirectory: s.cwd} - // AND the settings.py file doesn't exist - s.fileSystem.EXPECT(). - Find("", settingsPyFile, true). - Return([]string{}).Times(1) - - // WHEN run config files creation - p := newDjangoPlatformifier(s.templates, s.fileSystem) - err := p.Platformify(context.Background(), input) - // THEN it doesn't return any errors - assert.NoError(s.T(), err) - // AND the buffer is empty - assert.Empty(s.T(), buff) -} - -func (s *PlatformifyDjangoSuiteTester) TestPSHSettingsFileCreationError() { - // GIVEN working directory is a current directory - input := &UserInput{WorkingDirectory: s.cwd} - // AND the settings.py file exists - s.fileSystem.EXPECT(). - Find("", settingsPyFile, true). - Return([]string{settingsPyFile}).Times(1) - // AND creating PSH settings file fails - s.fileSystem.EXPECT(). - Create(gomock.Eq(settingsPshPyFile)). - Return(nil, errors.New("")).Times(1) - - // WHEN run config files creation - p := newDjangoPlatformifier(s.templates, s.fileSystem) - err := p.Platformify(context.Background(), input) - // THEN it fails - assert.Error(s.T(), err) -} - -func TestPlatformifyDjangoSuite(t *testing.T) { - suite.Run(t, new(PlatformifyDjangoSuiteTester)) -} diff --git a/internal/platformify/platformifier/fs.go b/internal/platformify/platformifier/fs.go deleted file mode 100644 index 9d6062722..000000000 --- a/internal/platformify/platformifier/fs.go +++ /dev/null @@ -1,90 +0,0 @@ -package platformifier - -import ( - "errors" - "io" - "io/fs" - "os" - "path" - "path/filepath" - "slices" - "strings" -) - -var skipDirs = []string{ - "vendor", - "node_modules", - ".next", - ".git", -} - -//go:generate mockgen -destination=fs_mock_test.go -package=platformifier -source=fs.go -type FS interface { - Create(name string) (io.WriteCloser, error) - Find(root, name string, firstMatch bool) []string - Open(name string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) -} - -func NewOSFileSystem(root string) *OSFileSystem { - return &OSFileSystem{ - root: root, - } -} - -type OSFileSystem struct { - root string -} - -func (f *OSFileSystem) Open(name string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) { - return os.OpenFile(f.fullPath(name), flag, perm) -} - -func (f *OSFileSystem) Create(name string) (io.WriteCloser, error) { - filePath := f.fullPath(name) - if err := os.MkdirAll(path.Dir(filePath), os.ModeDir|os.ModePerm); err != nil { - return nil, err - } - - return os.Create(filePath) -} - -// Find searches for the file inside the path recursively and returns all matches -func (f *OSFileSystem) Find(root, name string, firstMatch bool) []string { - root = strings.TrimPrefix(root, "/") - if root == "" { - root = "." - } - found := make([]string, 0) - _ = fs.WalkDir(f.readonly(), root, func(p string, d os.DirEntry, err error) error { - if err != nil { - return err - } - - if d.IsDir() { - // Skip vendor directories - if slices.Contains(skipDirs, d.Name()) { - return filepath.SkipDir - } - return nil - } - - if d.Name() == name { - found = append(found, p) - if firstMatch { - return errors.New("found") - } - } - - return nil - }) - - return found -} - -func (f *OSFileSystem) readonly() fs.FS { - return os.DirFS(f.root) -} - -func (f *OSFileSystem) fullPath(name string) string { - return filepath.Join(f.root, name) -} diff --git a/internal/platformify/platformifier/fs_mock_test.go b/internal/platformify/platformifier/fs_mock_test.go deleted file mode 100644 index af7f785eb..000000000 --- a/internal/platformify/platformifier/fs_mock_test.go +++ /dev/null @@ -1,80 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: fs.go - -// Package platformifier is a generated GoMock package. -package platformifier - -import ( - io "io" - os "os" - reflect "reflect" - - gomock "github.com/golang/mock/gomock" -) - -// MockFS is a mock of FS interface. -type MockFS struct { - ctrl *gomock.Controller - recorder *MockFSMockRecorder -} - -// MockFSMockRecorder is the mock recorder for MockFS. -type MockFSMockRecorder struct { - mock *MockFS -} - -// NewMockFS creates a new mock instance. -func NewMockFS(ctrl *gomock.Controller) *MockFS { - mock := &MockFS{ctrl: ctrl} - mock.recorder = &MockFSMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockFS) EXPECT() *MockFSMockRecorder { - return m.recorder -} - -// Create mocks base method. -func (m *MockFS) Create(name string) (io.WriteCloser, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", name) - ret0, _ := ret[0].(io.WriteCloser) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Create indicates an expected call of Create. -func (mr *MockFSMockRecorder) Create(name interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockFS)(nil).Create), name) -} - -// Find mocks base method. -func (m *MockFS) Find(root, name string, firstMatch bool) []string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Find", root, name, firstMatch) - ret0, _ := ret[0].([]string) - return ret0 -} - -// Find indicates an expected call of Find. -func (mr *MockFSMockRecorder) Find(root, name, firstMatch interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockFS)(nil).Find), root, name, firstMatch) -} - -// Open mocks base method. -func (m *MockFS) Open(name string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Open", name, flag, perm) - ret0, _ := ret[0].(io.ReadWriteCloser) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Open indicates an expected call of Open. -func (mr *MockFSMockRecorder) Open(name, flag, perm interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Open", reflect.TypeOf((*MockFS)(nil).Open), name, flag, perm) -} diff --git a/internal/platformify/platformifier/generic.go b/internal/platformify/platformifier/generic.go deleted file mode 100644 index 152ee9351..000000000 --- a/internal/platformify/platformifier/generic.go +++ /dev/null @@ -1,65 +0,0 @@ -package platformifier - -import ( - "bytes" - "context" - "fmt" - "io" - "io/fs" - "strings" - "text/template" - - "github.com/upsun/cli/internal/platformify/vendorization" - - "github.com/Masterminds/sprig/v3" -) - -func newGenericPlatformifier(templates fs.FS, fileSystem FS) *genericPlatformifier { - return &genericPlatformifier{ - templates: templates, - fileSystem: fileSystem, - } -} - -// genericPlatformifier contains the configuration for the application to Platformify -type genericPlatformifier struct { - templates fs.FS - fileSystem FS -} - -// Platformify will generate the needed configuration files in the current directory. -func (p *genericPlatformifier) Platformify(ctx context.Context, input *UserInput) error { - assets, _ := vendorization.FromContext(ctx) - err := fs.WalkDir(p.templates, ".", func(name string, d fs.DirEntry, _ error) error { - if d.IsDir() { - return nil - } - tpl := template.Must(template.New(d.Name()).Funcs(sprig.FuncMap()).ParseFS(p.templates, name)) - contents := &bytes.Buffer{} - if err := tpl.Execute(contents, templateData{input, assets}); err != nil { - return err - } - - // Skip empty files - if strings.TrimSpace(contents.String()) == "" { - return nil - } - - f, writeErr := p.fileSystem.Create(name) - if writeErr != nil { - return fmt.Errorf("could not write template: %w", writeErr) - } - defer f.Close() - - if _, err := io.Copy(f, contents); err != nil { - return err - } - - return nil - }) - if err != nil { - return err - } - - return nil -} diff --git a/internal/platformify/platformifier/generic_test.go b/internal/platformify/platformifier/generic_test.go deleted file mode 100644 index 86db6f5d5..000000000 --- a/internal/platformify/platformifier/generic_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package platformifier - -import ( - "bytes" - "context" - "embed" - "errors" - "io/fs" - "os" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" -) - -const ( - genericTemplatesDir = "templates/generic" - environmentFile = ".environment" - appConfigFile = ".platform.app.yaml" - routesConfigFile = ".platform/routes.yaml" - servicesConfigFile = ".platform/services.yaml" -) - -var ( - //go:embed templates/generic/* - testGenericTemplatesFS embed.FS -) - -type MockBuffer struct { - bytes.Buffer -} - -func (b *MockBuffer) Close() error { - return nil -} - -type PlatformifyGenericSuiteTester struct { - suite.Suite - - cwd string - templates fs.FS - fileSystem *MockFS -} - -func (s *PlatformifyGenericSuiteTester) SetupTest() { - ctrl := gomock.NewController(s.T()) - - cwd, err := os.Getwd() - require.NoError(s.T(), err) - s.cwd = cwd - - templates, err := fs.Sub(testGenericTemplatesFS, genericTemplatesDir) - require.NoError(s.T(), err) - s.templates = templates - - s.fileSystem = NewMockFS(ctrl) -} - -func (s *PlatformifyGenericSuiteTester) TestSuccessfulConfigsCreation() { - // GIVEN mock buffers to store config files - envBuff, appBuff, routesBuff, servicesBuff := &MockBuffer{}, &MockBuffer{}, &MockBuffer{}, &MockBuffer{} - // AND working directory is a current directory - input := &UserInput{WorkingDirectory: s.cwd} - // AND creation of the environment file returns no errors - s.fileSystem.EXPECT(). - Create(gomock.Eq(environmentFile)). - Return(envBuff, nil).Times(1) - // AND creation of the app config file returns no errors - s.fileSystem.EXPECT(). - Create(gomock.Eq(appConfigFile)). - Return(appBuff, nil).Times(1) - // AND creation of the routes config file returns no errors - s.fileSystem.EXPECT(). - Create(gomock.Eq(routesConfigFile)). - Return(routesBuff, nil).Times(1) - // AND creation of the services config file returns no errors - s.fileSystem.EXPECT(). - Create(gomock.Eq(servicesConfigFile)). - Return(servicesBuff, nil).Times(1) - - // WHEN run config files creation - p := newGenericPlatformifier(s.templates, s.fileSystem) - err := p.Platformify(context.Background(), input) - // THEN it doesn't return any errors - assert.NoError(s.T(), err) - // AND the buffers contain configs - assert.NotEmpty(s.T(), envBuff) - assert.NotEmpty(s.T(), appBuff) - assert.NotEmpty(s.T(), routesBuff) - assert.NotEmpty(s.T(), servicesBuff) -} - -func (s *PlatformifyGenericSuiteTester) TestEnvironmentCreationError() { - // GIVEN working directory is a current directory - input := &UserInput{WorkingDirectory: s.cwd} - // AND creating environment file fails - s.fileSystem.EXPECT(). - Create(gomock.Eq(environmentFile)). - Return(nil, errors.New("")).Times(1) - // AND creating other config files work fine - s.fileSystem.EXPECT(). - Create(gomock.Any()). - Return(&MockBuffer{}, nil).AnyTimes() - - // WHEN run config files creation - p := newGenericPlatformifier(s.templates, s.fileSystem) - err := p.Platformify(context.Background(), input) - // THEN it fails - assert.Error(s.T(), err) -} - -func (s *PlatformifyGenericSuiteTester) TestAppConfigCreationError() { - // GIVEN working directory is a current directory - input := &UserInput{WorkingDirectory: s.cwd} - // AND creating app config file fails - s.fileSystem.EXPECT(). - Create(gomock.Eq(appConfigFile)). - Return(nil, errors.New("")).Times(1) - // AND creating other config files work fine - s.fileSystem.EXPECT(). - Create(gomock.Any()). - Return(&MockBuffer{}, nil).AnyTimes() - - // WHEN run config files creation - p := newGenericPlatformifier(s.templates, s.fileSystem) - err := p.Platformify(context.Background(), input) - // THEN it fails - assert.Error(s.T(), err) -} - -func (s *PlatformifyGenericSuiteTester) TestRoutesConfigCreationError() { - // GIVEN working directory is a current directory - input := &UserInput{WorkingDirectory: s.cwd} - // AND creating routes config file fails - s.fileSystem.EXPECT(). - Create(gomock.Eq(routesConfigFile)). - Return(nil, errors.New("")).Times(1) - // AND creating other config files work fine - s.fileSystem.EXPECT(). - Create(gomock.Any()). - Return(&MockBuffer{}, nil).AnyTimes() - - // WHEN run config files creation - p := newGenericPlatformifier(s.templates, s.fileSystem) - err := p.Platformify(context.Background(), input) - // THEN it fails - assert.Error(s.T(), err) -} - -func (s *PlatformifyGenericSuiteTester) TestServicesConfigCreationError() { - // GIVEN working directory is a current directory - input := &UserInput{WorkingDirectory: s.cwd} - // AND creating services config file fails - s.fileSystem.EXPECT(). - Create(gomock.Eq(servicesConfigFile)). - Return(nil, errors.New("")).Times(1) - // AND creating other config files work fine - s.fileSystem.EXPECT(). - Create(gomock.Any()). - Return(&MockBuffer{}, nil).AnyTimes() - - // WHEN run config files creation - p := newGenericPlatformifier(s.templates, s.fileSystem) - err := p.Platformify(context.Background(), input) - // THEN it fails - assert.Error(s.T(), err) -} - -func TestPlatformifyGenericSuite(t *testing.T) { - suite.Run(t, new(PlatformifyGenericSuiteTester)) -} diff --git a/internal/platformify/platformifier/laravel.go b/internal/platformify/platformifier/laravel.go deleted file mode 100644 index 878e8d4f8..000000000 --- a/internal/platformify/platformifier/laravel.go +++ /dev/null @@ -1,48 +0,0 @@ -package platformifier - -import ( - "context" - "fmt" - "io/fs" - "path/filepath" - - "github.com/upsun/cli/internal/platformify/colors" - "github.com/upsun/cli/internal/platformify/utils" -) - -const ( - composerJSONFile = "composer.json" -) - -func newLaravelPlatformifier(templates fs.FS, fileSystem FS) *laravelPlatformifier { - return &laravelPlatformifier{ - templates: templates, - fileSystem: fileSystem, - } -} - -type laravelPlatformifier struct { - templates fs.FS - fileSystem FS -} - -func (p *laravelPlatformifier) Platformify(ctx context.Context, input *UserInput) error { - // Check for the Laravel Bridge. - appRoot := filepath.Join(input.Root, input.ApplicationRoot) - composerJSONPaths := p.fileSystem.Find(appRoot, composerJSONFile, false) - for _, composerJSONPath := range composerJSONPaths { - _, required := utils.GetJSONValue([]string{"require", "platformsh/laravel-bridge"}, composerJSONPath, true) - if !required { - out, _, ok := colors.FromContext(ctx) - if !ok { - return fmt.Errorf("output context failed") - } - - var suggest = "\nPlease use composer to add the Laravel Bridge to your project:\n" - var composerRequire = "\n composer require platformsh/laravel-bridge\n" - fmt.Fprintln(out, colors.Colorize(colors.WarningCode, suggest+composerRequire)) - } - } - - return nil -} diff --git a/internal/platformify/platformifier/models.go b/internal/platformify/platformifier/models.go deleted file mode 100644 index 681626124..000000000 --- a/internal/platformify/platformifier/models.go +++ /dev/null @@ -1,130 +0,0 @@ -package platformifier - -import ( - "strings" -) - -var ( - databases = []string{ - "mariadb", - "mysql", - "oracle-mysql", - "postgresql", - } - caches = []string{ - "redis", - "redis-persistent", - "memcached", - } -) - -const ( - Generic Stack = iota - Django - Laravel - NextJS - Strapi - Flask - Express - Rails -) - -type Stack int - -func (s Stack) Name() string { - switch s { - case Generic: - return "generic" - case Django: - return "django" - case Rails: - return "rails" - case Laravel: - return "laravel" - case NextJS: - return "nextjs" - case Strapi: - return "strapi" - case Flask: - return "flask" - case Express: - return "express" - default: - return "" - } -} - -type Relationship struct { - Service string - Endpoint string -} - -// UserInput contains the configuration from user input. -type UserInput struct { - Stack Stack - Root string - ApplicationRoot string - Name string - Type string - Runtime string - Environment map[string]string - BuildSteps []string - WebCommand string - SocketFamily string - DeployCommand []string - DependencyManagers []string - Locations map[string]map[string]interface{} - Dependencies map[string]map[string]string - BuildFlavor string - Disk string - Mounts map[string]map[string]string - Services []Service - Relationships map[string]Relationship - WorkingDirectory string - HasGit bool -} - -// Service contains the configuration for a service needed by the application. -type Service struct { - Name string - Type string - TypeVersions []string - Disk string - DiskSizes []string -} - -// Database returns the first service that is a database. -func (ui *UserInput) Database() string { - for _, service := range ui.Services { - for _, db := range databases { - if strings.Contains(service.Type, db) { - return service.Name - } - } - } - - return "" -} - -// DatabaseUpper returns the uppercase slug for the first service that is a database. -func (ui *UserInput) DatabaseUpper() string { - return strings.ToUpper(strings.ReplaceAll(ui.Database(), "-", "_")) -} - -// Cache returns the first service that is a cache. -func (ui *UserInput) Cache() string { - for _, service := range ui.Services { - for _, cache := range caches { - if strings.Contains(service.Type, cache) { - return service.Name - } - } - } - - return "" -} - -// CacheUpper returns the uppercase slug for the first service that is a cache. -func (ui *UserInput) CacheUpper() string { - return strings.ToUpper(strings.ReplaceAll(ui.Cache(), "-", "_")) -} diff --git a/internal/platformify/platformifier/nextjs.go b/internal/platformify/platformifier/nextjs.go deleted file mode 100644 index 5fcc37525..000000000 --- a/internal/platformify/platformifier/nextjs.go +++ /dev/null @@ -1,20 +0,0 @@ -package platformifier - -import ( - "context" - "io/fs" -) - -func newNextJSPlatformifier(templates fs.FS) *nextJSPlatformifier { - return &nextJSPlatformifier{ - templates: templates, - } -} - -type nextJSPlatformifier struct { - templates fs.FS -} - -func (p *nextJSPlatformifier) Platformify(_ context.Context, _ *UserInput) error { - return nil -} diff --git a/internal/platformify/platformifier/platformifier.go b/internal/platformify/platformifier/platformifier.go deleted file mode 100644 index c8e715cf0..000000000 --- a/internal/platformify/platformifier/platformifier.go +++ /dev/null @@ -1,97 +0,0 @@ -package platformifier - -import ( - "context" - "embed" - "io/fs" - - "github.com/upsun/cli/internal/platformify/vendorization" -) - -var ( - //go:embed templates/**/* - templatesFS embed.FS -) - -const ( - // Path names passed to open are UTF-8-encoded, - // unrooted, slash-separated sequences of path elements, like "x/y/z". - // Path names MUST NOT contain an element that is "." or ".." or the empty string, - // Paths MUST NOT start or end with a slash: "/x" and "x/" are invalid. - genericDir = "templates/generic" - upsunDir = "templates/upsun" - djangoDir = "templates/django" - laravelDir = "templates/laravel" - nextjsDir = "templates/nextjs" -) - -// A platformifier handles the business logic of a given runtime to platformify. -// -//go:generate mockgen -destination=platformifier_mock_test.go -package=platformifier -source=platformifier.go -type platformifier interface { - // Platformify loads and writes the templates to the user's system. - Platformify(ctx context.Context, input *UserInput) error -} - -type templateData struct { - *UserInput - Assets *vendorization.VendorAssets -} - -// New creates Platformifier with the appropriate platformifier stack based on UserInput. -func New(input *UserInput, flavor string, fileSystems ...FS) *Platformifier { - var fileSystem FS - if len(fileSystems) > 0 { - fileSystem = fileSystems[0] - } else { - fileSystem = NewOSFileSystem(input.WorkingDirectory) - } - - // fs.Sub(...) returns an error only if the given path name is invalid. - // Since we determine the path name ourselves in advance, - // there is no need to check for errors in this path name. - stacks := []platformifier{} - templatesDir := genericDir - if flavor == "upsun" { - templatesDir = upsunDir - } - - templates, _ := fs.Sub(templatesFS, templatesDir) - stacks = append(stacks, newGenericPlatformifier(templates, fileSystem)) - - switch input.Stack { - case Django: - // No need to check for errors (see the comment above) - templates, _ := fs.Sub(templatesFS, djangoDir) - stacks = append(stacks, newDjangoPlatformifier(templates, fileSystem)) - case Laravel: - // No need to check for errors (see the comment above) - templates, _ := fs.Sub(templatesFS, laravelDir) - stacks = append(stacks, newLaravelPlatformifier(templates, fileSystem)) - case NextJS: - // No need to check for errors (see the comment above) - templates, _ := fs.Sub(templatesFS, nextjsDir) - stacks = append(stacks, newNextJSPlatformifier(templates)) - } - - return &Platformifier{ - input: input, - stacks: stacks, - } -} - -// A Platformifier handles the business logic of a given runtime to platformify. -type Platformifier struct { - input *UserInput - stacks []platformifier -} - -func (p *Platformifier) Platformify(ctx context.Context) error { - for _, stack := range p.stacks { - err := stack.Platformify(ctx, p.input) - if err != nil { - return err - } - } - return nil -} diff --git a/internal/platformify/platformifier/platformifier_mock_test.go b/internal/platformify/platformifier/platformifier_mock_test.go deleted file mode 100644 index 82a4a678f..000000000 --- a/internal/platformify/platformifier/platformifier_mock_test.go +++ /dev/null @@ -1,49 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: platformifier.go - -// Package platformifier is a generated GoMock package. -package platformifier - -import ( - context "context" - reflect "reflect" - - gomock "github.com/golang/mock/gomock" -) - -// Mockplatformifier is a mock of platformifier interface. -type Mockplatformifier struct { - ctrl *gomock.Controller - recorder *MockplatformifierMockRecorder -} - -// MockplatformifierMockRecorder is the mock recorder for Mockplatformifier. -type MockplatformifierMockRecorder struct { - mock *Mockplatformifier -} - -// NewMockplatformifier creates a new mock instance. -func NewMockplatformifier(ctrl *gomock.Controller) *Mockplatformifier { - mock := &Mockplatformifier{ctrl: ctrl} - mock.recorder = &MockplatformifierMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *Mockplatformifier) EXPECT() *MockplatformifierMockRecorder { - return m.recorder -} - -// Platformify mocks base method. -func (m *Mockplatformifier) Platformify(ctx context.Context, input *UserInput) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Platformify", ctx, input) - ret0, _ := ret[0].(error) - return ret0 -} - -// Platformify indicates an expected call of Platformify. -func (mr *MockplatformifierMockRecorder) Platformify(ctx, input interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Platformify", reflect.TypeOf((*Mockplatformifier)(nil).Platformify), ctx, input) -} diff --git a/internal/platformify/platformifier/platformifier_test.go b/internal/platformify/platformifier/platformifier_test.go deleted file mode 100644 index 5f8dfbf43..000000000 --- a/internal/platformify/platformifier/platformifier_test.go +++ /dev/null @@ -1,595 +0,0 @@ -package platformifier - -import ( - "context" - "errors" - "io/fs" - "os" - "reflect" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" - - "github.com/upsun/cli/pkg/lint" -) - -var ( - djangoStack = &UserInput{ - Name: "django", - Type: "python:3.14", - Stack: Django, - Runtime: "python", - ApplicationRoot: "app", - Environment: map[string]string{ - "DJANGO_SETTINGS_MODULE": "app.settings", - "PYTHONUNBUFFERED": "1", - }, - Root: "app", - BuildFlavor: "none", - BuildSteps: []string{ - "pip install -r requirements.txt", - "# comment here", - "python manage.py collectstatic --noinput", - }, - WebCommand: "gunicorn app.wsgi", - SocketFamily: "unix", - DeployCommand: []string{ - "python manage.py migrate", - "# comment here", - }, - DependencyManagers: []string{"pip", "yarn"}, - Locations: map[string]map[string]interface{}{ - "/": { - "passthru": true, - }, - "/static": { - "root": "static", - "expires": "1h", - "allow": true, - }, - }, - Dependencies: map[string]map[string]string{ - "python": { - "poetry": "*", - "pip": ">=20.0.0", - }, - "nodejs": { - "yarn": "*", - "npm": ">=6.0.0", - }, - }, - Disk: "1024", - Mounts: map[string]map[string]string{ - "/.npm": { - "source": "local", - "source_path": "npm", - }, - "/.pip": { - "source": "local", - "source_path": "pip", - }, - }, - Relationships: map[string]Relationship{ - "db": {Service: "db", Endpoint: "postgresql"}, - "mysql": {Service: "mysql", Endpoint: "mysql"}, - }, - HasGit: true, - Services: []Service{ - { - Name: "db", - Type: "postgresql:17", - TypeVersions: []string{"17", "16", "15", "14", "13", "12"}, - Disk: "1024", - DiskSizes: []string{"1024", "2048"}, - }, - { - Name: "mysql", - Type: "mysql:11.8", - TypeVersions: []string{"11.0", "10.11", "10.6", "10.5", "10.4", "10.3"}, - Disk: "1024", - DiskSizes: []string{"1024", "2034"}, - }, - }, - } - genericStack = &UserInput{ - Name: "generic", - Type: "java:21", - Runtime: "java", - Stack: Generic, - Environment: map[string]string{ - "JAVA": "19", - }, - Root: "app", - BuildFlavor: "", - BuildSteps: []string{ - "mvn install", - }, - WebCommand: "tomcat", - SocketFamily: "tcp", - DeployCommand: []string{}, - DependencyManagers: []string{"mvn"}, - Locations: map[string]map[string]interface{}{ - "/": { - "passthru": true, - }, - }, - Dependencies: map[string]map[string]string{}, - Disk: "1024", - Mounts: map[string]map[string]string{ - "/.mvn": { - "source": "local", - "source_path": "maven", - }, - }, - Relationships: map[string]Relationship{ - "mysql": {Service: "mysql", Endpoint: "mysql"}, - }, - HasGit: true, - Services: []Service{ - { - Name: "mysql", - Type: "mysql:11.8", - TypeVersions: []string{"13", "14", "15"}, - Disk: "1024", - DiskSizes: []string{"1024", "2048"}, - }, - }, - } - laravelStack = &UserInput{ - Name: "laravel", - Type: "php:8.4", - Stack: Laravel, - Runtime: "php", - ApplicationRoot: "app", - Environment: map[string]string{}, - Root: "app", - BuildFlavor: "php", - BuildSteps: []string{}, - DeployCommand: []string{}, - DependencyManagers: []string{"composer"}, - Locations: map[string]map[string]interface{}{ - "/": { - "root": "index.php", - }, - }, - Dependencies: map[string]map[string]string{}, - Disk: "", - Mounts: map[string]map[string]string{}, - Relationships: map[string]Relationship{}, - HasGit: false, - Services: []Service{}, - } - nextJSStack = &UserInput{ - Name: "nextjs", - Type: "nodejs:24", - Runtime: "nodejs", - Stack: NextJS, - } - strapiStack = &UserInput{ - Name: "strapi", - Type: "nodejs:24", - Runtime: "nodejs", - Stack: Strapi, - } - flaskStack = &UserInput{ - Name: "flask", - Type: "python:3.14", - Runtime: "python", - Stack: Flask, - } - expressStack = &UserInput{ - Name: "express", - Type: "nodejs:24", - Runtime: "nodejs", - Stack: Express, - } -) - -func TestNewPlatformifier(t *testing.T) { - genericTemplates, err := fs.Sub(templatesFS, genericDir) - require.NoError(t, err) - djangoTemplates, err := fs.Sub(templatesFS, djangoDir) - require.NoError(t, err) - laravelTemplates, err := fs.Sub(templatesFS, laravelDir) - require.NoError(t, err) - nextjsTemplates, err := fs.Sub(templatesFS, nextjsDir) - require.NoError(t, err) - fileSystem := NewOSFileSystem("") - tests := []struct { - name string - stack Stack - platformifiers []platformifier - }{ - { - name: "generic", - stack: Generic, - platformifiers: []platformifier{ - &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, - }, - }, - { - name: "django", - stack: Django, - platformifiers: []platformifier{ - &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, - &djangoPlatformifier{templates: djangoTemplates, fileSystem: fileSystem}, - }, - }, - { - name: "laravel", - stack: Laravel, - platformifiers: []platformifier{ - &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, - &laravelPlatformifier{templates: laravelTemplates, fileSystem: fileSystem}, - }, - }, - { - name: "nextjs", - stack: NextJS, - platformifiers: []platformifier{ - &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, - &nextJSPlatformifier{templates: nextjsTemplates}, - }, - }, - { - name: "strapi", - stack: Strapi, - platformifiers: []platformifier{ - &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, - }, - }, - { - name: "flask", - stack: Flask, - platformifiers: []platformifier{ - &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, - }, - }, - { - name: "express", - stack: Express, - platformifiers: []platformifier{ - &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // GIVEN user input with given stack - input := &UserInput{Stack: tt.stack} - - // WHEN create new platformifier - pfier := New(input, "platform") - // THEN user input inside platformifier should be the same as given - assert.Equal(t, input, pfier.input) - // AND length of the platformifier's stack must be equal to the length of expected stacks - require.Len(t, pfier.stacks, len(tt.platformifiers)) - for i := range pfier.stacks { - // AND the type of each stack should be the same as expected - assert.IsType(t, tt.platformifiers[i], pfier.stacks[i]) - assert.True(t, reflect.DeepEqual(tt.platformifiers[i], pfier.stacks[i])) - } - }) - } -} - -type PlatformifySuiteTester struct { - suite.Suite - - generic *Mockplatformifier - django *Mockplatformifier - laravel *Mockplatformifier - nextjs *Mockplatformifier -} - -func (s *PlatformifySuiteTester) SetupTest() { - ctrl := gomock.NewController(s.T()) - - s.generic = NewMockplatformifier(ctrl) - s.django = NewMockplatformifier(ctrl) - s.laravel = NewMockplatformifier(ctrl) - s.nextjs = NewMockplatformifier(ctrl) -} - -func (s *PlatformifySuiteTester) TestSuccessfulPlatformifying() { - // GIVEN empty context - ctx := context.Background() - // AND user input is empty (because it doesn't matter if it's empty or not) - input := &UserInput{} - // AND platformifying generic stack returns no errors - s.generic.EXPECT(). - Platformify(gomock.Eq(ctx), gomock.Eq(input)). - Return(nil).AnyTimes() - // AND platformifying django stack returns no errors - s.django.EXPECT(). - Platformify(gomock.Eq(ctx), gomock.Eq(input)). - Return(nil).AnyTimes() - // AND platformifying laravel stack returns no errors - s.laravel.EXPECT(). - Platformify(gomock.Eq(ctx), gomock.Eq(input)). - Return(nil).AnyTimes() - // AND platformifying nextjs stack returns no errors - s.nextjs.EXPECT(). - Platformify(gomock.Eq(ctx), gomock.Eq(input)). - Return(nil).AnyTimes() - - tests := []struct { - name string - stacks []platformifier - }{ - { - name: "empty", - stacks: []platformifier{}, - }, - { - name: "generic", - stacks: []platformifier{s.generic}, - }, - { - name: "django", - stacks: []platformifier{s.django}, - }, - { - name: "laravel", - stacks: []platformifier{s.laravel}, - }, - { - name: "nextjs", - stacks: []platformifier{s.nextjs}, - }, - { - name: "generic+django", - stacks: []platformifier{s.generic, s.django}, - }, - { - name: "generic+laravel", - stacks: []platformifier{s.generic, s.laravel}, - }, - { - name: "generic+nextjs", - stacks: []platformifier{s.generic, s.nextjs}, - }, - { - name: "all", - stacks: []platformifier{s.generic, s.django, s.laravel, s.nextjs}, - }, - } - - for _, tt := range tests { - s.T().Run(tt.name, func(t *testing.T) { - // WHEN run platformifying of the given stack - p := Platformifier{ - input: input, - stacks: tt.stacks, - } - err := p.Platformify(ctx) - // THEN it doesn't return any errors - assert.NoError(t, err) - }) - } -} - -func (s *PlatformifySuiteTester) TestPlatformifyingError() { - // GIVEN empty context - ctx := context.Background() - // AND user input is empty (because it doesn't matter if it's empty or not) - input := &UserInput{} - // AND platformifying generic stack fails - s.generic.EXPECT(). - Platformify(gomock.Eq(ctx), gomock.Eq(input)). - Return(errors.New("")).AnyTimes() - // AND platformifying django stack fails - s.django.EXPECT(). - Platformify(gomock.Eq(ctx), gomock.Eq(input)). - Return(errors.New("")).AnyTimes() - // AND platformifying laravel stack fails - s.laravel.EXPECT(). - Platformify(gomock.Eq(ctx), gomock.Eq(input)). - Return(errors.New("")).AnyTimes() - // AND platformifying nextjs stack fails - s.nextjs.EXPECT(). - Platformify(gomock.Eq(ctx), gomock.Eq(input)). - Return(errors.New("")).AnyTimes() - - tests := []struct { - name string - stacks []platformifier - }{ - { - name: "generic", - stacks: []platformifier{s.generic}, - }, - { - name: "django", - stacks: []platformifier{s.django}, - }, - { - name: "laravel", - stacks: []platformifier{s.laravel}, - }, - { - name: "nextjs", - stacks: []platformifier{s.nextjs}, - }, - { - name: "generic+django", - stacks: []platformifier{s.generic, s.django}, - }, - { - name: "generic+laravel", - stacks: []platformifier{s.generic, s.laravel}, - }, - { - name: "generic+nextjs", - stacks: []platformifier{s.generic, s.nextjs}, - }, - { - name: "all", - stacks: []platformifier{s.generic, s.django, s.laravel, s.nextjs}, - }, - } - - for _, tt := range tests { - s.T().Run(tt.name, func(t *testing.T) { - // WHEN run platformifying of the given stack - p := Platformifier{ - input: input, - stacks: tt.stacks, - } - err := p.Platformify(ctx) - // THEN it fails - assert.Error(t, err) - }) - } -} - -func TestPlatformifySuite(t *testing.T) { - suite.Run(t, new(PlatformifySuiteTester)) -} - -func TestPlatformifier_Platformify(t *testing.T) { - type fields struct { - ui *UserInput - } - tests := []struct { - name string - fields fields - wantErr bool - }{ - { - name: "Django", - fields: fields{ui: djangoStack}, - }, - { - name: "Generic", - fields: fields{ui: genericStack}, - }, - { - name: "Laravel", - fields: fields{ui: laravelStack}, - }, - { - name: "Next.js", - fields: fields{ui: nextJSStack}, - }, - { - name: "Strapi", - fields: fields{ui: strapiStack}, - }, - { - name: "Flask", - fields: fields{ui: flaskStack}, - }, - { - name: "Express", - fields: fields{ui: expressStack}, - }, - } - - // Create a temporary directory to use as the output directory. - tempDir, err := os.MkdirTemp("", "yaml_tests") - if err != nil { - t.Fatalf("Failed to create temporary directory: %v", err) - } - defer os.RemoveAll(tempDir) - - ctx := context.Background() - for _, tt := range tests { - dir, err := os.MkdirTemp(tempDir, tt.name) - if err != nil { - t.Fatalf("Failed to create temporary %v directory: %v", tt.name, err) - } - tt.fields.ui.WorkingDirectory = dir - t.Run(tt.name, func(t *testing.T) { - if err := New(tt.fields.ui, "platform").Platformify(ctx); (err != nil) != tt.wantErr { - t.Errorf("Platformifier.Platformify() error = %v, wantErr %v", err, tt.wantErr) - } - - // Validate the config. - if err := lintConfig(dir); (err != nil) != tt.wantErr { - t.Errorf("Platformifier.Platformify() validation error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func TestPlatformifier_Upsunify(t *testing.T) { - type fields struct { - ui *UserInput - } - tests := []struct { - name string - fields fields - wantErr bool - }{ - { - name: "Django", - fields: fields{ui: djangoStack}, - }, - { - name: "Generic", - fields: fields{ui: genericStack}, - }, - { - name: "Laravel", - fields: fields{ui: laravelStack}, - }, - { - name: "Next.js", - fields: fields{ui: nextJSStack}, - }, - { - name: "Strapi", - fields: fields{ui: strapiStack}, - }, - { - name: "Flask", - fields: fields{ui: flaskStack}, - }, - { - name: "Express", - fields: fields{ui: expressStack}, - }, - } - - // Create a temporary directory to use as the output directory. - tempDir, err := os.MkdirTemp("", "yaml_tests") - if err != nil { - t.Fatalf("Failed to create temporary directory: %v", err) - } - defer os.RemoveAll(tempDir) - - ctx := context.Background() - for _, tt := range tests { - dir, err := os.MkdirTemp(tempDir, tt.name) - if err != nil { - t.Fatalf("Failed to create temporary %v directory: %v", tt.name, err) - } - tt.fields.ui.WorkingDirectory = dir - t.Run(tt.name, func(t *testing.T) { - if err := New(tt.fields.ui, "upsun").Platformify(ctx); (err != nil) != tt.wantErr { - t.Errorf("Platformifier.Platformify() error = %v, wantErr %v", err, tt.wantErr) - } - - // Validate the config. - if err := lintConfig(dir); (err != nil) != tt.wantErr { - t.Errorf("Platformifier.Platformify() validation error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -// lintConfig checks the configuration in dir with the CLI's linter. -func lintConfig(dir string) error { - result, err := lint.CheckDir(dir) - if err != nil { - return err - } - if result.HasErrors() { - return errors.New(result.String()) - } - return nil -} diff --git a/internal/platformify/platformify.go b/internal/platformify/platformify.go index 5264130c1..8020241cb 100644 --- a/internal/platformify/platformify.go +++ b/internal/platformify/platformify.go @@ -1,87 +1,250 @@ +// Package platformify generates a starter configuration for a project, by +// detecting its stack and asking a few questions (the guided mode of "init"). package platformify import ( - "context" "errors" "fmt" "io" + "os" + "path/filepath" + "regexp" + "slices" + "strings" - "github.com/spf13/cobra" + "github.com/AlecAivazis/survey/v2" - "github.com/upsun/cli/internal/platformify/colors" - "github.com/upsun/cli/internal/platformify/platformifier" - "github.com/upsun/cli/internal/platformify/question" - "github.com/upsun/cli/internal/platformify/question/models" - "github.com/upsun/cli/internal/platformify/questionnaire" - "github.com/upsun/cli/internal/platformify/vendorization" + "github.com/upsun/cli/internal/config" ) -type contextKey string +// Options configures Run. +type Options struct { + // Dir is the project's root directory, where files are written. + Dir string + Stdout io.Writer + Stderr io.Writer +} + +// errAborted stops the wizard without an error. +var errAborted = errors.New("aborted") -var FlavorKey contextKey = "flavor" +type wizard struct { + dir string + stdout io.Writer + stderr io.Writer + assets *assets + answers *answers +} -func NewPlatformifyCmd(assets *vendorization.VendorAssets) *cobra.Command { - 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) - }, +// Run asks questions about the project in opts.Dir, and writes its configuration files. +func Run(cnf *config.Config, opts Options) error { + w := &wizard{ + dir: opts.Dir, + stdout: opts.Stdout, + stderr: opts.Stderr, + assets: newAssets(cnf), + answers: &answers{}, + } + steps := []func() error{ + w.confirmOverwrite, + w.welcome, + w.detectStack, + w.askRuntime, + w.detectDependencyManagers, + w.setLocations, + w.setMounts, + w.askName, + w.detectApplicationRoot, + w.setEnvironment, + w.setBuildSteps, + w.setDeployCommand, + w.setSocketFamily, + w.setWebCommand, + w.almostDone, + w.askServices, + } + for _, step := range steps { + if err := step(); err != nil { + if errors.Is(err, errAborted) { + return nil + } + return err + } } - return cmd + files, err := generate(w.dir, w.templateInput()) + if err != nil { + return fmt.Errorf("could not configure project: %w", err) + } + if err := writeFiles(w.dir, files); err != nil { + return fmt.Errorf("could not configure project: %w", err) + } + if w.answers.stack == laravel { + w.suggestLaravelBridge() + } + w.done() + return nil } -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, - ) - q := questionnaire.New( - &question.WorkingDirectory{}, - &question.FilesOverwrite{}, - &question.Welcome{}, - &question.Stack{}, - &question.Type{}, - &question.DependencyManager{}, - &question.Locations{}, - &question.Mounts{}, - &question.Name{}, - &question.ApplicationRoot{}, - &question.Environment{}, - &question.BuildSteps{}, - &question.DeployCommand{}, - &question.SocketFamily{}, - &question.WebCommand{}, - &question.AlmostDone{}, - &question.Services{}, - ) - err := q.AskQuestions(ctx) - if errors.Is(err, questionnaire.ErrSilent) { +// writeFiles writes files, keyed by slash-separated paths relative to dir. +func writeFiles(dir string, files map[string][]byte) error { + for name, content := range files { + p := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + if err := os.WriteFile(p, content, 0o644); err != nil { //nolint:gosec // Configuration files are not secret. + return err + } + } + return nil +} + +// confirmOverwrite asks whether to continue, if configuration files exist. +func (w *wizard) confirmOverwrite() error { + var existing []string + for _, f := range w.assets.configFiles() { + if st, err := os.Stat(filepath.Join(w.dir, f)); err == nil && !st.IsDir() { + existing = append(existing, f) + } + } + if len(existing) == 0 { return nil } + fmt.Fprintln(w.stderr, colorize(warningCode, "You are reconfiguring the project at "+w.dir+".")) + fmt.Fprintln(w.stderr, colorize(warningCode, + fmt.Sprintf("The following %s files already exist in this directory:", w.assets.ServiceName))) + for _, f := range existing { + fmt.Fprintln(w.stderr, colorize(warningCode, " - "+f)) + } + proceed := false + if err := survey.AskOne(&survey.Confirm{Message: "Do you want to overwrite them?"}, &proceed); err != nil { + return err + } + if !proceed { + return errAborted + } + return nil +} - if err != nil { - fmt.Fprintln(stderr, colors.Colorize(colors.ErrorCode, err.Error())) +// suggestSymfonyCLI offers to stop, to use the Symfony CLI instead. +func (w *wizard) suggestSymfonyCLI() error { + useSymfonyCLI := 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?", + Default: useSymfonyCLI, + }, &useSymfonyCLI); err != nil { return err } + if useSymfonyCLI { + fmt.Fprintln(w.stderr, colorize(warningCode, + "Check out the Symfony CLI documentation here: "+w.assets.Docs().SymfonyCLI)) + return errAborted + } + return nil +} + +// askRuntime sets the runtime from the stack, or asks for it. +func (w *wizard) askRuntime() error { + a := w.answers + a.runtime = a.stack.runtime() + if a.runtime == "" { + var title string + options := titles(runtimeChoices) + if err := survey.AskOne(&survey.Select{ + Message: "What language is your project using? We support the following:", + Options: options, + }, &title, survey.WithPageSize(len(options))); err != nil { + return err + } + a.runtime = typeByTitle(runtimeChoices, title) + } else { + w.detected("runtime", runtimeTitle(a.runtime)) + } + if v := versions(a.runtime); len(v) > 0 { + a.version = v[0] + } + return nil +} - input := answers.ToUserInput() +func runtimeTitle(typ string) string { + for _, c := range runtimeChoices { + if c.typ == typ { + return c.title + } + } + return typ +} - pfier := platformifier.New(input, assets.ConfigFlavor) - 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) +var ( + invalidNameChars = regexp.MustCompile("[^a-z0-9_-]") + consecutiveDashes = regexp.MustCompile("-+") +) + +func slugify(s string) string { + s = invalidNameChars.ReplaceAllLiteralString(strings.ToLower(strings.TrimSpace(s)), "-") + return consecutiveDashes.ReplaceAllLiteralString(s, "-") +} + +// askName asks for the application name. +func (w *wizard) askName() error { + validSlug := func(val any) error { + if s, _ := val.(string); s != slugify(s) { + return fmt.Errorf("%s: the name can only contain lowercase alphanumeric characters, dashes, or underscores", s) + } + return nil } + return survey.AskOne(&survey.Input{ + Message: "Tell us your project's application name:", + Default: slugify(filepath.Base(w.dir)), + }, &w.answers.name, survey.WithValidator(survey.Required), survey.WithValidator(validSlug)) +} + +// askServices asks which services the project uses. +func (w *wizard) askServices() error { + fmt.Fprintln(w.stdout, colorize(accentCode, + "Last but not least, unless you’re creating a static website, your project uses services. Let’s define them:")) + fmt.Fprintln(w.stdout) - done := question.Done{} - return done.Ask(ctx) + var selected []string + for { + if err := survey.AskOne(&survey.MultiSelect{ + Message: "Select all the services you are using:", + Options: titles(serviceChoices), + }, &selected, survey.WithKeepFilter(true)); err != nil { + return err + } + if len(selected) > 0 { + break + } + proceed := false + if err := survey.AskOne(&survey.Confirm{ + Message: "You have not selected any service, would you like to proceed anyway?", + }, &proceed); err != nil { + return err + } + if proceed { + break + } + } + + for _, title := range selected { + typ := typeByTitle(serviceChoices, title) + v := versions(typ) + if len(v) == 0 { + continue + } + s := service{ + Name: typ, + Type: typ + ":" + v[0], + TypeVersions: v, + } + if !slices.Contains(ephemeralServices, typ) { + s.Disk = diskSizes[0] + s.DiskSizes = diskSizes + } + w.answers.services = append(w.answers.services, s) + } + return nil } diff --git a/internal/platformify/question/almost_done.go b/internal/platformify/question/almost_done.go deleted file mode 100644 index 11e91628a..000000000 --- a/internal/platformify/question/almost_done.go +++ /dev/null @@ -1,23 +0,0 @@ -package question - -import ( - "context" - "fmt" - - "github.com/upsun/cli/internal/platformify/colors" -) - -type AlmostDone struct{} - -func (q *AlmostDone) Ask(ctx context.Context) error { - out, _, ok := colors.FromContext(ctx) - if !ok { - return nil - } - - fmt.Fprintln(out) - fmt.Fprintln(out, colors.Colorize(colors.AccentCode, " (\\_/)")) - fmt.Fprintln(out, colors.Colorize(colors.AccentCode, "We’re almost done... =(^.^)=")) - fmt.Fprintln(out) - return nil -} diff --git a/internal/platformify/question/application_root.go b/internal/platformify/question/application_root.go deleted file mode 100644 index 57e002c3e..000000000 --- a/internal/platformify/question/application_root.go +++ /dev/null @@ -1,51 +0,0 @@ -package question - -import ( - "context" - "path" - "path/filepath" - - "github.com/upsun/cli/internal/platformify/question/models" - "github.com/upsun/cli/internal/platformify/utils" -) - -type ApplicationRoot struct{} - -func (q *ApplicationRoot) Ask(ctx context.Context) error { - answers, ok := models.FromContext(ctx) - if !ok { - return nil - } - - for _, dm := range answers.DependencyManagers { - switch dm { - case models.Composer: - if composerPath := utils.FindFile(answers.WorkingDirectory, "composer.json"); composerPath != "" { - answers.ApplicationRoot, _ = filepath.Rel(answers.WorkingDirectory, path.Dir(composerPath)) - return nil - } - case models.Npm, models.Yarn: - if packagePath := utils.FindFile(answers.WorkingDirectory, "package.json"); packagePath != "" { - answers.ApplicationRoot, _ = filepath.Rel(answers.WorkingDirectory, path.Dir(packagePath)) - return nil - } - case models.Poetry: - if pyProjectPath := utils.FindFile(answers.WorkingDirectory, "pyproject.toml"); pyProjectPath != "" { - answers.ApplicationRoot, _ = filepath.Rel(answers.WorkingDirectory, path.Dir(pyProjectPath)) - return nil - } - case models.Pipenv: - if pipfilePath := utils.FindFile(answers.WorkingDirectory, "Pipfile"); pipfilePath != "" { - answers.ApplicationRoot, _ = filepath.Rel(answers.WorkingDirectory, path.Dir(pipfilePath)) - return nil - } - case models.Pip: - if requirementsPath := utils.FindFile(answers.WorkingDirectory, "requirements.txt"); requirementsPath != "" { - answers.ApplicationRoot, _ = filepath.Rel(answers.WorkingDirectory, path.Dir(requirementsPath)) - return nil - } - } - } - - return nil -} diff --git a/internal/platformify/question/build_steps.go b/internal/platformify/question/build_steps.go deleted file mode 100644 index 5392ced42..000000000 --- a/internal/platformify/question/build_steps.go +++ /dev/null @@ -1,143 +0,0 @@ -package question - -import ( - "context" - "fmt" - "path" - "path/filepath" - "slices" - - "github.com/upsun/cli/internal/platformify/question/models" - "github.com/upsun/cli/internal/platformify/utils" - "github.com/upsun/cli/internal/platformify/vendorization" -) - -type BuildSteps struct{} - -func (q *BuildSteps) Ask(ctx context.Context) error { - answers, ok := models.FromContext(ctx) - if !ok { - return nil - } - - for _, dm := range answers.DependencyManagers { - switch dm { - case models.Poetry: - answers.BuildSteps = append( - answers.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 models.Pipenv: - answers.BuildSteps = append( - answers.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 models.Pip: - answers.BuildSteps = append( - answers.BuildSteps, - "pip install -r requirements.txt", - ) - case models.Yarn, models.Npm: - if answers.Type.Runtime != models.NodeJS { - if _, ok := answers.Dependencies["nodejs"]; !ok { - answers.Dependencies["nodejs"] = map[string]string{} - } - answers.Dependencies["nodejs"]["n"] = "*" - answers.Dependencies["nodejs"]["npx"] = "*" - answers.Environment["N_PREFIX"] = "/app/.global" - answers.BuildSteps = append( - answers.BuildSteps, - "n auto || n lts", - "hash -r", - ) - } - - if dm == models.Yarn { - answers.BuildSteps = append( - answers.BuildSteps, - "yarn", - ) - } else { - answers.BuildSteps = append( - answers.BuildSteps, - "npm i", - ) - } - if _, ok := utils.GetJSONValue( - []string{"scripts", "build"}, - path.Join(answers.WorkingDirectory, "package.json"), - true, - ); ok { - if dm == models.Yarn { - answers.BuildSteps = append(answers.BuildSteps, "yarn build") - } else { - answers.BuildSteps = append(answers.BuildSteps, "npm run build") - } - } - case models.Composer: - answers.BuildSteps = append( - answers.BuildSteps, - "composer --no-ansi --no-interaction install --no-progress --prefer-dist --optimize-autoloader --no-dev", - ) - case models.Bundler: - answers.BuildSteps = append( - answers.BuildSteps, - "bundle install", - ) - } - } - - switch answers.Stack { - case models.Django: - if managePyPath := utils.FindFile( - path.Join(answers.WorkingDirectory, answers.ApplicationRoot), - managePyFile, - ); managePyPath != "" { - prefix := "" - if slices.Contains(answers.DependencyManagers, models.Pipenv) { - prefix = "pipenv run " - } else if slices.Contains(answers.DependencyManagers, models.Poetry) { - prefix = "poetry run " - } - - managePyPath, _ = filepath.Rel(path.Join(answers.WorkingDirectory, answers.ApplicationRoot), managePyPath) - assets, _ := vendorization.FromContext(ctx) - answers.BuildSteps = append( - answers.BuildSteps, - fmt.Sprintf( - "# Collect static files so that they can be served by %s", - assets.ServiceName, - ), - fmt.Sprintf("%spython %s collectstatic --noinput", prefix, managePyPath), - ) - } - case models.NextJS: - // If there is no custom build script, fallback to next build for Next.js projects - if !slices.Contains(answers.BuildSteps, "yarn build") && !slices.Contains(answers.BuildSteps, "npm run build") { - cmd := "npm exec next build" - if slices.Contains(answers.DependencyManagers, models.Yarn) { - cmd = "yarn exec next build" - } - answers.BuildSteps = append(answers.BuildSteps, cmd) - } - case models.Rails: - answers.Environment["RAILS_ENV"] = "production" - answers.Environment["PIDFILE"] = "tmp/server.pid" - answers.BuildSteps = append( - answers.BuildSteps, - "bundle exec rails assets:precompile", - ) - } - - return nil -} diff --git a/internal/platformify/question/build_steps_test.go b/internal/platformify/question/build_steps_test.go deleted file mode 100644 index be313c130..000000000 --- a/internal/platformify/question/build_steps_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package question - -import ( - "context" - "reflect" - "testing" - - "github.com/upsun/cli/internal/platformify/question/models" -) - -func TestBuildSteps_Ask(t *testing.T) { - type args struct { - answers models.Answers - } - tests := []struct { - name string - q *BuildSteps - args args - buildSteps []string - wantErr bool - }{ - { - name: "Next.js fallback", - q: &BuildSteps{}, - args: args{models.Answers{ - Stack: models.NextJS, - Type: models.RuntimeType{Runtime: models.NodeJS, Version: "20.0"}, - Dependencies: map[string]map[string]string{}, - DependencyManagers: []models.DepManager{models.Yarn}, - Environment: map[string]string{}, - }}, - buildSteps: []string{"yarn", "yarn exec next build"}, - wantErr: false, - }, - { - name: "Next.js npm fallback", - q: &BuildSteps{}, - args: args{models.Answers{ - Stack: models.NextJS, - Type: models.RuntimeType{Runtime: models.NodeJS, Version: "20.0"}, - Dependencies: map[string]map[string]string{}, - DependencyManagers: []models.DepManager{models.Npm}, - Environment: map[string]string{}, - }}, - buildSteps: []string{"npm i", "npm exec next build"}, - wantErr: false, - }, - { - name: "Bundler", - q: &BuildSteps{}, - args: args{models.Answers{ - Stack: models.GenericStack, - Type: models.RuntimeType{Runtime: models.Ruby, Version: "3.3"}, - Dependencies: map[string]map[string]string{}, - DependencyManagers: []models.DepManager{models.Bundler}, - Environment: map[string]string{}, - }}, - buildSteps: []string{"bundle install"}, - wantErr: false, - }, - { - name: "Bundler with Rails", - q: &BuildSteps{}, - args: args{models.Answers{ - Stack: models.Rails, - Type: models.RuntimeType{Runtime: models.Ruby, Version: "3.3"}, - Dependencies: map[string]map[string]string{}, - DependencyManagers: []models.DepManager{models.Bundler}, - Environment: map[string]string{}, - }}, - buildSteps: []string{"bundle install", "bundle exec rails assets:precompile"}, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - q := &BuildSteps{} - ctx := models.ToContext(context.Background(), &tt.args.answers) - if err := q.Ask(ctx); (err != nil) != tt.wantErr { - t.Errorf("BuildSteps.Ask() error = %v, wantErr %v", err, tt.wantErr) - } - - if !reflect.DeepEqual(tt.args.answers.BuildSteps, tt.buildSteps) { - t.Errorf("BuildSteps.Ask() BuildSteps = %v, want %v", tt.args.answers.BuildSteps, tt.buildSteps) - } - }) - } -} diff --git a/internal/platformify/question/dependency_manager.go b/internal/platformify/question/dependency_manager.go deleted file mode 100644 index 2be339d22..000000000 --- a/internal/platformify/question/dependency_manager.go +++ /dev/null @@ -1,86 +0,0 @@ -package question - -import ( - "context" - "fmt" - - "github.com/upsun/cli/internal/platformify/colors" - "github.com/upsun/cli/internal/platformify/question/models" - "github.com/upsun/cli/internal/platformify/utils" -) - -const ( - npmLockFileName = "package-lock.json" - yarnLockFileName = "yarn.lock" - poetryLockFile = "poetry.lock" - pipenvLockFile = "Pipfile.lock" - pipLockFile = "requirements.txt" - composerLockFile = "composer.lock" - bundlerLockFile = "Gemfile.lock" -) - -type DependencyManager struct{} - -func (q *DependencyManager) Ask(ctx context.Context) error { - answers, ok := models.FromContext(ctx) - if !ok { - return nil - } - if len(answers.DependencyManagers) > 0 { - // Skip the step - return nil - } - - answers.Dependencies = map[string]map[string]string{} - answers.BuildFlavor = "none" - - defer func() { - _, stderr, ok := colors.FromContext(ctx) - if !ok { - return - } - - if len(answers.DependencyManagers) > 0 { - dependencyManagers := answers.DependencyManagers[0].Title() - for _, dm := range answers.DependencyManagers[1:] { - dependencyManagers = fmt.Sprintf("%s, %s", dependencyManagers, dm.Title()) - } - - fmt.Fprintf( - stderr, - "%s %s\n", - colors.Colorize(colors.GreenCode, "✓"), - colors.Colorize( - colors.BrandCode, - fmt.Sprintf("Detected dependency managers: %s", dependencyManagers), - ), - ) - } - }() - - 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) - } - - 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) - } - - if exists := utils.FileExists(answers.WorkingDirectory, bundlerLockFile); exists { - answers.DependencyManagers = append(answers.DependencyManagers, models.Bundler) - } - - return nil -} diff --git a/internal/platformify/question/deploy_command.go b/internal/platformify/question/deploy_command.go deleted file mode 100644 index 755d23afb..000000000 --- a/internal/platformify/question/deploy_command.go +++ /dev/null @@ -1,52 +0,0 @@ -package question - -import ( - "context" - "fmt" - "path" - "path/filepath" - "slices" - - "github.com/upsun/cli/internal/platformify/question/models" - "github.com/upsun/cli/internal/platformify/utils" -) - -type DeployCommand struct{} - -func (q *DeployCommand) Ask(ctx context.Context) error { - answers, ok := models.FromContext(ctx) - if !ok { - return nil - } - - switch answers.Stack { - case models.Django: - managePyPath := utils.FindFile(path.Join(answers.WorkingDirectory, answers.ApplicationRoot), managePyFile) - if managePyPath != "" { - managePyPath, _ = filepath.Rel(path.Join(answers.WorkingDirectory, answers.ApplicationRoot), managePyPath) - prefix := "" - if slices.Contains(answers.DependencyManagers, models.Pipenv) { - prefix = "pipenv run " - } else if slices.Contains(answers.DependencyManagers, models.Poetry) { - prefix = "poetry run " - } - answers.DeployCommand = append(answers.DeployCommand, - fmt.Sprintf("%spython %s migrate", prefix, managePyPath), - ) - } - case models.Laravel: - answers.DeployCommand = append(answers.DeployCommand, - "mkdir -p storage/framework/sessions", - "mkdir -p storage/framework/cache", - "mkdir -p storage/framework/views", - "php artisan migrate --force", - "php artisan optimize:clear", - ) - case models.Rails: - answers.DeployCommand = append(answers.DeployCommand, - "bundle exec rake db:migrate", - ) - } - - return nil -} diff --git a/internal/platformify/question/done.go b/internal/platformify/question/done.go deleted file mode 100644 index 00553707d..000000000 --- a/internal/platformify/question/done.go +++ /dev/null @@ -1,89 +0,0 @@ -package question - -import ( - "context" - "fmt" - - "github.com/upsun/cli/internal/platformify/colors" - "github.com/upsun/cli/internal/platformify/question/models" - "github.com/upsun/cli/internal/platformify/vendorization" -) - -type Done struct{} - -func (q *Done) Ask(ctx context.Context) error { - out, _, ok := colors.FromContext(ctx) - if !ok { - return nil - } - - answers, ok := models.FromContext(ctx) - if !ok { - return nil - } - - assets, _ := vendorization.FromContext(ctx) - fmt.Fprintln(out) - fmt.Fprintln(out, "┌───────────────────────────────────────────────────┐") - fmt.Fprintln(out, "│ CONGRATULATIONS! │") - fmt.Fprintln(out, "│ │") - fmt.Fprintln(out, "│ We have created the following files for your: │") - for _, f := range assets.ProprietaryFiles() { - fmt.Fprintf(out, "│ - %-44s│\n", f) - } - fmt.Fprintln(out, "│ │") - fmt.Fprintln(out, "│ We’re jumping for joy! ⍢ │") - fmt.Fprintln(out, "└───────────────────────────────────────────────────┘") - fmt.Fprintln(out, " │ /") - fmt.Fprintln(out, " │/") - fmt.Fprintln(out, " │") - fmt.Fprintln(out, " (\\ /)") - fmt.Fprintln(out, " ( . .)") - fmt.Fprintln(out, " o (_(“)(“)") - fmt.Fprintln(out) - if answers.HasGit { - fmt.Fprintln(out, colors.Colorize(colors.AccentCode, fmt.Sprintf( - "You can now deploy your application to %s!", - assets.ServiceName, - ))) - fmt.Fprintln( - out, - colors.Colorize( - colors.AccentCode, - fmt.Sprintf( - "To do so, commit your files and deploy your application using the %s CLI:", - assets.ServiceName, - ), - ), - ) - fmt.Fprintln(out, " $ git add .") - fmt.Fprintf(out, " $ git commit -m 'Add %s configuration files'\n", assets.ServiceName) - fmt.Fprintf(out, " $ %s project:set-remote\n", assets.Binary) - fmt.Fprintf(out, " $ %s push\n", assets.Binary) - fmt.Fprintln(out) - return nil - } - - fmt.Fprintln(out, colors.Colorize(colors.AccentCode, fmt.Sprintf( - "You can now deploy your application to %s!", - assets.ServiceName, - ))) - fmt.Fprintln( - out, - colors.Colorize( - colors.AccentCode, - fmt.Sprintf( - "To do so, you need to create a Git repository, commit your files and deploy your application using the %s CLI:", - assets.ServiceName, - ), - ), - ) - fmt.Fprintf(out, " $ git init %s\n", answers.WorkingDirectory) - fmt.Fprintln(out, " $ git add .") - fmt.Fprintf(out, " $ git commit -m 'Add %s configuration files'\n", assets.ServiceName) - fmt.Fprintf(out, " $ %s project:set-remote\n", assets.Binary) - fmt.Fprintf(out, " $ %s push\n", assets.Binary) - fmt.Fprintln(out) - - return nil -} diff --git a/internal/platformify/question/environment.go b/internal/platformify/question/environment.go deleted file mode 100644 index 6137f62b0..000000000 --- a/internal/platformify/question/environment.go +++ /dev/null @@ -1,30 +0,0 @@ -package question - -import ( - "context" - - "github.com/upsun/cli/internal/platformify/question/models" -) - -type Environment struct{} - -func (q *Environment) Ask(ctx context.Context) error { - answers, ok := models.FromContext(ctx) - if !ok { - 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" - } - } - - return nil -} diff --git a/internal/platformify/question/files_overwrite.go b/internal/platformify/question/files_overwrite.go deleted file mode 100644 index 6d74ffe79..000000000 --- a/internal/platformify/question/files_overwrite.go +++ /dev/null @@ -1,73 +0,0 @@ -package question - -import ( - "context" - "fmt" - "os" - "path/filepath" - - "github.com/AlecAivazis/survey/v2" - - "github.com/upsun/cli/internal/platformify/colors" - "github.com/upsun/cli/internal/platformify/question/models" - "github.com/upsun/cli/internal/platformify/questionnaire" - "github.com/upsun/cli/internal/platformify/vendorization" -) - -type FilesOverwrite struct{} - -func (q *FilesOverwrite) Ask(ctx context.Context) error { - answers, ok := models.FromContext(ctx) - if !ok { - 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() { - if st, err := os.Stat(filepath.Join(answers.WorkingDirectory, p)); err == nil && !st.IsDir() { - existingFiles = append(existingFiles, p) - } - } - - if len(existingFiles) > 0 { - fmt.Fprintln( - stderr, - colors.Colorize( - colors.WarningCode, - fmt.Sprintf("You are reconfiguring the project at %s.", answers.WorkingDirectory), - ), - ) - fmt.Fprintln( - stderr, - colors.Colorize( - colors.WarningCode, - fmt.Sprintf( - "The following %s files already exist in this directory:", - assets.ServiceName, - ), - ), - ) - for _, p := range existingFiles { - fmt.Fprintln(stderr, colors.Colorize(colors.WarningCode, fmt.Sprintf(" - %s", p))) - } - proceed := false - if err := survey.AskOne(&survey.Confirm{ - Message: "Do you want to overwrite them?", - Default: proceed, - }, &proceed); err != nil { - return err - } - - if !proceed { - return questionnaire.ErrUserAborted - } - } - - return nil -} diff --git a/internal/platformify/question/locations.go b/internal/platformify/question/locations.go deleted file mode 100644 index d8c8dc536..000000000 --- a/internal/platformify/question/locations.go +++ /dev/null @@ -1,43 +0,0 @@ -package question - -import ( - "context" - "path/filepath" - - "github.com/upsun/cli/internal/platformify/question/models" - "github.com/upsun/cli/internal/platformify/utils" -) - -type Locations struct{} - -func (q *Locations) Ask(ctx context.Context) error { - answers, ok := models.FromContext(ctx) - if !ok { - return nil - } - answers.Locations = make(map[string]map[string]interface{}) - switch answers.Stack { - case models.Django: - answers.Locations["/static"] = map[string]interface{}{ - "root": "static", - "expires": "1h", - "allow": true, - } - default: - if answers.Type.Runtime == models.PHP { - locations := map[string]interface{}{ - "passthru": "/index.php", - "root": "", - } - if indexPath := utils.FindFile(answers.WorkingDirectory, "index.php"); indexPath != "" { - indexRelPath, _ := filepath.Rel(answers.WorkingDirectory, indexPath) - if filepath.Dir(indexRelPath) != "." { - locations["root"] = filepath.Dir(indexRelPath) - } - } - answers.Locations["/"] = locations - } - } - - return nil -} diff --git a/internal/platformify/question/models/answer.go b/internal/platformify/question/models/answer.go deleted file mode 100644 index c25155f14..000000000 --- a/internal/platformify/question/models/answer.go +++ /dev/null @@ -1,179 +0,0 @@ -package models - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - - "github.com/upsun/cli/internal/platformify/platformifier" -) - -type Answers struct { - Stack Stack `json:"stack"` - Flavor string `json:"flavor"` - Type RuntimeType `json:"type"` - Name string `json:"name"` - ApplicationRoot string `json:"application_root"` - Environment map[string]string `json:"environment"` - BuildSteps []string `json:"build_steps"` - WebCommand string `json:"web_command"` - SocketFamily SocketFamily `json:"socket_family"` - DeployCommand []string `json:"deploy_command"` - DependencyManagers []DepManager `json:"dependency_managers"` - Dependencies map[string]map[string]string `json:"dependencies"` - BuildFlavor string `json:"build_flavor"` - Disk string `json:"disk"` - Mounts map[string]map[string]string `json:"mounts"` - Services []Service `json:"services"` - WorkingDirectory string `json:"working_directory"` - HasGit bool `json:"has_git"` - FilesCreated []string `json:"files_created"` - Locations map[string]map[string]interface{} `json:"locations"` -} - -type Service struct { - Name string `json:"name"` - Type ServiceType `json:"type"` - TypeVersions []string `json:"type_versions"` - Disk ServiceDisk `json:"disk,omitempty"` - DiskSizes []ServiceDisk `json:"disk_sizes"` -} - -type RuntimeType struct { - Runtime Runtime - Version string -} - -func (t RuntimeType) String() string { - if t.Version != "" { - return t.Runtime.String() + ":" + t.Version - } - return t.Runtime.String() -} - -func (t RuntimeType) MarshalJSON() ([]byte, error) { - return json.Marshal(t.String()) -} - -type ServiceType struct { - Name string - Version string -} - -func (t ServiceType) String() string { - if t.Version != "" { - return t.Name + ":" + t.Version - } - return t.Name -} - -func (t ServiceType) MarshalJSON() ([]byte, error) { - return json.Marshal(t.String()) -} - -func NewAnswers() *Answers { - return &Answers{ - Environment: make(map[string]string), - BuildSteps: make([]string, 0), - Services: make([]Service, 0), - } -} - -func (a *Answers) ToUserInput() *platformifier.UserInput { - services := make([]platformifier.Service, 0, len(a.Services)) - for _, service := range a.Services { - diskSizes := make([]string, 0, len(service.DiskSizes)) - for _, size := range service.DiskSizes { - diskSizes = append(diskSizes, size.String()) - } - services = append(services, platformifier.Service{ - Name: service.Name, - Type: service.Type.String(), - TypeVersions: service.TypeVersions, - Disk: service.Disk.String(), - DiskSizes: diskSizes, - }) - } - - locations := map[string]map[string]interface{}{ - "/": { - "passthru": true, - }, - } - for key, value := range a.Locations { - locations[key] = value - } - - dependencyManagers := make([]string, len(a.DependencyManagers)) - for _, dm := range a.DependencyManagers { - dependencyManagers = append(dependencyManagers, dm.String()) - } - - return &platformifier.UserInput{ - Stack: getStack(a.Stack), - Root: "", - ApplicationRoot: filepath.Join(string(os.PathSeparator), a.ApplicationRoot), - Name: a.Name, - Type: a.Type.String(), - Runtime: a.Type.Runtime.String(), - Environment: a.Environment, - BuildSteps: a.BuildSteps, - WebCommand: a.WebCommand, - SocketFamily: a.SocketFamily.String(), - DependencyManagers: dependencyManagers, - DeployCommand: a.DeployCommand, - Locations: locations, - Dependencies: a.Dependencies, - BuildFlavor: a.BuildFlavor, - Disk: a.Disk, - Mounts: a.Mounts, - Services: services, - Relationships: getRelationships(a.Services), - WorkingDirectory: a.WorkingDirectory, - HasGit: a.HasGit, - } -} - -func getStack(answersStack Stack) platformifier.Stack { - switch answersStack { - case Django: - return platformifier.Django - case Laravel: - return platformifier.Laravel - case Rails: - return platformifier.Rails - case NextJS: - return platformifier.NextJS - case Strapi: - return platformifier.Strapi - case Flask: - return platformifier.Flask - case Express: - return platformifier.Express - default: - return platformifier.Generic - } -} - -// getRelationships returns a map of service names to their endpoint names. -func getRelationships(services []Service) map[string]platformifier.Relationship { - endpointRemap := map[string]string{ - "mariadb": "mysql", - "oracle-mysql": "mysql", - "chrome-headless": "http", - "redis-persistent": "redis", - } - relationships := make(map[string]platformifier.Relationship) - for _, service := range services { - endpoint := strings.Split(service.Type.Name, ":")[0] - if remappedEndpoint, ok := endpointRemap[endpoint]; ok { - endpoint = remappedEndpoint - } - relationships[service.Name] = platformifier.Relationship{ - Service: service.Name, - Endpoint: endpoint, - } - } - return relationships -} diff --git a/internal/platformify/question/models/answer_test.go b/internal/platformify/question/models/answer_test.go deleted file mode 100644 index e317d7724..000000000 --- a/internal/platformify/question/models/answer_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package models - -import ( - "reflect" - "testing" - - "github.com/upsun/cli/internal/platformify/platformifier" -) - -func Test_getRelationships(t *testing.T) { - type args struct { - services []Service - } - tests := []struct { - name string - args args - want map[string]platformifier.Relationship - }{ - { - name: "Simple", - args: args{ - services: []Service{ - { - Name: "mysql", - Type: ServiceType{ - Name: "mysql", - Version: "5.7", - }, - }, - }, - }, - want: map[string]platformifier.Relationship{ - "mysql": {Service: "mysql", Endpoint: "mysql"}, - }, - }, - { - name: "Remapped", - args: args{ - services: []Service{ - { - Name: "mariadb", - Type: ServiceType{ - Name: "mariadb", - Version: "14", - }, - }, - { - Name: "oracle-mysql", - Type: ServiceType{ - Name: "oracle-mysql", - Version: "14", - }, - }, - { - Name: "chrome-headless", - Type: ServiceType{ - Name: "chrome-headless", - Version: "14", - }, - }, - { - Name: "redis-persistent", - Type: ServiceType{ - Name: "redis-persistent", - Version: "14", - }, - }, - }, - }, - want: map[string]platformifier.Relationship{ - "mariadb": {Service: "mariadb", Endpoint: "mysql"}, - "oracle-mysql": {Service: "oracle-mysql", Endpoint: "mysql"}, - "chrome-headless": {Service: "chrome-headless", Endpoint: "http"}, - "redis-persistent": {Service: "redis-persistent", Endpoint: "redis"}, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := getRelationships(tt.args.services); !reflect.DeepEqual(got, tt.want) { - t.Errorf("getRelationships() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/internal/platformify/question/models/context.go b/internal/platformify/question/models/context.go deleted file mode 100644 index e24cba0d9..000000000 --- a/internal/platformify/question/models/context.go +++ /dev/null @@ -1,24 +0,0 @@ -package models - -import ( - "context" -) - -// key is an unexported type for keys defined in this package. -// This prevents collisions with keys defined in other packages. -type key int - -// answerKey is the key for answers values in Contexts. It is unexported. -// Clients use models.ToContext and models.FromContext instead of using this key directly. -var answerKey key - -// ToContext returns a new Context that carries answers value. -func ToContext(ctx context.Context, answers *Answers) context.Context { - return context.WithValue(ctx, answerKey, answers) -} - -// FromContext returns the answers value stored in ctx, if any. -func FromContext(ctx context.Context) (*Answers, bool) { - answers, ok := ctx.Value(answerKey).(*Answers) - return answers, ok -} diff --git a/internal/platformify/question/models/dependency_manager.go b/internal/platformify/question/models/dependency_manager.go deleted file mode 100644 index 04262545d..000000000 --- a/internal/platformify/question/models/dependency_manager.go +++ /dev/null @@ -1,41 +0,0 @@ -package models - -const ( - GenericDepManager DepManager = "generic" - Pip DepManager = "pip" - Poetry DepManager = "poetry" - Pipenv DepManager = "pipenv" - Composer DepManager = "composer" - Yarn DepManager = "yarn" - Npm DepManager = "npm" - Bundler DepManager = "bundler" -) - -type DepManager string - -func (m DepManager) String() string { - return string(m) -} - -func (m DepManager) Title() string { - switch m { - case GenericDepManager: - return "Other" - case Pip: - return "Pip" - case Poetry: - return "Poetry" - case Pipenv: - return "Pipenv" - case Composer: - return "Composer" - case Yarn: - return "Yarn" - case Npm: - return "Npm" - case Bundler: - return "Bundler" - default: - return "" - } -} diff --git a/internal/platformify/question/models/generate_versions.go b/internal/platformify/question/models/generate_versions.go deleted file mode 100644 index 9a54d7623..000000000 --- a/internal/platformify/question/models/generate_versions.go +++ /dev/null @@ -1,288 +0,0 @@ -//go:build ignore - -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "go/format" - "io" - "log" - "net/http" - "os" - "path/filepath" - "sort" - "strings" - "text/template" -) - -const registryURL = "https://raw.githubusercontent.com/platformsh/platformsh-docs/main/shared/data/registry.json" - -type Registry map[string]Service - -type Service struct { - Description string `json:"description"` - Disk bool `json:"disk"` - Runtime bool `json:"runtime"` - Type string `json:"type"` - Versions map[string][]string `json:"versions"` -} - -// Mapping from registry service names to our ServiceName constants -var serviceMapping = map[string]string{ - "chrome-headless": "ChromeHeadless", - "influxdb": "InfluxDB", - "kafka": "Kafka", - "mariadb": "MariaDB", - "memcached": "Memcached", - "mysql": "MySQL", - "network-storage": "NetworkStorage", - "opensearch": "OpenSearch", - "oracle-mysql": "OracleMySQL", - "postgresql": "PostgreSQL", - "rabbitmq": "RabbitMQ", - "redis": "Redis", - "solr": "Solr", - "varnish": "Varnish", - "vault-kms": "VaultKMS", -} - -// Mapping from registry runtime names to our Runtime constants -var runtimeMapping = map[string]string{ - "dotnet": "DotNet", - "elixir": "Elixir", - "golang": "Golang", - "java": "Java", - "nodejs": "NodeJS", - "php": "PHP", - "python": "Python", - "ruby": "Ruby", - "rust": "Rust", -} - -const versionTemplate = `//go:generate go run generate_versions.go - -package models - -var ( - LanguageTypeVersions = map[Runtime][]string{ -{{- range .Languages }} - {{ .Name }}: {{ .Versions }}, -{{- end }} - } - - ServiceTypeVersions = map[ServiceName][]string{ -{{- range .Services }} - {{ .Name }}: {{ .Versions }}, -{{- end }} - } -) - -func DefaultVersionForRuntime(r Runtime) string { - versions := LanguageTypeVersions[r] - if len(versions) == 0 { - return "" - } - return versions[0] -} -` - -type TemplateData struct { - Languages []TypeVersion - Services []TypeVersion -} - -type TypeVersion struct { - Name string - Versions string -} - -func main() { - // Fetch the registry data - resp, err := http.Get(registryURL) - if err != nil { - log.Fatalf("Failed to fetch registry: %v", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - log.Fatalf("Failed to read response: %v", err) - } - - var registry Registry - if err := json.Unmarshal(body, ®istry); err != nil { - log.Fatalf("Failed to parse JSON: %v", err) - } - - var languages []TypeVersion - var services []TypeVersion - - // Process runtimes (languages) - for serviceName, service := range registry { - if !service.Runtime { - continue - } - - constantName, exists := runtimeMapping[serviceName] - if !exists { - continue // Skip runtimes we don't support - } - - supported := service.Versions["supported"] - if len(supported) == 0 { - continue - } - - versions := formatVersionSlice(supported) - languages = append(languages, TypeVersion{ - Name: constantName, - Versions: versions, - }) - } - - // Process services - for serviceName, service := range registry { - if service.Runtime { - continue - } - - constantName, exists := serviceMapping[serviceName] - if !exists { - continue // Skip services we don't support - } - - supported := service.Versions["supported"] - if len(supported) == 0 { - continue - } - - versions := formatVersionSlice(supported) - services = append(services, TypeVersion{ - Name: constantName, - Versions: versions, - }) - - // Add RedisPersistent right after Redis - if serviceName == "redis" { - services = append(services, TypeVersion{ - Name: "RedisPersistent", - Versions: versions, - }) - } - } - - // Sort for consistent output - sort.Slice(languages, func(i, j int) bool { - return languages[i].Name < languages[j].Name - }) - sort.Slice(services, func(i, j int) bool { - return services[i].Name < services[j].Name - }) - - // Generate the Go code - tmpl, err := template.New("version").Parse(versionTemplate) - if err != nil { - log.Fatalf("Failed to parse template: %v", err) - } - - var buf bytes.Buffer - data := TemplateData{ - Languages: languages, - Services: services, - } - - if err := tmpl.Execute(&buf, data); err != nil { - log.Fatalf("Failed to execute template: %v", err) - } - - // Format the generated Go code - formatted, err := format.Source(buf.Bytes()) - if err != nil { - log.Fatalf("Failed to format Go code: %v", err) - } - - // Write to version.go file - dir, err := os.Getwd() - if err != nil { - log.Fatalf("Failed to get working directory: %v", err) - } - - outputPath := filepath.Join(dir, "version.go") - if err := os.WriteFile(outputPath, formatted, 0644); err != nil { - log.Fatalf("Failed to write version.go: %v", err) - } - - fmt.Printf("Successfully generated %s\n", outputPath) -} - -func formatVersionSlice(versions []string) string { - if len(versions) == 0 { - return "{}" - } - - // Sort versions from latest to oldest - sortedVersions := make([]string, len(versions)) - copy(sortedVersions, versions) - - sort.Slice(sortedVersions, func(i, j int) bool { - return compareVersions(sortedVersions[i], sortedVersions[j]) - }) - - quoted := make([]string, len(sortedVersions)) - for i, v := range sortedVersions { - quoted[i] = fmt.Sprintf(`"%s"`, v) - } - - return fmt.Sprintf("{%s}", strings.Join(quoted, ", ")) -} - -// compareVersions returns true if version a is greater than version b -func compareVersions(a, b string) bool { - // Parse version parts - aParts := parseVersion(a) - bParts := parseVersion(b) - - // Compare each part - maxLen := len(aParts) - if len(bParts) > maxLen { - maxLen = len(bParts) - } - - for i := 0; i < maxLen; i++ { - aVal := 0 - bVal := 0 - - if i < len(aParts) { - aVal = aParts[i] - } - if i < len(bParts) { - bVal = bParts[i] - } - - if aVal > bVal { - return true - } - if aVal < bVal { - return false - } - } - - return false // versions are equal -} - -// parseVersion parses a version string into numeric parts -func parseVersion(version string) []int { - parts := strings.Split(version, ".") - nums := make([]int, 0, len(parts)) - - for _, part := range parts { - // Handle cases like "25.05" or "1.0" or just "1" - num := 0 - fmt.Sscanf(part, "%d", &num) - nums = append(nums, num) - } - - return nums -} diff --git a/internal/platformify/question/models/runtime.go b/internal/platformify/question/models/runtime.go deleted file mode 100644 index 599cba53c..000000000 --- a/internal/platformify/question/models/runtime.go +++ /dev/null @@ -1,81 +0,0 @@ -package models - -import ( - "fmt" -) - -const ( - DotNet Runtime = "dotnet" - Elixir Runtime = "elixir" - Golang Runtime = "golang" - Java Runtime = "java" - NodeJS Runtime = "nodejs" - PHP Runtime = "php" - Python Runtime = "python" - Ruby Runtime = "ruby" - Rust Runtime = "rust" -) - -var ( - Runtimes = RuntimeList{ - DotNet, - Elixir, - Golang, - Java, - NodeJS, - PHP, - Python, - Ruby, - Rust, - } -) - -type Runtime string - -func (r Runtime) String() string { - return string(r) -} - -func (r Runtime) Title() string { - switch r { - case DotNet: - return "C#/.Net Core" - case Elixir: - return "Elixir" - case Golang: - return "Go" - case Java: - return "Java" - case NodeJS: - return "JavaScript/Node.js" - case PHP: - return "PHP" - case Python: - return "Python" - case Ruby: - return "Ruby" - case Rust: - return "Rust" - default: - return "" - } -} - -type RuntimeList []Runtime - -func (r RuntimeList) AllTitles() []string { - titles := make([]string, 0, len(r)) - for _, runtime := range r { - titles = append(titles, runtime.Title()) - } - return titles -} - -func (r RuntimeList) RuntimeByTitle(title string) (Runtime, error) { - for _, runtime := range r { - if runtime.Title() == title { - return runtime, nil - } - } - return "", fmt.Errorf("runtime by title is not found") -} diff --git a/internal/platformify/question/models/service_disk.go b/internal/platformify/question/models/service_disk.go deleted file mode 100644 index 5e3f253e8..000000000 --- a/internal/platformify/question/models/service_disk.go +++ /dev/null @@ -1,42 +0,0 @@ -package models - -const ( - D1024 ServiceDisk = "1024" - D2048 ServiceDisk = "2048" - D3072 ServiceDisk = "3072" - D4096 ServiceDisk = "4096" - D5120 ServiceDisk = "5120" -) - -var ( - ServiceDisks = []ServiceDisk{ - D1024, - D2048, - D3072, - D4096, - D5120, - } -) - -type ServiceDisk string - -func (s ServiceDisk) String() string { - return string(s) -} - -func (s ServiceDisk) Title() string { - switch s { - case D1024: - return "1GB" - case D2048: - return "2GB" - case D3072: - return "3GB" - case D4096: - return "4GB" - case D5120: - return "5GB" - default: - return "" - } -} diff --git a/internal/platformify/question/models/service_name.go b/internal/platformify/question/models/service_name.go deleted file mode 100644 index f42c82123..000000000 --- a/internal/platformify/question/models/service_name.go +++ /dev/null @@ -1,137 +0,0 @@ -package models - -import ( - "fmt" - - "github.com/AlecAivazis/survey/v2" -) - -const ( - ChromeHeadless ServiceName = "chrome-headless" - ClickHouse ServiceName = "clickhouse" - InfluxDB ServiceName = "influxdb" - Kafka ServiceName = "kafka" - MariaDB ServiceName = "mariadb" - Memcached ServiceName = "memcached" - MySQL ServiceName = "mysql" - NetworkStorage ServiceName = "network-storage" - OpenSearch ServiceName = "opensearch" - OracleMySQL ServiceName = "oracle-mysql" - PostgreSQL ServiceName = "postgresql" - RabbitMQ ServiceName = "rabbitmq" - Redis ServiceName = "redis" - RedisPersistent ServiceName = "redis-persistent" - Solr ServiceName = "solr" - Varnish ServiceName = "varnish" - VaultKMS ServiceName = "vault-kms" -) - -var ( - ServiceNames = ServiceNameList{ - MariaDB, - MySQL, - PostgreSQL, - Redis, - RedisPersistent, - Memcached, - OpenSearch, - Solr, - Varnish, - Kafka, - VaultKMS, - RabbitMQ, - InfluxDB, - ChromeHeadless, - NetworkStorage, - OracleMySQL, - } -) - -type ServiceName string - -func (s ServiceName) String() string { - return string(s) -} - -func (s ServiceName) Title() string { - switch s { - case ChromeHeadless: - return "Chrome Headless" - case InfluxDB: - return "InfluxDB" - case Kafka: - return "Kafka" - case MariaDB: - return "MariaDB" - case Memcached: - return "Memcached" - case MySQL: - return "MySQL" - case NetworkStorage: - return "Network Storage" - case OpenSearch: - return "OpenSearch" - case OracleMySQL: - return "Oracle MySQL" - case PostgreSQL: - return "PostgreSQL" - case RabbitMQ: - return "RabbitMQ" - case Redis: - return "Redis" - case RedisPersistent: - return "Redis Persistent" - case Solr: - return "Solr" - case Varnish: - return "Varnish" - case VaultKMS: - return "Vault KMS" - default: - return "" - } -} - -func (s ServiceName) IsPersistent() bool { - switch s { - case ChromeHeadless, Memcached, Redis: - return false - default: - return true - } -} - -type ServiceNameList []ServiceName - -func (s *ServiceNameList) WriteAnswer(_ string, value interface{}) error { - switch answer := value.(type) { - case []survey.OptionAnswer: // MultiSelect - for _, item := range answer { - service, err := ServiceNames.ServiceByTitle(item.Value) - if err != nil { - return err - } - *s = append(*s, service) - } - default: - return fmt.Errorf("unsupported type") - } - return nil -} - -func (s *ServiceNameList) AllTitles() []string { - titles := make([]string, 0, len(*s)) - for _, service := range *s { - titles = append(titles, service.Title()) - } - return titles -} - -func (s *ServiceNameList) ServiceByTitle(title string) (ServiceName, error) { - for _, service := range *s { - if service.Title() == title { - return service, nil - } - } - return "", fmt.Errorf("service name by title is not found") -} diff --git a/internal/platformify/question/models/socket_family.go b/internal/platformify/question/models/socket_family.go deleted file mode 100644 index f40688c98..000000000 --- a/internal/platformify/question/models/socket_family.go +++ /dev/null @@ -1,23 +0,0 @@ -package models - -const ( - TCP SocketFamily = "tcp" - UnixSocket SocketFamily = "unix" -) - -type SocketFamily string - -func (i SocketFamily) String() string { - return string(i) -} - -func (i SocketFamily) Title() string { - switch i { - case TCP: - return "TCP Port" - case UnixSocket: - return "Unix-socket" - default: - return "" - } -} diff --git a/internal/platformify/question/models/stack.go b/internal/platformify/question/models/stack.go deleted file mode 100644 index 06783f499..000000000 --- a/internal/platformify/question/models/stack.go +++ /dev/null @@ -1,104 +0,0 @@ -package models - -import ( - "fmt" - - "github.com/AlecAivazis/survey/v2" -) - -const ( - GenericStack Stack = iota - Django - Laravel - NextJS - Strapi - Flask - Express - Rails -) - -var ( - Stacks = StackList{ - GenericStack, - Django, - Laravel, - NextJS, - Strapi, - Flask, - Express, - Rails, - } -) - -type Stack int - -func (s Stack) Title() string { - switch s { - case GenericStack: - return "Other" - case Django: - return "Django" - case Rails: - return "Rails" - case Laravel: - return "Laravel" - case NextJS: - return "Next.js" - case Strapi: - return "Strapi" - case Flask: - return "Flask" - case Express: - return "Express" - default: - return "" - } -} - -func (s *Stack) WriteAnswer(_ string, value interface{}) error { - switch answer := value.(type) { - case survey.OptionAnswer: // Select - stack, err := Stacks.StackByTitle(answer.Value) - if err != nil { - return err - } - *s = stack - default: - return fmt.Errorf("unsupported type") - } - return nil -} - -type StackList []Stack - -func (s StackList) AllTitles() []string { - titles := make([]string, 0, len(s)) - for _, stack := range s { - titles = append(titles, stack.Title()) - } - return titles -} - -func (s StackList) StackByTitle(title string) (Stack, error) { - for _, stack := range s { - if stack.Title() == title { - return stack, nil - } - } - return GenericStack, fmt.Errorf("stack by title is not found") -} - -func RuntimeForStack(stack Stack) Runtime { - switch stack { - case Django, Flask: - return Python - case Rails: - return Ruby - case Laravel: - return PHP - case NextJS, Strapi, Express: - return NodeJS - default: - return "" - } -} diff --git a/internal/platformify/question/models/version.go b/internal/platformify/question/models/version.go deleted file mode 100644 index 963030de2..000000000 --- a/internal/platformify/question/models/version.go +++ /dev/null @@ -1,45 +0,0 @@ -//go:generate go run generate_versions.go - -package models - -var ( - LanguageTypeVersions = map[Runtime][]string{ - DotNet: {"8.0", "7.0", "6.0"}, - Elixir: {"1.18", "1.15", "1.14"}, - Golang: {"1.25", "1.24", "1.23", "1.22", "1.21", "1.20"}, - Java: {"21", "19", "18", "17", "11", "8"}, - NodeJS: {"24", "22", "20"}, - PHP: {"8.4", "8.3", "8.2", "8.1"}, - Python: {"3.13", "3.12", "3.11", "3.10", "3.9", "3.8"}, - Ruby: {"3.4", "3.3", "3.2", "3.1", "3.0"}, - Rust: {"1"}, - } - - ServiceTypeVersions = map[ServiceName][]string{ - ChromeHeadless: {"120", "113", "95", "91"}, - ClickHouse: {"25.3", "24.3", "23.8"}, - InfluxDB: {"2.7", "2.3"}, - Kafka: {"3.7", "3.6", "3.4", "3.2"}, - MariaDB: {"11.8", "11.4", "10.11", "10.6"}, - Memcached: {"1.6", "1.5", "1.4"}, - MySQL: {"11.8", "11.4", "10.11", "10.6"}, - NetworkStorage: {"1.0"}, - OpenSearch: {"3", "2"}, - OracleMySQL: {"8.0", "5.7"}, - PostgreSQL: {"18", "17", "16", "15", "14", "13", "12"}, - RabbitMQ: {"4.1", "4.0", "3.13", "3.12"}, - Redis: {"8.0", "7.2"}, - RedisPersistent: {"8.0", "7.2"}, - Solr: {"9.9", "9.6", "9.4", "9.2", "9.1", "8.11"}, - Varnish: {"7.6", "7.3", "7.2", "6.0"}, - VaultKMS: {"1.12"}, - } -) - -func DefaultVersionForRuntime(r Runtime) string { - versions := LanguageTypeVersions[r] - if len(versions) == 0 { - return "" - } - return versions[0] -} diff --git a/internal/platformify/question/mounts.go b/internal/platformify/question/mounts.go deleted file mode 100644 index 5d1bdc166..000000000 --- a/internal/platformify/question/mounts.go +++ /dev/null @@ -1,85 +0,0 @@ -package question - -import ( - "context" - - "github.com/upsun/cli/internal/platformify/question/models" -) - -type Mounts struct{} - -func (q *Mounts) Ask(ctx context.Context) error { - answers, ok := models.FromContext(ctx) - if !ok { - return nil - } - - switch answers.Stack { - case models.Rails: - answers.Disk = "2048" // in MB - answers.Mounts = map[string]map[string]string{ - "storage": { - "source": "local", - "source_path": "storage", - }, - "tmp": { - "source": "tmp", - "source_path": "tmp", - }, - "log": { - "source": "tmp", - "source_path": "tmp", - }, - } - case models.Laravel: - answers.Disk = "2048" // in MB - answers.Mounts = map[string]map[string]string{ - "storage": { - "source": "local", - "source_path": "storage", - }, - "bootstrap/cache": { - "source": "local", - "source_path": "cache", - }, - "/.config": { - "source": "local", - "source_path": "config", - }, - } - case models.NextJS: - answers.Disk = "512" // in MB - answers.Mounts = map[string]map[string]string{ - "/.npm": { - "source": "local", - "source_path": "npm", - }, - } - case models.Strapi: - answers.Disk = "1024" // in MB - answers.Mounts = map[string]map[string]string{ - "/.cache": { - "source": "local", - "source_path": "cache", - }, - "/.tmp": { - "source": "local", - "source_path": "app", - }, - "database": { - "source": "local", - "source_path": "database", - }, - "extensions": { - "source": "local", - "source_path": "extensions", - }, - "public/uploads": { - "source": "local", - "source_path": "uploads", - }, - } - } - - return nil -} diff --git a/internal/platformify/question/name.go b/internal/platformify/question/name.go deleted file mode 100644 index 20a35a940..000000000 --- a/internal/platformify/question/name.go +++ /dev/null @@ -1,63 +0,0 @@ -package question - -import ( - "context" - "fmt" - "path" - "regexp" - "strings" - - "github.com/AlecAivazis/survey/v2" - - "github.com/upsun/cli/internal/platformify/question/models" -) - -type Name struct{} - -var ( - invalidChars = regexp.MustCompile("[^a-z0-9_-]") - consecutiveDashes = regexp.MustCompile("-+") -) - -func (q *Name) Ask(ctx context.Context) error { - answers, ok := models.FromContext(ctx) - if !ok { - return nil - } - if answers.Name != "" { - // Skip the step - return nil - } - - question := &survey.Input{ - Message: "Tell us your project's application name:", Default: slugify(path.Base(answers.WorkingDirectory)), - } - - var name string - err := survey.AskOne(question, &name, survey.WithValidator(survey.Required), survey.WithValidator(validSlug)) - if err != nil { - return err - } - - answers.Name = name - - return nil -} - -func slugify(s string) string { - return consecutiveDashes.ReplaceAllLiteralString( - invalidChars.ReplaceAllLiteralString(strings.ToLower( - strings.TrimSpace(s), - ), "-"), "-") -} - -func validSlug(val interface{}) error { - if val.(string) != slugify(val.(string)) { - return fmt.Errorf( - "%s: the name can only contain lowercase alphanumeric characters, dashes, or underscores", - val.(string), - ) - } - - return nil -} diff --git a/internal/platformify/question/services.go b/internal/platformify/question/services.go deleted file mode 100644 index 1389fe667..000000000 --- a/internal/platformify/question/services.go +++ /dev/null @@ -1,91 +0,0 @@ -package question - -import ( - "context" - "fmt" - "strings" - - "github.com/AlecAivazis/survey/v2" - - "github.com/upsun/cli/internal/platformify/colors" - "github.com/upsun/cli/internal/platformify/question/models" -) - -type Services struct{} - -func (q *Services) Ask(ctx context.Context) error { - answers, ok := models.FromContext(ctx) - if !ok { - return nil - } - if len(answers.Services) != 0 { - // Skip the step - return nil - } - - out, _, ok := colors.FromContext(ctx) - if !ok { - return nil - } - fmt.Fprintln( - out, - colors.Colorize( - colors.AccentCode, - "Last but not least, unless you’re creating a static website, your project uses services. Let’s define them:", - ), - ) - fmt.Fprintln(out) - - question := &survey.MultiSelect{ - Message: "Select all the services you are using:", - Options: models.ServiceNames.AllTitles(), - } - - var services models.ServiceNameList - for { - if err := survey.AskOne(question, &services, survey.WithKeepFilter(true)); err != nil { - return err - } - - if len(services) > 0 { - break - } - - confirmQuestion := &survey.Confirm{ - Message: "You have not selected any service, would you like to proceed anyway?", - Default: false, - } - proceed := false - if err := survey.AskOne(confirmQuestion, &proceed); err != nil { - return err - } - - if proceed { - break - } - } - - for _, serviceName := range services { - versions, ok := models.ServiceTypeVersions[serviceName] - if !ok || len(versions) == 0 { - return nil - } - - service := models.Service{ - Name: strings.ReplaceAll(serviceName.String(), "_", "-"), - Type: models.ServiceType{ - Name: serviceName.String(), - Version: versions[0], - }, - TypeVersions: versions, - } - if serviceName.IsPersistent() { - service.Disk = models.ServiceDisks[0] - service.DiskSizes = models.ServiceDisks - } - - answers.Services = append(answers.Services, service) - } - - return nil -} diff --git a/internal/platformify/question/socket_family.go b/internal/platformify/question/socket_family.go deleted file mode 100644 index 6953c40ef..000000000 --- a/internal/platformify/question/socket_family.go +++ /dev/null @@ -1,27 +0,0 @@ -package question - -import ( - "context" - - "github.com/upsun/cli/internal/platformify/question/models" -) - -type SocketFamily struct{} - -func (q *SocketFamily) Ask(ctx context.Context) error { - answers, ok := models.FromContext(ctx) - if !ok { - return nil - } - - switch answers.Type.Runtime { - case models.PHP: - return nil - case models.Ruby, models.Python: - answers.SocketFamily = models.UnixSocket - return nil - default: - answers.SocketFamily = models.TCP - return nil - } -} diff --git a/internal/platformify/question/stack.go b/internal/platformify/question/stack.go deleted file mode 100644 index 98878a04f..000000000 --- a/internal/platformify/question/stack.go +++ /dev/null @@ -1,218 +0,0 @@ -package question - -import ( - "context" - "fmt" - "os" - "slices" - "strings" - - "github.com/AlecAivazis/survey/v2" - - "github.com/upsun/cli/internal/platformify/colors" - "github.com/upsun/cli/internal/platformify/question/models" - "github.com/upsun/cli/internal/platformify/questionnaire" - "github.com/upsun/cli/internal/platformify/utils" - "github.com/upsun/cli/internal/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 { - answers, ok := models.FromContext(ctx) - if !ok { - return nil - } - - defer func() { - _, stderr, ok := colors.FromContext(ctx) - if !ok { - return - } - if answers.Stack != models.GenericStack { - fmt.Fprintf( - stderr, - "%s %s\n", - colors.Colorize(colors.GreenCode, "✓"), - colors.Colorize( - colors.BrandCode, - fmt.Sprintf("Detected stack: %s", answers.Stack.Title()), - ), - ) - } - }() - - answers.Stack = models.GenericStack - - hasSettingsPy := utils.FileExists(answers.WorkingDirectory, settingsPyFile) - hasManagePy := utils.FileExists(answers.WorkingDirectory, managePyFile) - if hasSettingsPy && hasManagePy { - answers.Stack = models.Django - return nil - } - - rackPath := utils.FindFile(answers.WorkingDirectory, rackFile) - if rackPath != "" { - f, err := os.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 := os.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([]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([]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([]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([]string{"dependencies", "next"}, packageJSONPath, true); ok { - answers.Stack = models.NextJS - return nil - } - - if _, ok := utils.GetJSONValue([]string{"dependencies", "@strapi/strapi"}, packageJSONPath, true); ok { - answers.Stack = models.Strapi - return nil - } - - if _, ok := utils.GetJSONValue([]string{"dependencies", "strapi"}, packageJSONPath, true); ok { - answers.Stack = models.Strapi - return nil - } - - if _, ok := utils.GetJSONValue([]string{"dependencies", "express"}, packageJSONPath, true); ok { - answers.Stack = models.Express - return nil - } - } - - hasSymfonyLock := utils.FileExists(answers.WorkingDirectory, symfonyLockFile) - hasSymfonyBundle := false - hasIbexaDependencies := false - hasShopwareDependencies := false - for _, composerJSONPath := range composerJSONPaths { - if _, ok := utils.GetJSONValue([]string{"autoload", "psr-0", "shopware"}, composerJSONPath, true); ok { - hasShopwareDependencies = true - break - } - if _, ok := utils.GetJSONValue([]string{"autoload", "psr-4", "shopware\\core\\"}, composerJSONPath, true); ok { - hasShopwareDependencies = true - break - } - if _, ok := utils.GetJSONValue([]string{"autoload", "psr-4", "shopware\\appbundle\\"}, composerJSONPath, true); ok { - hasShopwareDependencies = true - break - } - - if keywords, ok := utils.GetJSONValue([]string{"keywords"}, composerJSONPath, true); ok { - if keywordsVal, ok := keywords.([]string); ok && slices.Contains(keywordsVal, "shopware") { - hasShopwareDependencies = true - break - } - } - if requirements, ok := utils.GetJSONValue([]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 - } - } - } - } - } - - 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, - ), - ), - ) - return questionnaire.ErrSilent - } - } - - return nil -} diff --git a/internal/platformify/question/type.go b/internal/platformify/question/type.go deleted file mode 100644 index ff206b1fe..000000000 --- a/internal/platformify/question/type.go +++ /dev/null @@ -1,66 +0,0 @@ -package question - -import ( - "context" - "fmt" - - "github.com/AlecAivazis/survey/v2" - - "github.com/upsun/cli/internal/platformify/colors" - "github.com/upsun/cli/internal/platformify/question/models" -) - -type Type struct{} - -func (q *Type) Ask(ctx context.Context) error { - answers, ok := models.FromContext(ctx) - if !ok { - return nil - } - if answers.Type.String() != "" { - // Skip the step - return nil - } - - defer func() { - _, stderr, ok := colors.FromContext(ctx) - if !ok { - return - } - - if answers.Stack != models.GenericStack { - fmt.Fprintf( - stderr, - "%s %s\n", - colors.Colorize(colors.GreenCode, "✓"), - colors.Colorize( - colors.BrandCode, - fmt.Sprintf("Detected runtime: %s", answers.Type.Runtime.Title()), - ), - ) - } - }() - - runtime := models.RuntimeForStack(answers.Stack) - if runtime == "" { - question := &survey.Select{ - Message: "What language is your project using? We support the following:", - Options: models.Runtimes.AllTitles(), - } - - var title string - err := survey.AskOne(question, &title, survey.WithPageSize(len(question.Options))) - if err != nil { - return err - } - - runtime, err = models.Runtimes.RuntimeByTitle(title) - if err != nil { - return err - } - } - answers.Type.Runtime = runtime - answers.Type.Version = models.DefaultVersionForRuntime(runtime) - - return nil -} diff --git a/internal/platformify/question/web_command.go b/internal/platformify/question/web_command.go deleted file mode 100644 index 7cf827f0a..000000000 --- a/internal/platformify/question/web_command.go +++ /dev/null @@ -1,165 +0,0 @@ -package question - -import ( - "context" - "fmt" - "os" - "path" - "path/filepath" - "slices" - "strings" - - "github.com/upsun/cli/internal/platformify/question/models" - "github.com/upsun/cli/internal/platformify/utils" - "github.com/upsun/cli/internal/platformify/vendorization" -) - -type WebCommand struct{} - -func (q *WebCommand) Ask(ctx context.Context) error { - answers, ok := models.FromContext(ctx) - if !ok { - return nil - } - if answers.WebCommand != "" { - // Skip the step - return nil - } - - // Do not ask the command for PHP applications - if answers.Type.Runtime == models.PHP { - return nil - } - - assets, _ := vendorization.FromContext(ctx) - - //nolint:lll - answers.WebCommand = fmt.Sprintf( - "echo 'Put your web server command in here! You need to listen to \"$UNIX\" unix socket. Read more about it here: %s#web-commands'; sleep 60", - assets.Docs().AppReference, - ) - - if answers.SocketFamily == models.TCP { - //nolint:lll - answers.WebCommand = fmt.Sprintf( - "echo 'Put your web server command in here! You need to listen to \"$PORT\" port. Read more about it here: %s#web-commands'; sleep 60", - - assets.Docs().AppReference, - ) - } - - switch answers.Stack { - case models.Django: - prefix := "" - pythonPath := "" - wsgi := "app.wsgi" - // try to find the wsgi.py file to change the default command - wsgiPath := utils.FindFile(path.Join(answers.WorkingDirectory, answers.ApplicationRoot), "wsgi.py") - if wsgiPath != "" { - wsgiParentDir := path.Base(path.Dir(wsgiPath)) - wsgi = fmt.Sprintf("%s.wsgi", wsgiParentDir) - - // add the pythonpath if the wsgi.py file is not in the root of the app - wsgiRel, _ := filepath.Rel( - path.Join(answers.WorkingDirectory, answers.ApplicationRoot), - path.Dir(path.Dir(wsgiPath)), - ) - if wsgiRel != "." { - pythonPath = "--pythonpath=" + path.Base(path.Dir(path.Dir(wsgiPath))) - } - } - - if slices.Contains(answers.DependencyManagers, models.Pipenv) { - prefix = "pipenv run " - } else if slices.Contains(answers.DependencyManagers, models.Poetry) { - prefix = "poetry run " - } - if answers.SocketFamily == models.TCP { - answers.WebCommand = fmt.Sprintf("%sgunicorn %s -b 0.0.0.0:$PORT %s --log-file -", prefix, pythonPath, wsgi) - return nil - } - - answers.WebCommand = fmt.Sprintf("%sgunicorn %s -b unix:$SOCKET %s --log-file -", prefix, pythonPath, wsgi) - return nil - case models.Rails: - if answers.SocketFamily == models.TCP { - answers.WebCommand = "bundle exec rails server" - return nil - } - - answers.WebCommand = "bundle exec puma -b unix://$SOCKET" - return nil - case models.NextJS: - answers.WebCommand = "npx next start -p $PORT" - return nil - case models.Strapi: - if _, ok := utils.GetJSONValue( - []string{"scripts", "start"}, - path.Join(answers.WorkingDirectory, "package.json"), - true, - ); ok { - if slices.Contains(answers.DependencyManagers, models.Yarn) { - answers.WebCommand = "NODE_ENV=production yarn start" - } else { - answers.WebCommand = "NODE_ENV=production npm start" - } - } - case models.Express: - if _, ok := utils.GetJSONValue( - []string{"scripts", "start"}, - path.Join(answers.WorkingDirectory, "package.json"), - true, - ); ok { - if slices.Contains(answers.DependencyManagers, models.Yarn) { - answers.WebCommand = "NODE_ENV=production yarn start" - } else { - answers.WebCommand = "NODE_ENV=production npm start" - } - return nil - } - - if mainPath, ok := utils.GetJSONValue( - []string{"main"}, - path.Join(answers.WorkingDirectory, "package.json"), - true, - ); ok { - answers.WebCommand = fmt.Sprintf("node %s", mainPath.(string)) - return nil - } - - if indexFile := utils.FindFile(answers.WorkingDirectory, "index.js"); indexFile != "" { - indexFile, _ = filepath.Rel(answers.WorkingDirectory, indexFile) - answers.WebCommand = fmt.Sprintf("node %s", indexFile) - return nil - } - case models.Flask: - appPath := "" - // try to find the app.py, api.py or server.py files - for _, name := range []string{"app.py", "server.py", "api.py"} { - if _, err := os.Stat(path.Join(answers.WorkingDirectory, name)); err == nil { - appPath = fmt.Sprintf("'%s:app'", strings.TrimSuffix(name, ".py")) - break - } - } - if appPath == "" { - return nil - } - - prefix := "" - if slices.Contains(answers.DependencyManagers, models.Pipenv) { - prefix = "pipenv run " - } else if slices.Contains(answers.DependencyManagers, models.Poetry) { - prefix = "poetry run " - } - - if answers.SocketFamily == models.TCP { - answers.WebCommand = fmt.Sprintf("%sgunicorn -b 0.0.0.0:$PORT %s --log-file -", prefix, appPath) - return nil - } - - answers.WebCommand = fmt.Sprintf("%sgunicorn -b unix:$SOCKET %s --log-file -", prefix, appPath) - return nil - } - - return nil -} diff --git a/internal/platformify/question/welcome.go b/internal/platformify/question/welcome.go deleted file mode 100644 index 5546f6b7d..000000000 --- a/internal/platformify/question/welcome.go +++ /dev/null @@ -1,29 +0,0 @@ -package question - -import ( - "context" - "fmt" - - "github.com/upsun/cli/internal/platformify/colors" - "github.com/upsun/cli/internal/platformify/vendorization" -) - -type Welcome struct{} - -func (q *Welcome) Ask(ctx context.Context) error { - out, _, ok := colors.FromContext(ctx) - if !ok { - return nil - } - - assets, _ := vendorization.FromContext(ctx) - fmt.Fprintln( - out, - colors.Colorize(colors.BrandCode, fmt.Sprintf("Welcome to %s!", assets.ServiceName)), - ) - fmt.Fprintln(out, colors.Colorize(colors.BrandCode, "Let's get started with a few questions.")) - fmt.Fprintln(out) - fmt.Fprintln(out, "We need to know a bit more about your project. This will only take a minute!") - fmt.Fprintln(out) - return nil -} diff --git a/internal/platformify/question/working_directory.go b/internal/platformify/question/working_directory.go deleted file mode 100644 index 3044037ae..000000000 --- a/internal/platformify/question/working_directory.go +++ /dev/null @@ -1,93 +0,0 @@ -package question - -import ( - "bytes" - "context" - "fmt" - "os" - "os/exec" - "path" - - "github.com/AlecAivazis/survey/v2" - - "github.com/upsun/cli/internal/platformify/colors" - "github.com/upsun/cli/internal/platformify/question/models" - "github.com/upsun/cli/internal/platformify/vendorization" -) - -type WorkingDirectory struct{} - -func (q *WorkingDirectory) Ask(ctx context.Context) error { - _, stderr, ok := colors.FromContext(ctx) - if !ok { - return nil - } - - cwd, err := os.Getwd() - if err != nil { - return err - } - answers, ok := models.FromContext(ctx) - if !ok { - return nil - } - answers.WorkingDirectory = cwd - answers.HasGit = false - - var outBuf, errBuf bytes.Buffer - cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-dir") - cmd.Stdout = &outBuf - cmd.Stderr = &errBuf - err = cmd.Run() - if err != nil { - fmt.Fprintln( - stderr, - colors.Colorize( - colors.WarningCode, - "It seems like you're not inside a Git repository.", - ), - ) - assets, _ := vendorization.FromContext(ctx) - fmt.Fprintln( - stderr, - colors.Colorize( - colors.WarningCode, - fmt.Sprintf( - "You'll need to create a Git repository to deploy your application to %s", - assets.ServiceName, - ), - ), - ) - return nil - } - - gitRepoAbsPath := path.Dir(outBuf.String()) - answers.HasGit = gitRepoAbsPath == "." - if !answers.HasGit { - fmt.Fprintln( - stderr, - colors.Colorize( - colors.WarningCode, - "Project configuration should be run at the root of a Git repository.", - ), - ) - msg := fmt.Sprintf( - "Would you like to change directory to %s instead?", - gitRepoAbsPath, - ) - proceed := true - if err := survey.AskOne(&survey.Confirm{ - Message: msg, - Default: proceed, - }, &proceed); err != nil { - return nil - } - - if proceed { - answers.WorkingDirectory = gitRepoAbsPath - answers.HasGit = true - } - } - - return nil -} diff --git a/internal/platformify/questionnaire/errors.go b/internal/platformify/questionnaire/errors.go deleted file mode 100644 index 779ff13b5..000000000 --- a/internal/platformify/questionnaire/errors.go +++ /dev/null @@ -1,11 +0,0 @@ -package questionnaire - -import ( - "errors" - "fmt" -) - -var ( - ErrSilent = errors.New("silent error") - ErrUserAborted = fmt.Errorf("user aborted: %w", ErrSilent) -) diff --git a/internal/platformify/questionnaire/questionnaire.go b/internal/platformify/questionnaire/questionnaire.go deleted file mode 100644 index 6292a3079..000000000 --- a/internal/platformify/questionnaire/questionnaire.go +++ /dev/null @@ -1,29 +0,0 @@ -package questionnaire - -import ( - "context" -) - -type Asker interface { - Ask(ctx context.Context) error -} - -type Questionnaire struct { - questions []Asker -} - -func New(questions ...Asker) *Questionnaire { - return &Questionnaire{ - questions: questions, - } -} - -func (i *Questionnaire) AskQuestions(ctx context.Context) error { - for _, question := range i.questions { - err := question.Ask(ctx) - if err != nil { - return err - } - } - return nil -} diff --git a/internal/platformify/init_colors.go b/internal/platformify/survey.go similarity index 84% rename from internal/platformify/init_colors.go rename to internal/platformify/survey.go index 01dc41bb9..2f8b1ad47 100644 --- a/internal/platformify/init_colors.go +++ b/internal/platformify/survey.go @@ -4,8 +4,6 @@ import ( "fmt" "github.com/AlecAivazis/survey/v2" - - "github.com/upsun/cli/internal/platformify/colors" ) func init() { @@ -15,7 +13,7 @@ func init() { {{- color "%[2]s"}}[{{.Answer}}]{{color "reset"}}{{"\n"}} {{ else }} {{- if .Default }}{{color "%[3]s"}}[{{.Default}}] {{color "reset"}}{{ end }} -{{- end }}`, colors.Accent, colors.Brand, colors.Secondary, colors.Error, colors.Default) +{{- end }}`, colorAccent, colorBrand, colorSecondary, colorError, colorDefault) //nolint:lll survey.SelectQuestionTemplate = fmt.Sprintf(` @@ -31,7 +29,7 @@ func init() { {{- range $ix, $option := .PageEntries}} {{- template "option" $.IterateOption $ix $option}} {{- end}} -{{- end }}`, colors.Accent, colors.Brand, colors.Secondary, colors.Error, colors.Default) +{{- end }}`, colorAccent, colorBrand, colorSecondary, colorError, colorDefault) survey.ConfirmQuestionTemplate = fmt.Sprintf(` {{- color "%[1]s"}}{{ .Message }} {{color "reset"}} @@ -39,7 +37,7 @@ func init() { {{- color "%[2]s"}}[{{.Answer}}]{{color "reset"}}{{"\n"}} {{- else }} {{- color "%[3]s"}}{{if .Default}}(Y/n){{else}}(y/N){{end}}{{color "reset"}}{{"\n"}} -{{- end }}`, colors.Accent, colors.Brand, colors.Secondary, colors.Error, colors.Default) +{{- end }}`, colorAccent, colorBrand, colorSecondary, colorError, colorDefault) //nolint:lll survey.MultiSelectQuestionTemplate = fmt.Sprintf(` @@ -56,5 +54,5 @@ func init() { {{- range $ix, $option := .PageEntries}} {{- template "option" $.IterateOption $ix $option}} {{- end}} -{{- end }}`, colors.Accent, colors.Brand, colors.Secondary, colors.Error, colors.Default) +{{- end }}`, colorAccent, colorBrand, colorSecondary, colorError, colorDefault) } diff --git a/internal/platformify/platformifier/templates/django/settings_psh.py b/internal/platformify/templates/django/settings_psh.py similarity index 100% rename from internal/platformify/platformifier/templates/django/settings_psh.py rename to internal/platformify/templates/django/settings_psh.py diff --git a/internal/platformify/platformifier/templates/generic/.environment b/internal/platformify/templates/fixed/.environment similarity index 100% rename from internal/platformify/platformifier/templates/generic/.environment rename to internal/platformify/templates/fixed/.environment diff --git a/internal/platformify/platformifier/templates/generic/.platform.app.yaml b/internal/platformify/templates/fixed/.platform.app.yaml similarity index 100% rename from internal/platformify/platformifier/templates/generic/.platform.app.yaml rename to internal/platformify/templates/fixed/.platform.app.yaml diff --git a/internal/platformify/platformifier/templates/generic/.platform/routes.yaml b/internal/platformify/templates/fixed/.platform/routes.yaml similarity index 100% rename from internal/platformify/platformifier/templates/generic/.platform/routes.yaml rename to internal/platformify/templates/fixed/.platform/routes.yaml diff --git a/internal/platformify/platformifier/templates/generic/.platform/services.yaml b/internal/platformify/templates/fixed/.platform/services.yaml similarity index 100% rename from internal/platformify/platformifier/templates/generic/.platform/services.yaml rename to internal/platformify/templates/fixed/.platform/services.yaml diff --git a/internal/platformify/platformifier/templates/upsun/.environment b/internal/platformify/templates/flex/.environment similarity index 100% rename from internal/platformify/platformifier/templates/upsun/.environment rename to internal/platformify/templates/flex/.environment diff --git a/internal/platformify/platformifier/templates/upsun/.upsun/config.yaml b/internal/platformify/templates/flex/.upsun/config.yaml similarity index 100% rename from internal/platformify/platformifier/templates/upsun/.upsun/config.yaml rename to internal/platformify/templates/flex/.upsun/config.yaml diff --git a/internal/platformify/utils/utils.go b/internal/platformify/utils/utils.go deleted file mode 100644 index d83b378ea..000000000 --- a/internal/platformify/utils/utils.go +++ /dev/null @@ -1,173 +0,0 @@ -package utils - -import ( - "bufio" - "bytes" - "cmp" - "encoding/json" - "io" - "os" - "path/filepath" - "slices" - "strings" - - "github.com/pelletier/go-toml/v2" -) - -var skipDirs = []string{ - "vendor", - "node_modules", - ".next", - ".git", -} - -// FileExists checks if the file exists -func FileExists(searchPath, name string) bool { - return FindFile(searchPath, name) != "" -} - -// FindFile searches for the file inside the path recursively -// and returns the full path of the file if found -// If multiple files exist, tries to return the one closest to root -func FindFile(searchPath, name string) string { - files := FindAllFiles(searchPath, name) - if len(files) == 0 { - return "" - } - - slices.SortFunc(files, func(a, b string) int { - return cmp.Compare(strings.Count(a, string(os.PathSeparator)), strings.Count(b, string(os.PathSeparator))) - }) - return files[0] -} - -// FindAllFiles searches for the file inside the path recursively and returns all matches -func FindAllFiles(searchPath, name string) []string { - found := make([]string, 0) - _ = filepath.WalkDir(searchPath, func(p string, d os.DirEntry, err error) error { - if err != nil { - return err - } - - if d.IsDir() { - // Skip vendor directories - if slices.Contains(skipDirs, d.Name()) { - return filepath.SkipDir - } - return nil - } - - if d.Name() == name { - found = append(found, p) - } - - return nil - }) - - return found -} - -func GetMapValue(keyPath []string, data map[string]interface{}) (value interface{}, ok bool) { - if len(keyPath) == 0 { - return data, true - } - - for _, key := range keyPath[:len(keyPath)-1] { - if value, ok = data[key]; !ok { - return nil, false - } - - if data, ok = value.(map[string]interface{}); !ok { - return nil, false - } - } - - if value, ok = data[keyPath[len(keyPath)-1]]; !ok { - return nil, false - } - - return value, true -} - -// GetJSONValue gets a value from a JSON file, by traversing the path given -func GetJSONValue(keyPath []string, filePath string, caseInsensitive bool) (value interface{}, ok bool) { - fin, err := os.Open(filePath) - if err != nil { - return nil, false - } - defer fin.Close() - - rawData, err := io.ReadAll(fin) - if err != nil { - return nil, false - } - - if caseInsensitive { - rawData = bytes.ToLower(rawData) - for i := range keyPath { - keyPath[i] = strings.ToLower(keyPath[i]) - } - } - - var data map[string]interface{} - err = json.Unmarshal(rawData, &data) - if err != nil { - return nil, false - } - - return GetMapValue(keyPath, data) -} - -// ContainsStringInFile checks if the given file contains the given string -func ContainsStringInFile(file io.Reader, target string, caseInsensitive bool) (bool, error) { - if caseInsensitive { - target = strings.ToLower(target) - } - scanner := bufio.NewScanner(file) - for scanner.Scan() { - if caseInsensitive { - if strings.Contains(strings.ToLower(scanner.Text()), target) { - return true, nil - } - } else { - if strings.Contains(scanner.Text(), target) { - return true, nil - } - } - } - - if err := scanner.Err(); err != nil { - return false, err - } - - return false, nil -} - -// GetTOMLValue gets a value from a TOML file, by traversing the path given -func GetTOMLValue(keyPath []string, filePath string, caseInsensitive bool) (value interface{}, ok bool) { - fin, err := os.Open(filePath) - if err != nil { - return nil, false - } - defer fin.Close() - - rawData, err := io.ReadAll(fin) - if err != nil { - return nil, false - } - - if caseInsensitive { - rawData = bytes.ToLower(rawData) - for i := range keyPath { - keyPath[i] = strings.ToLower(keyPath[i]) - } - } - - var data map[string]interface{} - err = toml.Unmarshal(rawData, &data) - if err != nil { - return nil, false - } - - return GetMapValue(keyPath, data) -} diff --git a/internal/platformify/vendorization/vendorization.go b/internal/platformify/vendorization/vendorization.go deleted file mode 100644 index 55de75b4c..000000000 --- a/internal/platformify/vendorization/vendorization.go +++ /dev/null @@ -1,87 +0,0 @@ -package vendorization - -import ( - "context" - "fmt" -) - -type vendorAssetsKey string - -var key vendorAssetsKey = "vendorAssets" - -type Docs struct { - AppReference string - GettingStarted string - Hooks string - PHP string - Routes string - Services string - SymfonyCLI string - TimeZone string - Variables string -} - -type VendorAssets struct { - Binary string - ConfigFlavor string - DocsBaseURL string - EnvPrefix string - ServiceName string - Use string -} - -func (va *VendorAssets) ProprietaryFiles() []string { - if va.ConfigFlavor == "upsun" { - return []string{ - ".environment", - ".upsun/config.yaml", - } - } - - return []string{ - ".environment", - ".platform.app.yaml", - ".platform/services.yaml", - ".platform/routes.yaml", - ".platform/applications.yaml", - } -} - -func (va *VendorAssets) Docs() *Docs { - return &Docs{ - AppReference: fmt.Sprintf("%s/app/reference/", va.DocsBaseURL), - GettingStarted: fmt.Sprintf("%s/stacks/symfony/", va.DocsBaseURL), - Hooks: fmt.Sprintf("%s/app/hooks/compare/", va.DocsBaseURL), - PHP: fmt.Sprintf("%s/languages/php/", va.DocsBaseURL), - Routes: fmt.Sprintf("%s/routes/", va.DocsBaseURL), - Services: fmt.Sprintf("%s/services/", va.DocsBaseURL), - SymfonyCLI: fmt.Sprintf("%s/stacks/symfony/cli/tips/", va.DocsBaseURL), - TimeZone: fmt.Sprintf("%s/app/timezone/", va.DocsBaseURL), - Variables: fmt.Sprintf("%s/variables/use/provided/", va.DocsBaseURL), - } -} - -func defaults() *VendorAssets { - // Return all values as DEFAULT VALUE key - return &VendorAssets{ - Binary: "DEFAULT VALUE BINARY", - ConfigFlavor: "DEFAULT VALUE CONFIGFLAVOR", - DocsBaseURL: "DEFAULT VALUE DOCS BASE URL", - EnvPrefix: "DEFAULT VALUE ENVPREFIX", - ServiceName: "DEFAULT VALUE SERVICENAME", - Use: "DEFAULT VALUE USE", - } -} - -func FromContext(ctx context.Context) (*VendorAssets, bool) { - assets, ok := ctx.Value(key).(*VendorAssets) - if !ok { - return defaults(), false - } - - return assets, ok -} - -func WithVendorAssets(ctx context.Context, assets *VendorAssets) context.Context { - return context.WithValue(ctx, key, assets) -} From 54b2e75e06473088e1a7c77221219a078f7d30d3 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Sun, 18 Oct 2026 16:43:40 +0100 Subject: [PATCH 3/4] fix(init): configure apps in subdirectories, and fix template variables Fix problems inherited from Platformify, found in review: - Detect the application root before the steps that use it, and resolve the PHP web root, package.json, index.js and the Flask app from it, so that an app in a subdirectory gets paths that exist. - Write .environment, and on Fixed .platform.app.yaml, in the application root, where they are read. - Ask about overwriting once the application root is known, listing the files that would be generated, and list the written files when done. This drops .platform/applications.yaml, which was never written. - Export REDIS_URL for Redis Persistent: the template compared the cache to "redis_persistent", which never matched. - Set DATABASE_SCHEME for Strapi on Fixed from $DB_SCHEME: $DB_CONNECTION is only set for Laravel. - Quote relationship names in jq paths on Fixed, as jq parsed names such as "redis-persistent" as a subtraction. - Render relationships from the service names, and drop the unused endpoint mapping. Refs CLI-184. Co-Authored-By: Claude Opus 5.5 --- internal/platformify/detect.go | 14 ++-- internal/platformify/detect_test.go | 58 +++++++++++++++++ internal/platformify/fixtures_test.go | 12 +--- internal/platformify/generate.go | 44 ++++++------- internal/platformify/generate_test.go | 65 +++++++++++++------ internal/platformify/messages.go | 18 +---- internal/platformify/platformify.go | 54 ++++++++------- .../platformify/templates/fixed/.environment | 24 +++---- .../templates/fixed/.platform.app.yaml | 6 +- .../platformify/templates/flex/.environment | 2 +- .../templates/flex/.upsun/config.yaml | 6 +- 11 files changed, 185 insertions(+), 118 deletions(-) diff --git a/internal/platformify/detect.go b/internal/platformify/detect.go index ca6c12390..2d4a97486 100644 --- a/internal/platformify/detect.go +++ b/internal/platformify/detect.go @@ -227,7 +227,7 @@ func (w *wizard) setBuildSteps() error { a.environment["N_PREFIX"] = "/app/.global" a.buildSteps = append(a.buildSteps, "n auto || n lts", "hash -r") } - _, hasBuild := jsonValue(filepath.Join(w.dir, packageJSON), "scripts", "build") + _, hasBuild := jsonValue(filepath.Join(w.appDir(), packageJSON), "scripts", "build") if dm == yarn { a.buildSteps = append(a.buildSteps, "yarn") if hasBuild { @@ -332,7 +332,7 @@ func (w *wizard) setWebCommand() error { listen, w.assets.Docs().AppReference, ) - packageJSONPath := filepath.Join(w.dir, packageJSON) + packageJSONPath := filepath.Join(w.appDir(), packageJSON) npmStart := func() string { if slices.Contains(a.dependencyManagers, yarn) { return "NODE_ENV=production yarn start" @@ -375,13 +375,13 @@ func (w *wizard) setWebCommand() error { if s, ok := main.(string); ok { a.webCommand = "node " + s } - } else if index := findFile(w.dir, "index.js"); index != "" { - rel, _ := filepath.Rel(w.dir, index) + } else if index := findFile(w.appDir(), "index.js"); index != "" { + rel, _ := filepath.Rel(w.appDir(), index) a.webCommand = "node " + filepath.ToSlash(rel) } case flask: for _, name := range []string{"app.py", "server.py", "api.py"} { - if _, err := os.Stat(filepath.Join(w.dir, name)); err != nil { + if _, err := os.Stat(filepath.Join(w.appDir(), name)); err != nil { continue } app := fmt.Sprintf("'%s:app'", strings.TrimSuffix(name, ".py")) @@ -412,8 +412,8 @@ func (w *wizard) setLocations() error { "passthru": "/index.php", "root": "", } - if index := findFile(w.dir, "index.php"); index != "" { - if rel, _ := filepath.Rel(w.dir, filepath.Dir(index)); rel != "." { + if index := findFile(w.appDir(), "index.php"); index != "" { + if rel, _ := filepath.Rel(w.appDir(), filepath.Dir(index)); rel != "." { location["root"] = filepath.ToSlash(rel) } } diff --git a/internal/platformify/detect_test.go b/internal/platformify/detect_test.go index 8b65294c1..eaf46d74a 100644 --- a/internal/platformify/detect_test.go +++ b/internal/platformify/detect_test.go @@ -1,6 +1,7 @@ package platformify import ( + "io" "os" "path/filepath" "testing" @@ -113,3 +114,60 @@ func TestSetWebCommandExpressMain(t *testing.T) { require.NoError(t, w.setWebCommand()) assert.Equal(t, "node src/Server.js", w.answers.webCommand) } + +func TestSubdirectoryApp(t *testing.T) { + cases := []struct { + name string + files map[string]string + check func(t *testing.T, a *answers) + }{ + { + name: "laravel", + files: map[string]string{ + "app/composer.json": `{"require": {"laravel/framework": "^11"}}`, + "app/composer.lock": "{}", + "app/public/index.php": "", + }, + check: func(t *testing.T, a *answers) { + assert.Equal(t, "app", a.applicationRoot) + assert.Equal(t, "public", a.locations["/"]["root"]) + }, + }, + { + name: "express", + files: map[string]string{ + "frontend/package.json": `{"dependencies": {"express": "^5"}, "scripts": {"build": "tsc"}}`, + "frontend/package-lock.json": "{}", + "frontend/src/index.js": "", + }, + check: func(t *testing.T, a *answers) { + assert.Equal(t, "frontend", a.applicationRoot) + assert.Equal(t, []string{"npm i", "npm run build"}, a.buildSteps) + assert.Equal(t, "node src/index.js", a.webCommand) + }, + }, + { + name: "flask", + files: map[string]string{ + "api/requirements.txt": "flask\n", + "api/app.py": "", + }, + check: func(t *testing.T, a *answers) { + assert.Equal(t, "api", a.applicationRoot) + assert.Equal(t, "gunicorn -b unix:$SOCKET 'app:app' --log-file -", a.webCommand) + }, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + w := &wizard{dir: writeTree(t, c.files), stdout: io.Discard, stderr: io.Discard, assets: &assets{}, + answers: &answers{}} + require.NoError(t, w.detectStack()) + w.answers.runtime = w.answers.stack.runtime() + for _, step := range w.detectionSteps() { + require.NoError(t, step()) + } + c.check(t, w.answers) + }) + } +} diff --git a/internal/platformify/fixtures_test.go b/internal/platformify/fixtures_test.go index 42958693d..b2af0983d 100644 --- a/internal/platformify/fixtures_test.go +++ b/internal/platformify/fixtures_test.go @@ -38,10 +38,6 @@ func testInputs() map[string]*templateInput { "/.npm": {"source": "local", "source_path": "npm"}, "/.pip": {"source": "local", "source_path": "pip"}, }, - Relationships: map[string]relationship{ - "db": {Service: "db", Endpoint: "postgresql"}, - "mysql": {Service: "mysql", Endpoint: "mysql"}, - }, Services: []service{ { Name: "db", @@ -78,9 +74,6 @@ func testInputs() map[string]*templateInput { Mounts: map[string]map[string]string{ "/.mvn": {"source": "local", "source_path": "maven"}, }, - Relationships: map[string]relationship{ - "mysql": {Service: "mysql", Endpoint: "mysql"}, - }, Services: []service{ { Name: "mysql", @@ -102,9 +95,8 @@ func testInputs() map[string]*templateInput { Locations: map[string]map[string]any{ "/": {"root": "index.php"}, }, - Dependencies: map[string]map[string]string{}, - Mounts: map[string]map[string]string{}, - Relationships: map[string]relationship{}, + Dependencies: map[string]map[string]string{}, + Mounts: map[string]map[string]string{}, }, "nextjs": {Name: "nextjs", Type: "nodejs:24", Runtime: "nodejs", Stack: nextJS}, "strapi": {Name: "strapi", Type: "nodejs:24", Runtime: "nodejs", Stack: strapi}, diff --git a/internal/platformify/generate.go b/internal/platformify/generate.go index 8e937cba5..dd8068348 100644 --- a/internal/platformify/generate.go +++ b/internal/platformify/generate.go @@ -8,6 +8,7 @@ import ( "os" "path" "path/filepath" + "slices" "strings" "text/template" @@ -51,12 +52,6 @@ type templateInput struct { Disk string Mounts map[string]map[string]string Services []service - Relationships map[string]relationship -} - -type relationship struct { - Service string - Endpoint string } func (w *wizard) templateInput() *templateInput { @@ -85,27 +80,17 @@ func (w *wizard) templateInput() *templateInput { Disk: a.disk, Mounts: a.mounts, Services: a.services, - Relationships: relationships(a.services), } } -// relationships returns the relationship for each service, by service name. -func relationships(services []service) map[string]relationship { - endpoints := map[string]string{ - "mariadb": "mysql", - "oracle-mysql": "mysql", - "chrome-headless": "http", - "redis-persistent": "redis", - } - r := make(map[string]relationship, len(services)) - for _, s := range services { - endpoint, _, _ := strings.Cut(s.Type, ":") - if e, ok := endpoints[endpoint]; ok { - endpoint = e - } - r[s.Name] = relationship{Service: s.Name, Endpoint: endpoint} +// ServiceNames returns the sorted names of the services. +func (t *templateInput) ServiceNames() []string { + names := make([]string, len(t.Services)) + for i, s := range t.Services { + names[i] = s.Name } - return r + slices.Sort(names) + return names } // Database returns the name of the first database service. @@ -160,7 +145,7 @@ func generate(dir string, input *templateInput) (map[string][]byte, error) { return err } if len(bytes.TrimSpace(content)) > 0 { - files[strings.TrimPrefix(name, templatesDir+"/")] = content + files[outputPath(strings.TrimPrefix(name, templatesDir+"/"), input.ApplicationRoot)] = content } return nil }) @@ -175,6 +160,17 @@ func generate(dir string, input *templateInput) (map[string][]byte, error) { return files, nil } +// appFiles are written in the application root, and the other files in the project root. +var appFiles = []string{".environment", ".platform.app.yaml"} + +// outputPath returns the path for a template's output, relative to the project root. +func outputPath(name, appRoot string) string { + if slices.Contains(appFiles, name) { + return strings.TrimPrefix(path.Join(appRoot, name), "/") + } + return name +} + // generateDjango adds a settings_psh.py file next to settings.py, and imports it there. func generateDjango(dir string, input *templateInput, files map[string][]byte) error { settingsPath := findFile(filepath.Join(dir, filepath.FromSlash(input.ApplicationRoot)), settingsPyFile) diff --git a/internal/platformify/generate_test.go b/internal/platformify/generate_test.go index 76febcef8..05f31a6d6 100644 --- a/internal/platformify/generate_test.go +++ b/internal/platformify/generate_test.go @@ -1,6 +1,7 @@ package platformify import ( + "maps" "os" "path/filepath" "slices" @@ -23,9 +24,7 @@ func TestGenerate(t *testing.T) { files, err := generate(dir, in) require.NoError(t, err) - for f := range files { - assert.Contains(t, a.configFiles(), f) - } + assert.NotEmpty(t, files) require.NoError(t, writeFiles(dir, files)) result, err := lint.CheckDir(dir) require.NoError(t, err) @@ -57,25 +56,51 @@ func TestGenerateDjango(t *testing.T) { assert.NotContains(t, files, "app/mysite/settings.py") } -func TestRelationships(t *testing.T) { - services := []service{ - {Name: "mysql", Type: "mysql:5.7"}, - {Name: "mariadb", Type: "mariadb:11.8"}, - {Name: "oracle-mysql", Type: "oracle-mysql:8.4"}, - {Name: "chrome-headless", Type: "chrome-headless:120"}, - {Name: "redis-persistent", Type: "redis-persistent:8.0"}, - } - assert.Equal(t, map[string]relationship{ - "mysql": {Service: "mysql", Endpoint: "mysql"}, - "mariadb": {Service: "mariadb", Endpoint: "mysql"}, - "oracle-mysql": {Service: "oracle-mysql", Endpoint: "mysql"}, - "chrome-headless": {Service: "chrome-headless", Endpoint: "http"}, - "redis-persistent": {Service: "redis-persistent", Endpoint: "redis"}, - }, relationships(services)) -} - func TestVersions(t *testing.T) { for _, c := range slices.Concat(runtimeChoices, serviceChoices) { assert.NotEmpty(t, versions(c.typ), c.typ) } } + +func TestGenerateSubdirectoryApp(t *testing.T) { + cases := []struct { + flex bool + want []string + }{ + {true, []string{".upsun/config.yaml", "app/.environment"}}, + {false, []string{".platform/routes.yaml", ".platform/services.yaml", "app/.environment", "app/.platform.app.yaml"}}, + } + for _, c := range cases { + in := testInputs()["generic"] + in.Assets = &assets{flex: c.flex} + in.ApplicationRoot = "/app" + files, err := generate(t.TempDir(), in) + require.NoError(t, err) + assert.ElementsMatch(t, c.want, slices.Collect(maps.Keys(files))) + } +} + +func TestGenerateRedisPersistent(t *testing.T) { + in := testInputs()["generic"] + in.Services = []service{{Name: "redis-persistent", Type: "redis-persistent:8.0"}} + for _, flex := range []bool{true, false} { + in.Assets = &assets{flex: flex} + files, err := generate(t.TempDir(), in) + require.NoError(t, err) + assert.Contains(t, string(files[".environment"]), `export REDIS_URL="$CACHE_URL"`) + if !flex { + assert.Contains(t, string(files[".environment"]), `jq -r '.["redis-persistent"][0].host'`) + } + } +} + +func TestGenerateStrapiDatabaseScheme(t *testing.T) { + in := testInputs()["strapi"] + in.Services = []service{{Name: "postgresql", Type: "postgresql:18"}} + for _, flex := range []bool{true, false} { + in.Assets = &assets{flex: flex} + files, err := generate(t.TempDir(), in) + require.NoError(t, err) + assert.Contains(t, string(files[".environment"]), `export DATABASE_SCHEME="$DB_SCHEME"`) + } +} diff --git a/internal/platformify/messages.go b/internal/platformify/messages.go index 10feda174..3f49521f5 100644 --- a/internal/platformify/messages.go +++ b/internal/platformify/messages.go @@ -26,14 +26,14 @@ func (w *wizard) almostDone() error { return nil } -func (w *wizard) done() { +func (w *wizard) done(files []string) { out := w.stdout fmt.Fprintln(out) fmt.Fprintln(out, "┌───────────────────────────────────────────────────┐") fmt.Fprintln(out, "│ CONGRATULATIONS! │") fmt.Fprintln(out, "│ │") fmt.Fprintln(out, "│ We have created the following files for your: │") - for _, f := range w.assets.configFiles() { + for _, f := range files { fmt.Fprintf(out, "│ - %-44s│\n", f) } fmt.Fprintln(out, "│ │") @@ -106,17 +106,3 @@ func (a *assets) Docs() docs { Variables: a.docsBaseURL + "/variables/use/provided/", } } - -// configFiles returns the paths of the files that the wizard creates. -func (a *assets) configFiles() []string { - if a.flex { - return []string{".environment", ".upsun/config.yaml"} - } - return []string{ - ".environment", - ".platform.app.yaml", - ".platform/services.yaml", - ".platform/routes.yaml", - ".platform/applications.yaml", - } -} diff --git a/internal/platformify/platformify.go b/internal/platformify/platformify.go index 8020241cb..ef170129e 100644 --- a/internal/platformify/platformify.go +++ b/internal/platformify/platformify.go @@ -3,9 +3,11 @@ package platformify import ( + "bytes" "errors" "fmt" "io" + "maps" "os" "path/filepath" "regexp" @@ -45,24 +47,11 @@ func Run(cnf *config.Config, opts Options) error { assets: newAssets(cnf), answers: &answers{}, } - steps := []func() error{ - w.confirmOverwrite, - w.welcome, - w.detectStack, - w.askRuntime, - w.detectDependencyManagers, - w.setLocations, - w.setMounts, - w.askName, - w.detectApplicationRoot, - w.setEnvironment, - w.setBuildSteps, - w.setDeployCommand, - w.setSocketFamily, - w.setWebCommand, - w.almostDone, - w.askServices, - } + steps := slices.Concat( + []func() error{w.welcome, w.detectStack, w.askRuntime}, + w.detectionSteps(), + []func() error{w.confirmOverwrite, w.askName, w.almostDone, w.askServices}, + ) for _, step := range steps { if err := step(); err != nil { if errors.Is(err, errAborted) { @@ -82,10 +71,25 @@ func Run(cnf *config.Config, opts Options) error { if w.answers.stack == laravel { w.suggestLaravelBridge() } - w.done() + w.done(slices.Sorted(maps.Keys(files))) return nil } +// detectionSteps returns the steps that configure the app from its files, after the stack and runtime are known. +func (w *wizard) detectionSteps() []func() error { + return []func() error{ + w.detectDependencyManagers, + w.detectApplicationRoot, + w.setLocations, + w.setMounts, + w.setEnvironment, + w.setBuildSteps, + w.setDeployCommand, + w.setSocketFamily, + w.setWebCommand, + } +} + // writeFiles writes files, keyed by slash-separated paths relative to dir. func writeFiles(dir string, files map[string][]byte) error { for name, content := range files { @@ -100,11 +104,17 @@ func writeFiles(dir string, files map[string][]byte) error { return nil } -// confirmOverwrite asks whether to continue, if configuration files exist. +// confirmOverwrite asks whether to continue, if files that would be generated exist. func (w *wizard) confirmOverwrite() error { + files, err := generate(w.dir, w.templateInput()) + if err != nil { + return err + } var existing []string - for _, f := range w.assets.configFiles() { - if st, err := os.Stat(filepath.Join(w.dir, f)); err == nil && !st.IsDir() { + for _, f := range slices.Sorted(maps.Keys(files)) { + current, err := os.ReadFile(filepath.Join(w.dir, filepath.FromSlash(f))) + // Files that are only appended to (Django's settings.py) are not overwritten. + if err == nil && !bytes.HasPrefix(files[f], current) { existing = append(existing, f) } } diff --git a/internal/platformify/templates/fixed/.environment b/internal/platformify/templates/fixed/.environment index 63a6498ee..91132effe 100644 --- a/internal/platformify/templates/fixed/.environment +++ b/internal/platformify/templates/fixed/.environment @@ -2,15 +2,15 @@ export RELATIONSHIPS_JSON="$(echo ${{ .Assets.EnvPrefix }}_RELATIONSHIPS | base6 {{- if .Database }} # Set database environment variables -export DB_HOST="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Database }}[0].host')" -export DB_PORT="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Database }}[0].port')" -export DB_PATH="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Database }}[0].path')" +export DB_HOST="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Database }}"][0].host')" +export DB_PORT="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Database }}"][0].port')" +export DB_PATH="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Database }}"][0].path')" export DB_DATABASE="$DB_PATH" -export DB_USERNAME="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Database }}[0].username')" -export DB_PASSWORD="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Database }}[0].password')" +export DB_USERNAME="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Database }}"][0].username')" +export DB_PASSWORD="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Database }}"][0].password')" {{ if eq .Database "postgresql" }}export DB_SCHEME="postgresql" {{- else if and (eq .Stack.Name "rails") (or (eq .Database "mariadb") (eq .Database "mysql")) }}export DB_SCHEME="mysql2" # mysql/mariadb rails adapter -{{- else }}export DB_SCHEME="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Database }}[0].scheme')"{{ end }} +{{- else }}export DB_SCHEME="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Database }}"][0].scheme')"{{ end }} {{- end -}} {{- if eq .Stack.Name "laravel" }} @@ -21,13 +21,13 @@ export DATABASE_URL="${DB_SCHEME}://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${D {{- if .Cache }} # Set Cache environment variables -export CACHE_HOST="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Cache }}[0].host')" -export CACHE_PORT="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Cache }}[0].port')" -export CACHE_PASSWORD="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Cache }}[0].password')" -export CACHE_SCHEME="$(echo $RELATIONSHIPS_JSON | jq -r '.{{ .Cache }}[0].scheme')" +export CACHE_HOST="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Cache }}"][0].host')" +export CACHE_PORT="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Cache }}"][0].port')" +export CACHE_PASSWORD="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Cache }}"][0].password')" +export CACHE_SCHEME="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Cache }}"][0].scheme')" export CACHE_URL="${CACHE_SCHEME}://${CACHE_PASSWORD}@${CACHE_HOST}:${CACHE_PORT}" {{- end -}} -{{- if or (eq .Cache "redis") (eq .Cache "redis_persistent") }} +{{- if or (eq .Cache "redis") (eq .Cache "redis-persistent") }} # Set Redis environment variables export REDIS_URL="$CACHE_URL" @@ -40,7 +40,7 @@ export DATABASE_PORT="$DB_PORT" export DATABASE_NAME="$DB_PATH" export DATABASE_USERNAME="$DB_USERNAME" export DATABASE_PASSWORD="$DB_PASSWORD" -export DATABASE_SCHEME="$DB_CONNECTION" +export DATABASE_SCHEME="$DB_SCHEME" # Set secrets needed by Strapi, if they are not set # Prefer setting these as project secret variables with {{ .Assets.Binary }} variable:create env:SECRET_NAME --sensitive=true diff --git a/internal/platformify/templates/fixed/.platform.app.yaml b/internal/platformify/templates/fixed/.platform.app.yaml index 0c21624a2..d5ec92ac0 100644 --- a/internal/platformify/templates/fixed/.platform.app.yaml +++ b/internal/platformify/templates/fixed/.platform.app.yaml @@ -17,10 +17,10 @@ type: "{{ .Type }}" # to the application in the {{ .Assets.EnvPrefix }}_RELATIONSHIPS variable. The right-hand # side is in the form `:`. # More information: {{ .Assets.Docs.AppReference }}relationships/ -{{ if .Relationships -}} +{{ if .Services -}} relationships: - {{ range $key, $value := .Relationships }} - {{- $key }}: + {{ range .ServiceNames }} + {{- . }}: {{ end -}} {{ else }} # relationships: diff --git a/internal/platformify/templates/flex/.environment b/internal/platformify/templates/flex/.environment index 6d8730df1..7e8813427 100644 --- a/internal/platformify/templates/flex/.environment +++ b/internal/platformify/templates/flex/.environment @@ -23,7 +23,7 @@ export CACHE_PORT="${{ .CacheUpper }}_PORT" export CACHE_SCHEME="${{ .CacheUpper }}_SCHEME" export CACHE_URL="${CACHE_SCHEME}://${CACHE_HOST}:${CACHE_PORT}" {{- end -}} -{{- if or (eq .Cache "redis") (eq .Cache "redis_persistent") }} +{{- if or (eq .Cache "redis") (eq .Cache "redis-persistent") }} # Set Redis environment variables export REDIS_URL="$CACHE_URL" diff --git a/internal/platformify/templates/flex/.upsun/config.yaml b/internal/platformify/templates/flex/.upsun/config.yaml index 7ec361e42..2e6ccac2a 100644 --- a/internal/platformify/templates/flex/.upsun/config.yaml +++ b/internal/platformify/templates/flex/.upsun/config.yaml @@ -18,10 +18,10 @@ applications: # to the application in the {{ .Assets.EnvPrefix }}_RELATIONSHIPS variable. The right-hand # side is in the form `:`. # More information: {{ .Assets.Docs.AppReference }}relationships/ - {{ if .Relationships -}} + {{ if .Services -}} relationships: - {{ range $key, $value := .Relationships }} - {{- $key }}: + {{ range .ServiceNames }} + {{- . }}: {{ end -}} {{ else }} # relationships: From eca88aadb275982bccd99b292aadccdd23c355a8 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Sun, 18 Oct 2026 16:47:50 +0100 Subject: [PATCH 4/4] fix(init): warn about other existing configuration files The overwrite check only looked at the files the wizard generates, so on Fixed an app config at another path (e.g. a root .platform.app.yaml when the app is now in a subdirectory, or .platform/applications.yaml) was kept without a warning, and could define a conflicting app. List those files, and other .upsun/*.yaml files on Flex, as kept, and ask whether to continue. Refs CLI-184. Co-Authored-By: Claude Opus 5.5 --- internal/platformify/generate_test.go | 46 ++++++++++++++++++ internal/platformify/platformify.go | 70 +++++++++++++++++++++------ 2 files changed, 101 insertions(+), 15 deletions(-) diff --git a/internal/platformify/generate_test.go b/internal/platformify/generate_test.go index 05f31a6d6..64b05f72f 100644 --- a/internal/platformify/generate_test.go +++ b/internal/platformify/generate_test.go @@ -104,3 +104,49 @@ func TestGenerateStrapiDatabaseScheme(t *testing.T) { assert.Contains(t, string(files[".environment"]), `export DATABASE_SCHEME="$DB_SCHEME"`) } } + +func TestExistingConfig(t *testing.T) { + cases := []struct { + name string + flex bool + files map[string]string + wantOverwrite []string + wantConflicts []string + }{ + { + name: "fixed app moved to a subdirectory", + files: map[string]string{ + "app/composer.lock": "{}", + ".platform.app.yaml": "name: app\n", + ".platform/applications.yaml": "[]\n", + }, + wantConflicts: []string{".platform.app.yaml", ".platform/applications.yaml"}, + }, + { + name: "fixed app overwritten", + files: map[string]string{"app/composer.lock": "{}", "app/.platform.app.yaml": "name: app\n"}, + wantOverwrite: []string{"app/.platform.app.yaml"}, + }, + { + name: "flex", + flex: true, + files: map[string]string{".upsun/config.yaml": "applications: {}\n", ".upsun/other.yml": "services: {}\n"}, + wantOverwrite: []string{".upsun/config.yaml"}, + wantConflicts: []string{".upsun/other.yml"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dir := writeTree(t, c.files) + in := testInputs()["generic"] + in.Assets = &assets{flex: c.flex} + in.ApplicationRoot = "/app" + files, err := generate(dir, in) + require.NoError(t, err) + w := &wizard{dir: dir, assets: in.Assets} + overwrite, conflicts := w.existingConfig(files) + assert.Equal(t, c.wantOverwrite, overwrite) + assert.Equal(t, c.wantConflicts, conflicts) + }) + } +} diff --git a/internal/platformify/platformify.go b/internal/platformify/platformify.go index ef170129e..21fdfe340 100644 --- a/internal/platformify/platformify.go +++ b/internal/platformify/platformify.go @@ -104,31 +104,33 @@ func writeFiles(dir string, files map[string][]byte) error { return nil } -// confirmOverwrite asks whether to continue, if files that would be generated exist. +// confirmOverwrite asks whether to continue, if configuration files exist. func (w *wizard) confirmOverwrite() error { files, err := generate(w.dir, w.templateInput()) if err != nil { return err } - var existing []string - for _, f := range slices.Sorted(maps.Keys(files)) { - current, err := os.ReadFile(filepath.Join(w.dir, filepath.FromSlash(f))) - // Files that are only appended to (Django's settings.py) are not overwritten. - if err == nil && !bytes.HasPrefix(files[f], current) { - existing = append(existing, f) - } - } - if len(existing) == 0 { + overwrite, conflicts := w.existingConfig(files) + if len(overwrite) == 0 && len(conflicts) == 0 { return nil } fmt.Fprintln(w.stderr, colorize(warningCode, "You are reconfiguring the project at "+w.dir+".")) - fmt.Fprintln(w.stderr, colorize(warningCode, - fmt.Sprintf("The following %s files already exist in this directory:", w.assets.ServiceName))) - for _, f := range existing { - fmt.Fprintln(w.stderr, colorize(warningCode, " - "+f)) + if len(overwrite) > 0 { + fmt.Fprintln(w.stderr, colorize(warningCode, "These files exist, and will be overwritten:")) + for _, f := range overwrite { + fmt.Fprintln(w.stderr, colorize(warningCode, " - "+f)) + } + } + if len(conflicts) > 0 { + fmt.Fprintln(w.stderr, colorize(warningCode, + fmt.Sprintf("These %s configuration files exist, and will be kept (remove them if they conflict):", + w.assets.ServiceName))) + for _, f := range conflicts { + fmt.Fprintln(w.stderr, colorize(warningCode, " - "+f)) + } } proceed := false - if err := survey.AskOne(&survey.Confirm{Message: "Do you want to overwrite them?"}, &proceed); err != nil { + if err := survey.AskOne(&survey.Confirm{Message: "Do you want to continue?"}, &proceed); err != nil { return err } if !proceed { @@ -137,6 +139,44 @@ func (w *wizard) confirmOverwrite() error { return nil } +// existingConfig returns the generated files that exist and would be overwritten, +// and other existing configuration files, which would be kept. +func (w *wizard) existingConfig(files map[string][]byte) (overwrite, conflicts []string) { + for _, f := range slices.Sorted(maps.Keys(files)) { + current, err := os.ReadFile(filepath.Join(w.dir, filepath.FromSlash(f))) + // Files that are only appended to (Django's settings.py) are not overwritten. + if err == nil && !bytes.HasPrefix(files[f], current) { + overwrite = append(overwrite, f) + } + } + + var candidates []string + if w.assets.flex { + for _, pattern := range []string{"*.yaml", "*.yml"} { + matches, _ := filepath.Glob(filepath.Join(w.dir, ".upsun", pattern)) + candidates = append(candidates, matches...) + } + } else { + candidates = findAllFiles(w.dir, ".platform.app.yaml") + candidates = append(candidates, filepath.Join(w.dir, ".platform", "applications.yaml")) + } + for _, p := range candidates { + rel, err := filepath.Rel(w.dir, p) + if err != nil { + continue + } + rel = filepath.ToSlash(rel) + if _, generated := files[rel]; generated { + continue + } + if st, err := os.Stat(p); err == nil && !st.IsDir() { + conflicts = append(conflicts, rel) + } + } + slices.Sort(conflicts) + return overwrite, conflicts +} + // suggestSymfonyCLI offers to stop, to use the Symfony CLI instead. func (w *wizard) suggestSymfonyCLI() error { useSymfonyCLI := true