From 33651c4a852e717cdc25c0d2932037d2d0e39b2f Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Mon, 27 Jul 2026 14:29:59 +0200 Subject: [PATCH 01/16] feat: initialize Plural CLI authentication and access modules - Add `assets.go` to embed terminal cell logo for CLI - Implement core authentication types and interfaces in `bridge/types.go` - Develop CLI state reading logic in `bridge/welcome/local.go` - Add unit tests for `welcome` package functionality - Create access management infrastructure in `bridge/access/local.go` - Include diagnostics view tests in `tui/screens/diagnostics` - Establish access management and tests in `bridge/access/access.go` - Add TUI models for diagnostics and access screens --- .gitignore | 2 +- cmd/command/plural/plural.go | 2 + cmd/command/tui/tui.go | 34 ++ cmd/command/tui/tui_test.go | 30 ++ go.mod | 17 + go.sum | 40 ++ pkg/api/client.go | 8 +- pkg/bridge/access/access.go | 287 ++++++++++++++ pkg/bridge/access/access_test.go | 158 ++++++++ pkg/bridge/access/local.go | 194 +++++++++ pkg/bridge/access/local_test.go | 54 +++ pkg/bridge/access/plural_service_accounts.go | 75 ++++ .../access/plural_service_accounts_test.go | 35 ++ pkg/bridge/auth.go | 91 +++++ pkg/bridge/auth_test.go | 98 +++++ pkg/bridge/credentials.go | 130 ++++++ pkg/bridge/errors.go | 21 + pkg/bridge/errors_test.go | 18 + pkg/bridge/identity.go | 28 ++ pkg/bridge/identity_test.go | 27 ++ pkg/bridge/legacy_profile.go | 26 ++ pkg/bridge/legacy_profile_test.go | 26 ++ pkg/bridge/plural_auth.go | 46 +++ pkg/bridge/types.go | 172 ++++++++ pkg/bridge/welcome/local.go | 96 +++++ pkg/bridge/welcome/local_test.go | 75 ++++ pkg/bridge/welcome/welcome.go | 60 +++ pkg/bridge/welcome/welcome_test.go | 22 ++ pkg/common/common.go | 94 +++-- pkg/common/login_test.go | 100 +++++ pkg/config/config.go | 15 +- pkg/console/config.go | 5 +- tui/app/model.go | 93 +++++ tui/app/model_test.go | 86 ++++ tui/app/run.go | 35 ++ tui/app/view.go | 25 ++ tui/assets/assets.go | 9 + tui/assets/logo.txt | 5 + tui/components/commandbar/commandbar.go | 236 +++++++++++ tui/components/commandbar/commandbar_test.go | 97 +++++ tui/components/page/page.go | 103 +++++ tui/components/page/page_test.go | 37 ++ tui/components/spinner/spinner.go | 23 ++ tui/components/spinner/spinner_test.go | 29 ++ tui/navigation/navigation.go | 21 + tui/screens/access/golden_test.go | 74 ++++ tui/screens/access/model.go | 371 ++++++++++++++++++ tui/screens/access/model_test.go | 137 +++++++ tui/screens/access/testdata/access-120.golden | 30 ++ tui/screens/access/testdata/access-80.golden | 24 ++ tui/screens/access/view.go | 153 ++++++++ tui/screens/diagnostics/golden_test.go | 67 ++++ tui/screens/diagnostics/model.go | 78 ++++ tui/screens/diagnostics/model_test.go | 33 ++ .../testdata/diagnostics-120.golden | 30 ++ .../testdata/diagnostics-80.golden | 24 ++ tui/screens/diagnostics/view.go | 65 +++ tui/screens/welcome/model.go | 90 +++++ tui/screens/welcome/model_test.go | 323 +++++++++++++++ .../welcome/testdata/welcome-120.golden | 30 ++ .../welcome/testdata/welcome-80.golden | 24 ++ .../welcome/testdata/welcome-popup-80.golden | 24 ++ tui/screens/welcome/view.go | 273 +++++++++++++ tui/theme/testdata/ansi16.golden | 1 + tui/theme/testdata/ansi256.golden | 1 + tui/theme/testdata/no-color.golden | 1 + tui/theme/testdata/truecolor.golden | 1 + tui/theme/theme.go | 98 +++++ tui/theme/theme_test.go | 37 ++ 69 files changed, 4820 insertions(+), 54 deletions(-) create mode 100644 cmd/command/tui/tui.go create mode 100644 cmd/command/tui/tui_test.go create mode 100644 pkg/bridge/access/access.go create mode 100644 pkg/bridge/access/access_test.go create mode 100644 pkg/bridge/access/local.go create mode 100644 pkg/bridge/access/local_test.go create mode 100644 pkg/bridge/access/plural_service_accounts.go create mode 100644 pkg/bridge/access/plural_service_accounts_test.go create mode 100644 pkg/bridge/auth.go create mode 100644 pkg/bridge/auth_test.go create mode 100644 pkg/bridge/credentials.go create mode 100644 pkg/bridge/errors.go create mode 100644 pkg/bridge/errors_test.go create mode 100644 pkg/bridge/identity.go create mode 100644 pkg/bridge/identity_test.go create mode 100644 pkg/bridge/legacy_profile.go create mode 100644 pkg/bridge/legacy_profile_test.go create mode 100644 pkg/bridge/plural_auth.go create mode 100644 pkg/bridge/types.go create mode 100644 pkg/bridge/welcome/local.go create mode 100644 pkg/bridge/welcome/local_test.go create mode 100644 pkg/bridge/welcome/welcome.go create mode 100644 pkg/bridge/welcome/welcome_test.go create mode 100644 pkg/common/login_test.go create mode 100644 tui/app/model.go create mode 100644 tui/app/model_test.go create mode 100644 tui/app/run.go create mode 100644 tui/app/view.go create mode 100644 tui/assets/assets.go create mode 100644 tui/assets/logo.txt create mode 100644 tui/components/commandbar/commandbar.go create mode 100644 tui/components/commandbar/commandbar_test.go create mode 100644 tui/components/page/page.go create mode 100644 tui/components/page/page_test.go create mode 100644 tui/components/spinner/spinner.go create mode 100644 tui/components/spinner/spinner_test.go create mode 100644 tui/navigation/navigation.go create mode 100644 tui/screens/access/golden_test.go create mode 100644 tui/screens/access/model.go create mode 100644 tui/screens/access/model_test.go create mode 100644 tui/screens/access/testdata/access-120.golden create mode 100644 tui/screens/access/testdata/access-80.golden create mode 100644 tui/screens/access/view.go create mode 100644 tui/screens/diagnostics/golden_test.go create mode 100644 tui/screens/diagnostics/model.go create mode 100644 tui/screens/diagnostics/model_test.go create mode 100644 tui/screens/diagnostics/testdata/diagnostics-120.golden create mode 100644 tui/screens/diagnostics/testdata/diagnostics-80.golden create mode 100644 tui/screens/diagnostics/view.go create mode 100644 tui/screens/welcome/model.go create mode 100644 tui/screens/welcome/model_test.go create mode 100644 tui/screens/welcome/testdata/welcome-120.golden create mode 100644 tui/screens/welcome/testdata/welcome-80.golden create mode 100644 tui/screens/welcome/testdata/welcome-popup-80.golden create mode 100644 tui/screens/welcome/view.go create mode 100644 tui/theme/testdata/ansi16.golden create mode 100644 tui/theme/testdata/ansi256.golden create mode 100644 tui/theme/testdata/no-color.golden create mode 100644 tui/theme/testdata/truecolor.golden create mode 100644 tui/theme/theme.go create mode 100644 tui/theme/theme_test.go diff --git a/.gitignore b/.gitignore index cd0d11399..0b42017d6 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,4 @@ vendor/ # Agents .codex/ -AGENTS.md \ No newline at end of file +AGENTS.md diff --git a/cmd/command/plural/plural.go b/cmd/command/plural/plural.go index 15df2fa2d..e184e8a30 100644 --- a/cmd/command/plural/plural.go +++ b/cmd/command/plural/plural.go @@ -15,6 +15,7 @@ import ( "github.com/pluralsh/plural-cli/cmd/command/pr" "github.com/pluralsh/plural-cli/cmd/command/profile" "github.com/pluralsh/plural-cli/cmd/command/stacks" + tuicmd "github.com/pluralsh/plural-cli/cmd/command/tui" "github.com/pluralsh/plural-cli/cmd/command/up" "github.com/pluralsh/plural-cli/cmd/command/version" "github.com/pluralsh/plural-cli/cmd/command/workbenches" @@ -119,6 +120,7 @@ func CreateNewApp(plural *Plural) *cli.App { profile.Command(), stacks.Command(plural.Plural), pr.Command(plural.Plural), + tuicmd.Command(), cmdinit.Command(plural.Plural), up.Command(plural.Plural), version.Command(), diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go new file mode 100644 index 000000000..7aeb871ca --- /dev/null +++ b/cmd/command/tui/tui.go @@ -0,0 +1,34 @@ +package tui + +import ( + "context" + "os" + + "github.com/urfave/cli" + + "github.com/pluralsh/plural-cli/pkg/bridge" + accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + "github.com/pluralsh/plural-cli/pkg/common" + tuiapp "github.com/pluralsh/plural-cli/tui/app" +) + +// Command is the only route that starts the full-screen terminal application. +func Command() cli.Command { + return command(func(ctx context.Context) error { + welcome := welcomebridge.NewService(welcomebridge.NewLocalSource(common.Version)) + auth := bridge.NewAuthService(bridge.PluralAuthFactory{}, 0) + access := accessbridge.NewLocalManager("", auth, nil) + return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{Welcome: welcome, Access: access}) + }) +} + +func command(run func(context.Context) error) cli.Command { + return cli.Command{ + Name: "tui", + Usage: "opens the interactive terminal application", + Action: func(*cli.Context) error { + return run(context.Background()) + }, + } +} diff --git a/cmd/command/tui/tui_test.go b/cmd/command/tui/tui_test.go new file mode 100644 index 000000000..ff292a562 --- /dev/null +++ b/cmd/command/tui/tui_test.go @@ -0,0 +1,30 @@ +package tui + +import ( + "context" + "testing" + + "github.com/urfave/cli" +) + +func TestCommandIsExplicitLaunchPath(t *testing.T) { + called := false + app := cli.NewApp() + app.Commands = []cli.Command{command(func(context.Context) error { + called = true + return nil + })} + + if err := app.Run([]string{"plural"}); err != nil { + t.Fatalf("bare app: %v", err) + } + if called { + t.Fatal("bare plural launched the TUI") + } + if err := app.Run([]string{"plural", "tui"}); err != nil { + t.Fatalf("plural tui: %v", err) + } + if !called { + t.Fatal("plural tui did not launch the TUI") + } +} diff --git a/go.mod b/go.mod index a578baee8..07bc82563 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/pluralsh/plural-cli go 1.26.5 require ( + charm.land/bubbles/v2 v2.1.1 + charm.land/bubbletea/v2 v2.0.8 + charm.land/lipgloss/v2 v2.0.5 cloud.google.com/go/compute v1.64.0 cloud.google.com/go/container v1.53.0 cloud.google.com/go/resourcemanager v1.15.0 @@ -27,6 +30,9 @@ require ( github.com/aws/aws-sdk-go-v2/service/route53 v1.64.0 github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 github.com/briandowns/spinner v1.23.2 + github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/x/ansi v0.11.7 + github.com/charmbracelet/x/term v0.2.2 github.com/chartmuseum/helm-push v0.11.1 github.com/fatih/color v1.19.0 github.com/go-git/go-git/v5 v5.19.1 @@ -50,6 +56,7 @@ require ( github.com/samber/lo v1.53.0 github.com/urfave/cli v1.22.17 github.com/yuin/gopher-lua v1.1.2 + github.com/zalando/go-keyring v0.2.6 gitlab.com/gitlab-org/api/client-go v1.46.0 golang.org/x/crypto v0.53.0 golang.org/x/oauth2 v0.36.0 @@ -67,6 +74,7 @@ require ( ) require ( + al.essio.dev/pkg/shellescape v1.6.0 // indirect cel.dev/expr v0.25.2 // indirect cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect @@ -100,6 +108,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect github.com/andybalholm/brotli v1.2.2 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.27 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect @@ -118,6 +127,9 @@ require ( github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect @@ -126,6 +138,7 @@ require ( github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/docker/cli v29.6.1+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.8 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -152,6 +165,7 @@ require ( github.com/go-openapi/swag/typeutils v0.27.0 // indirect github.com/go-openapi/swag/yamlutils v0.27.0 // indirect github.com/goccy/go-json v0.10.6 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/s2a-go v0.1.9 // indirect @@ -166,9 +180,11 @@ require ( github.com/kylelemons/godebug v1.1.0 // indirect github.com/likexian/gokit v0.25.16 // indirect github.com/linkdata/deadlock v0.5.5 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect github.com/minio/simdjson-go v0.4.5 // indirect github.com/mitchellh/pointerstructure v1.2.1 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.3.0 // indirect @@ -199,6 +215,7 @@ require ( github.com/trailofbits/go-mutexasserts v0.0.0-20250514102930-c1f3d2e37561 // indirect github.com/vektah/gqlparser/v2 v2.5.36 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/collector/component v1.62.0 // indirect diff --git a/go.sum b/go.sum index 2c393e7c8..9abdfd114 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,15 @@ +al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= +al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60= +charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo= +charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= +charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= +charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= @@ -172,6 +180,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= @@ -214,6 +224,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 h1:bLZ0PolJ8J+HkJHztcXORUpHXBye github.com/aws/aws-sdk-go-v2/service/sts v1.44.0/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -231,6 +243,20 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/gettext-go v1.0.3 h1:9liNh8t+u26xl5ddmWLmsOsdNLwkdRTg5AG+JnTiM80= github.com/chai2010/gettext-go v1.0.3/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/chartmuseum/helm-push v0.11.1 h1:H/coyIQ120kuHKGNpjVcmsillr2+rxXiiWmVCuI9DQ0= github.com/chartmuseum/helm-push v0.11.1/go.mod h1:wKQbUrVv41bnzjfrmYg30sq31w/MY0G8L/kow40FDHQ= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= @@ -267,6 +293,8 @@ github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -396,6 +424,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= @@ -430,6 +460,8 @@ github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/v github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -515,6 +547,8 @@ github.com/likexian/gokit v0.25.16 h1:wwBeUIN/OdoPp6t00xTnZE8Di/+s969Bl5N2Kw6bzP github.com/likexian/gokit v0.25.16/go.mod h1:Wqd4f+iifV0qxA1N3MqePJTUsmRy/lpst9/yXriDx/4= github.com/linkdata/deadlock v0.5.5 h1:d6O+rzEqasSfamGDA8u7bjtaq7hOX8Ha4Zn36Wxrkvo= github.com/linkdata/deadlock v0.5.5/go.mod h1:tXb28stzAD3trzEEK0UJWC+rZKuobCoPktPYzebb1u0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 h1:YkjVPl/YH5XlJ+/NiwzJtPYXXKRcyjmEUhsDci6YK3c= github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -564,6 +598,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oleiade/reflections v1.1.0 h1:D+I/UsXQB4esMathlt0kkZRJZdUDmhv5zGi/HOwYTWo= @@ -739,6 +775,8 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +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= @@ -747,6 +785,8 @@ github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24= gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/pkg/api/client.go b/pkg/api/client.go index dea61a7bd..401d53b0a 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -71,6 +71,12 @@ func NewClient() Client { } func FromConfig(conf *config.Config) Client { + return FromConfigWithContext(context.Background(), conf) +} + +// FromConfigWithContext constructs a client whose requests honor caller-owned +// cancellation. FromConfig remains as the compatibility entrypoint. +func FromConfigWithContext(ctx context.Context, conf *config.Config) Client { httpClient := http.Client{ Transport: &authedTransport{ key: conf.Token, @@ -81,7 +87,7 @@ func FromConfig(conf *config.Config) Client { return &client{ pluralClient: gqlclient.NewClient(&httpClient, conf.Url(), nil), config: *conf, - ctx: context.Background(), + ctx: ctx, httpClient: &httpClient, } } diff --git a/pkg/bridge/access/access.go b/pkg/bridge/access/access.go new file mode 100644 index 000000000..afd939f40 --- /dev/null +++ b/pkg/bridge/access/access.go @@ -0,0 +1,287 @@ +package access + +import ( + "context" + "errors" + "fmt" + "net/url" + "sort" + "strings" + "sync" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type Profile = bridge.Profile +type Identity = bridge.Identity +type ConsoleProfile = bridge.ConsoleProfile +type AuthContext = bridge.AuthContext +type DeviceAuthorization = bridge.DeviceAuthorization + +// ServiceAccount is a selectable acting identity. Its credential is never +// persisted as profile metadata. +type ServiceAccount struct { + ID string `yaml:"id"` + Email string `yaml:"email"` +} + +// State is the non-secret, independently switchable access registry. +type State struct { + Profiles []Profile `yaml:"profiles"` + ConsoleProfiles []ConsoleProfile `yaml:"consoleProfiles"` + ActiveProfileID string `yaml:"activeProfile"` + ActiveConsoleID string `yaml:"activeConsole"` +} + +type Snapshot struct { + State State + Context AuthContext + ServiceAccounts []ServiceAccount +} + +type Repository interface { + Load(context.Context) (State, error) + Save(context.Context, State) error +} +type ServiceAccountSource interface { + ListServiceAccounts(context.Context, Profile, string) ([]ServiceAccount, error) +} + +// Manager is the narrow contract consumed by the Access screen. +type Manager interface { + Load(context.Context) (Snapshot, error) + BeginDeviceLogin(context.Context, string) (DeviceAuthorization, error) + CompleteDeviceLogin(context.Context, string, DeviceAuthorization, string) (Profile, error) + AddConsoleProfile(context.Context, string, string, string) (ConsoleProfile, error) + ActivateProfile(context.Context, string) error + ActivateConsole(context.Context, string) error + SearchServiceAccounts(context.Context, string) ([]ServiceAccount, error) + Impersonate(context.Context, string) error + StopImpersonating() +} + +// Service coordinates registries, secure credentials, and ephemeral sessions. +type Service struct { + repository Repository + credentials bridge.CredentialStore + auth *bridge.AuthService + serviceAccounts ServiceAccountSource + mu sync.RWMutex + acting *Identity +} + +func NewService(repository Repository, credentials bridge.CredentialStore, auth *bridge.AuthService, serviceAccounts ServiceAccountSource) *Service { + return &Service{repository: repository, credentials: credentials, auth: auth, serviceAccounts: serviceAccounts} +} + +func (s *Service) Load(ctx context.Context) (Snapshot, error) { + state, err := s.repository.Load(ctx) + if err != nil { + return Snapshot{}, err + } + result := Snapshot{State: state} + if profile, ok := findProfile(state.Profiles, state.ActiveProfileID); ok { + result.Context.Base = &profile + } + if profile, ok := findConsole(state.ConsoleProfiles, state.ActiveConsoleID); ok { + result.Context.Console = &profile + } + s.mu.RLock() + if s.acting != nil { + copy := *s.acting + result.Context.Acting = © + } + s.mu.RUnlock() + return result, result.Context.Validate() +} + +func (s *Service) BeginDeviceLogin(ctx context.Context, endpoint string) (DeviceAuthorization, error) { + if s.auth == nil { + return DeviceAuthorization{}, errors.New("device login is unavailable") + } + return s.auth.BeginDeviceLogin(ctx, normalizeAppEndpoint(endpoint)) +} + +func (s *Service) CompleteDeviceLogin(ctx context.Context, name string, authorization DeviceAuthorization, endpoint string) (Profile, error) { + if s.auth == nil { + return Profile{}, errors.New("device login is unavailable") + } + endpoint = normalizeAppEndpoint(endpoint) + credential, err := s.auth.AwaitDeviceLogin(ctx, endpoint, authorization.DeviceToken) + if err != nil { + return Profile{}, err + } + session, err := s.auth.EstablishSession(ctx, endpoint, credential, "", true, nil) + if err != nil { + return Profile{}, err + } + profile := Profile{ID: stableID("app", name, session.BaseEmail, endpoint), Name: strings.TrimSpace(name), Email: session.BaseEmail, Endpoint: endpoint} + if profile.Name == "" { + profile.Name = "default" + } + if err := s.credentials.Set(ctx, profile.ID, session.Credential); err != nil { + return Profile{}, err + } + state, err := s.repository.Load(ctx) + if err != nil { + return Profile{}, err + } + state.Profiles = upsertProfile(state.Profiles, profile) + state.ActiveProfileID = profile.ID + if err := s.repository.Save(ctx, state); err != nil { + _ = s.credentials.Delete(ctx, profile.ID) + return Profile{}, err + } + s.StopImpersonating() + return profile, nil +} + +func (s *Service) AddConsoleProfile(ctx context.Context, name, rawURL, token string) (ConsoleProfile, error) { + normalized, err := normalizeConsoleURL(rawURL) + if err != nil { + return ConsoleProfile{}, err + } + profile := ConsoleProfile{ID: stableID("console", name, normalized), Name: strings.TrimSpace(name), URL: normalized} + if profile.Name == "" { + profile.Name = "default" + } + if strings.TrimSpace(token) == "" { + return ConsoleProfile{}, errors.New("Console token is required") + } + if err := s.credentials.Set(ctx, profile.ID, token); err != nil { + return ConsoleProfile{}, err + } + state, err := s.repository.Load(ctx) + if err != nil { + return ConsoleProfile{}, err + } + state.ConsoleProfiles = upsertConsole(state.ConsoleProfiles, profile) + state.ActiveConsoleID = profile.ID + if err := s.repository.Save(ctx, state); err != nil { + _ = s.credentials.Delete(ctx, profile.ID) + return ConsoleProfile{}, err + } + return profile, nil +} + +func (s *Service) ActivateProfile(ctx context.Context, id string) error { + state, err := s.repository.Load(ctx) + if err != nil { + return err + } + if _, ok := findProfile(state.Profiles, id); !ok { + return fmt.Errorf("Plural App profile %q not found", id) + } + state.ActiveProfileID = id + s.StopImpersonating() + return s.repository.Save(ctx, state) +} + +func (s *Service) ActivateConsole(ctx context.Context, id string) error { + state, err := s.repository.Load(ctx) + if err != nil { + return err + } + if _, ok := findConsole(state.ConsoleProfiles, id); !ok { + return fmt.Errorf("Console profile %q not found", id) + } + state.ActiveConsoleID = id + return s.repository.Save(ctx, state) +} + +func (s *Service) SearchServiceAccounts(ctx context.Context, query string) ([]ServiceAccount, error) { + snapshot, err := s.Load(ctx) + if err != nil || snapshot.Context.Base == nil { + return nil, err + } + if s.serviceAccounts == nil { + return nil, nil + } + return s.serviceAccounts.ListServiceAccounts(ctx, *snapshot.Context.Base, query) +} + +func (s *Service) Impersonate(ctx context.Context, email string) error { + if s.auth == nil { + return errors.New("impersonation is unavailable") + } + snapshot, err := s.Load(ctx) + if err != nil { + return err + } + if snapshot.Context.Base == nil { + return errors.New("connect a Plural App profile before impersonating") + } + credential, err := s.credentials.Get(ctx, snapshot.Context.Base.ID) + if err != nil { + return err + } + session, err := s.auth.EstablishSession(ctx, snapshot.Context.Base.Endpoint, credential, email, false, nil) + if err != nil { + return err + } + s.mu.Lock() + s.acting = &Identity{Email: session.EffectiveEmail, ServiceAccount: true} + s.mu.Unlock() + return nil +} + +func (s *Service) StopImpersonating() { s.mu.Lock(); s.acting = nil; s.mu.Unlock() } + +func findProfile(values []Profile, id string) (Profile, bool) { + for _, value := range values { + if value.ID == id { + return value, true + } + } + return Profile{}, false +} +func findConsole(values []ConsoleProfile, id string) (ConsoleProfile, bool) { + for _, value := range values { + if value.ID == id { + return value, true + } + } + return ConsoleProfile{}, false +} +func upsertProfile(values []Profile, value Profile) []Profile { + for i := range values { + if values[i].ID == value.ID { + values[i] = value + return values + } + } + values = append(values, value) + sort.Slice(values, func(i, j int) bool { return values[i].Name < values[j].Name }) + return values +} +func upsertConsole(values []ConsoleProfile, value ConsoleProfile) []ConsoleProfile { + for i := range values { + if values[i].ID == value.ID { + values[i] = value + return values + } + } + values = append(values, value) + sort.Slice(values, func(i, j int) bool { return values[i].Name < values[j].Name }) + return values +} +func normalizeAppEndpoint(endpoint string) string { + return strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(endpoint), "https://"), "/") +} +func normalizeConsoleURL(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return "", errors.New("Console URL must be an absolute https URL") + } + parsed.Path = strings.TrimSuffix(parsed.Path, "/") + return parsed.String(), nil +} +func stableID(parts ...string) string { + joined := strings.ToLower(strings.Join(parts, "\x00")) + var hash uint64 = 1469598103934665603 + for i := range joined { + hash ^= uint64(joined[i]) + hash *= 1099511628211 + } + return fmt.Sprintf("%s-%x", parts[0], hash) +} diff --git a/pkg/bridge/access/access_test.go b/pkg/bridge/access/access_test.go new file mode 100644 index 000000000..7415fc4c4 --- /dev/null +++ b/pkg/bridge/access/access_test.go @@ -0,0 +1,158 @@ +package access + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeAuthFactory struct{ client *fakeAuthClient } + +func (f fakeAuthFactory) New(context.Context, string, string) bridge.AuthClient { return f.client } + +type fakeAuthClient struct{} + +func (*fakeAuthClient) DeviceLogin(context.Context) (bridge.DeviceAuthorization, error) { + return bridge.DeviceAuthorization{LoginURL: "https://example.com/login", DeviceToken: "device"}, nil +} +func (*fakeAuthClient) PollLoginToken(context.Context, string) (string, error) { + return "jwt", nil +} +func (*fakeAuthClient) CurrentIdentity(context.Context) (string, error) { + return "dev@example.com", nil +} +func (*fakeAuthClient) ImpersonateServiceAccount(context.Context, string) (string, string, error) { + return "session-jwt", "deploy@example.com", nil +} +func (*fakeAuthClient) GrabAccessToken(context.Context) (string, error) { + return "access-token", nil +} + +type memoryAccessRepository struct{ state State } + +func (r *memoryAccessRepository) Load(context.Context) (State, error) { return r.state, nil } +func (r *memoryAccessRepository) Save(_ context.Context, state State) error { + r.state = state + return nil +} + +type memoryCredentials struct { + values map[string]string + unavailable bool +} + +func (s *memoryCredentials) Get(_ context.Context, id string) (string, error) { + if s.unavailable { + return "", errors.New("unavailable") + } + value, ok := s.values[id] + if !ok { + return "", os.ErrNotExist + } + return value, nil +} +func (s *memoryCredentials) Set(_ context.Context, id, value string) error { + if s.unavailable { + return errors.New("unavailable") + } + if s.values == nil { + s.values = map[string]string{} + } + s.values[id] = value + return nil +} +func (s *memoryCredentials) Delete(_ context.Context, id string) error { + if s.unavailable { + return errors.New("unavailable") + } + delete(s.values, id) + return nil +} + +func TestAccessProfilesSwitchIndependentlyAndClearActingIdentity(t *testing.T) { + repository := &memoryAccessRepository{state: State{ + Profiles: []Profile{{ID: "app-a", Email: "a@example.com"}, {ID: "app-b", Email: "b@example.com"}}, ActiveProfileID: "app-a", + ConsoleProfiles: []ConsoleProfile{{ID: "console-a"}, {ID: "console-b"}}, ActiveConsoleID: "console-a", + }} + credentials := &memoryCredentials{values: map[string]string{"app-a": "base-token"}} + service := NewService(repository, credentials, bridge.NewAuthService(fakeAuthFactory{&fakeAuthClient{}}, time.Millisecond), nil) + if err := service.Impersonate(t.Context(), "deploy@example.com"); err != nil { + t.Fatalf("Impersonate() error = %v", err) + } + if err := service.ActivateConsole(t.Context(), "console-b"); err != nil { + t.Fatalf("ActivateConsole() error = %v", err) + } + snapshot, _ := service.Load(t.Context()) + if snapshot.State.ActiveProfileID != "app-a" || snapshot.State.ActiveConsoleID != "console-b" || snapshot.Context.Acting == nil { + t.Fatalf("independent Console switch lost state: %#v", snapshot) + } + if err := service.ActivateProfile(t.Context(), "app-b"); err != nil { + t.Fatalf("ActivateProfile() error = %v", err) + } + snapshot, _ = service.Load(t.Context()) + if snapshot.State.ActiveConsoleID != "console-b" || snapshot.Context.Acting != nil { + t.Fatalf("base switch did not preserve Console/clear acting: %#v", snapshot) + } + if credentials.values["app-a"] != "base-token" { + t.Fatal("impersonation overwrote the base credential") + } +} + +func TestCompleteDeviceLoginStoresBaseCredentialOutsideMetadata(t *testing.T) { + repository := &memoryAccessRepository{} + credentials := &memoryCredentials{} + service := NewService(repository, credentials, bridge.NewAuthService(fakeAuthFactory{&fakeAuthClient{}}, time.Millisecond), nil) + profile, err := service.CompleteDeviceLogin(t.Context(), "personal", DeviceAuthorization{DeviceToken: "device"}, "app.plural.sh") + if err != nil { + t.Fatalf("CompleteDeviceLogin() error = %v", err) + } + if repository.state.ActiveProfileID != profile.ID || profile.Email != "dev@example.com" { + t.Fatalf("profile = %#v state = %#v", profile, repository.state) + } + if credentials.values[profile.ID] != "access-token" { + t.Fatalf("stored credential = %q", credentials.values[profile.ID]) + } +} + +func TestFileCredentialStoreUsesOwnerOnlyPermissions(t *testing.T) { + store := bridge.FileCredentialStore{Dir: filepath.Join(t.TempDir(), "credentials")} + if err := store.Set(t.Context(), "../../unsafe", "secret"); err != nil { + t.Fatalf("Set() error = %v", err) + } + entries, err := os.ReadDir(store.Dir) + if err != nil || len(entries) != 1 { + t.Fatalf("entries = %v, error = %v", entries, err) + } + info, _ := entries[0].Info() + if info.Mode().Perm() != 0600 { + t.Fatalf("credential mode = %o", info.Mode().Perm()) + } + dirInfo, _ := os.Stat(store.Dir) + if dirInfo.Mode().Perm() != 0700 { + t.Fatalf("directory mode = %o", dirInfo.Mode().Perm()) + } +} + +func TestResilientCredentialStoreMigratesFallback(t *testing.T) { + primary := &memoryCredentials{unavailable: true} + fallback := &memoryCredentials{values: map[string]string{"profile": "secret"}} + store := bridge.ResilientCredentialStore{Primary: primary, Fallback: fallback} + if value, err := store.Get(t.Context(), "profile"); err != nil || value != "secret" { + t.Fatalf("fallback Get() = %q, %v", value, err) + } + primary.unavailable = false + if value, err := store.Get(t.Context(), "profile"); err != nil || value != "secret" { + t.Fatalf("migrating Get() = %q, %v", value, err) + } + if primary.values["profile"] != "secret" { + t.Fatal("fallback credential was not migrated") + } + if _, ok := fallback.values["profile"]; ok { + t.Fatal("fallback credential remained after migration") + } +} diff --git a/pkg/bridge/access/local.go b/pkg/bridge/access/local.go new file mode 100644 index 000000000..29cb2569a --- /dev/null +++ b/pkg/bridge/access/local.go @@ -0,0 +1,194 @@ +package access + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + + "gopkg.in/yaml.v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +const accessRegistryName = "access.yml" + +// LocalRepository persists only non-secret registry metadata and imports +// legacy config.yml/console.yml records the first time it is loaded. +type LocalRepository struct { + Dir string + Credentials bridge.CredentialStore + mu sync.Mutex +} + +func NewLocalRepository(home string, credentials bridge.CredentialStore) *LocalRepository { + if home == "" { + home, _ = os.UserHomeDir() + } + return &LocalRepository{Dir: filepath.Join(home, ".plural"), Credentials: credentials} +} + +func (r *LocalRepository) Load(ctx context.Context) (State, error) { + if err := ctx.Err(); err != nil { + return State{}, err + } + r.mu.Lock() + defer r.mu.Unlock() + contents, err := os.ReadFile(filepath.Join(r.Dir, accessRegistryName)) + if err == nil { + var state State + if err := yaml.Unmarshal(contents, &state); err != nil { + return State{}, err + } + return state, nil + } + if !errors.Is(err, os.ErrNotExist) { + return State{}, err + } + state, err := r.importLegacy(ctx) + if err != nil { + return State{}, err + } + if err := r.save(state); err != nil { + return State{}, err + } + return state, nil +} + +func (r *LocalRepository) Save(ctx context.Context, state State) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + return r.save(state) +} + +func (r *LocalRepository) save(state State) error { + contents, err := yaml.Marshal(state) + if err != nil { + return err + } + if err := os.MkdirAll(r.Dir, 0700); err != nil { + return err + } + if err := os.Chmod(r.Dir, 0700); err != nil { + return err + } + temporary, err := os.CreateTemp(r.Dir, ".access-*.tmp") + if err != nil { + return err + } + name := temporary.Name() + defer os.Remove(name) + if err := temporary.Chmod(0600); err != nil { + temporary.Close() + return err + } + if _, err := temporary.Write(contents); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return os.Rename(name, filepath.Join(r.Dir, accessRegistryName)) +} + +type legacyAppConfig struct { + Kind string `yaml:"kind"` + Metadata struct { + Name string `yaml:"name"` + } `yaml:"metadata"` + Spec struct { + Email string `yaml:"email"` + Token string `yaml:"token"` + Endpoint string `yaml:"endpoint"` + } `yaml:"spec"` +} +type legacyConsoleConfig struct { + Kind string `yaml:"kind"` + Spec struct { + URL string `yaml:"url"` + Token string `yaml:"token"` + } `yaml:"spec"` +} + +func (r *LocalRepository) importLegacy(ctx context.Context) (State, error) { + state := State{} + entries, err := os.ReadDir(r.Dir) + if errors.Is(err, os.ErrNotExist) { + return state, nil + } + if err != nil { + return state, err + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yml") || entry.Name() == accessRegistryName { + continue + } + contents, err := os.ReadFile(filepath.Join(r.Dir, entry.Name())) + if err != nil { + return state, err + } + if entry.Name() == "console.yml" { + var legacy legacyConsoleConfig + if yaml.Unmarshal(contents, &legacy) != nil || legacy.Spec.URL == "" { + continue + } + profile := ConsoleProfile{ID: stableID("console", "default", legacy.Spec.URL), Name: "default", URL: legacy.Spec.URL} + state.ConsoleProfiles = upsertConsole(state.ConsoleProfiles, profile) + state.ActiveConsoleID = profile.ID + if legacy.Spec.Token != "" && r.Credentials != nil { + if err := r.Credentials.Set(ctx, profile.ID, legacy.Spec.Token); err != nil { + return state, err + } + } + continue + } + var legacy legacyAppConfig + if yaml.Unmarshal(contents, &legacy) != nil || legacy.Kind != "Config" || legacy.Spec.Email == "" { + continue + } + name := strings.TrimSuffix(entry.Name(), ".yml") + if entry.Name() == "config.yml" { + name = "default" + } + if legacy.Metadata.Name != "" { + name = legacy.Metadata.Name + } + profile := Profile{ID: stableID("app", name, legacy.Spec.Email, legacy.Spec.Endpoint), Name: name, Email: legacy.Spec.Email, Endpoint: legacy.Spec.Endpoint} + state.Profiles = upsertProfile(state.Profiles, profile) + if entry.Name() == "config.yml" { + state.ActiveProfileID = profile.ID + } + if legacy.Spec.Token != "" && r.Credentials != nil { + if err := r.Credentials.Set(ctx, profile.ID, legacy.Spec.Token); err != nil { + return state, err + } + } + } + if state.ActiveProfileID == "" && len(state.Profiles) > 0 { + state.ActiveProfileID = state.Profiles[0].ID + } + return state, nil +} + +// NewLocalManager builds the production persistence stack. Callers can +// still inject every boundary separately in tests or alternate frontends. +func NewLocalManager(home string, auth *bridge.AuthService, serviceAccounts ServiceAccountSource) *Service { + if home == "" { + home, _ = os.UserHomeDir() + } + credentials := bridge.ResilientCredentialStore{ + Primary: bridge.KeyringCredentialStore{}, + Fallback: bridge.FileCredentialStore{Dir: filepath.Join(home, ".plural", "credentials")}, + } + if serviceAccounts == nil { + serviceAccounts = PluralServiceAccountSource{Credentials: credentials} + } + repository := NewLocalRepository(home, credentials) + return NewService(repository, credentials, auth, serviceAccounts) +} diff --git a/pkg/bridge/access/local_test.go b/pkg/bridge/access/local_test.go new file mode 100644 index 000000000..145349433 --- /dev/null +++ b/pkg/bridge/access/local_test.go @@ -0,0 +1,54 @@ +package access + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLocalAccessRepositoryImportsLegacyProfilesOnce(t *testing.T) { + home := t.TempDir() + dir := filepath.Join(home, ".plural") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + legacy := "apiVersion: platform.plural.sh/v1alpha1\nkind: Config\nmetadata:\n name: personal\nspec:\n email: dev@example.com\n endpoint: app.plural.sh\n token: legacy-secret\n" + if err := os.WriteFile(filepath.Join(dir, "config.yml"), []byte(legacy), 0600); err != nil { + t.Fatal(err) + } + credentials := &memoryCredentials{} + repository := NewLocalRepository(home, credentials) + state, err := repository.Load(t.Context()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if len(state.Profiles) != 1 || state.ActiveProfileID == "" { + t.Fatalf("state = %#v", state) + } + if credentials.values[state.ActiveProfileID] != "legacy-secret" { + t.Fatal("legacy secret was not moved to credential storage") + } + contents, err := os.ReadFile(filepath.Join(dir, accessRegistryName)) + if err != nil { + t.Fatal(err) + } + if string(contents) == "" || containsSecret(string(contents), "legacy-secret") { + t.Fatalf("registry contains secret:\n%s", contents) + } + if err := os.Remove(filepath.Join(dir, "config.yml")); err != nil { + t.Fatal(err) + } + state, err = repository.Load(t.Context()) + if err != nil || len(state.Profiles) != 1 { + t.Fatalf("second Load() = %#v, %v", state, err) + } +} + +func containsSecret(value, secret string) bool { + for i := 0; i+len(secret) <= len(value); i++ { + if value[i:i+len(secret)] == secret { + return true + } + } + return false +} diff --git a/pkg/bridge/access/plural_service_accounts.go b/pkg/bridge/access/plural_service_accounts.go new file mode 100644 index 000000000..03637e5e5 --- /dev/null +++ b/pkg/bridge/access/plural_service_accounts.go @@ -0,0 +1,75 @@ +package access + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/config" +) + +// PluralServiceAccountSource queries the Plural App API without exposing its +// GraphQL transport to the Access screen. +type PluralServiceAccountSource struct { + Credentials bridge.CredentialStore + Client *http.Client +} + +func (s PluralServiceAccountSource) ListServiceAccounts(ctx context.Context, profile Profile, query string) ([]ServiceAccount, error) { + credential, err := s.Credentials.Get(ctx, profile.ID) + if err != nil { + return nil, err + } + payload, err := json.Marshal(map[string]any{ + "query": `query TUIServiceAccounts($q: String, $serviceAccount: Boolean!) { users(q: $q, serviceAccount: $serviceAccount, first: 100) { edges { node { id email } } } }`, + "variables": map[string]any{"q": query, "serviceAccount": true}, + }) + if err != nil { + return nil, err + } + conf := config.Config{Endpoint: profile.Endpoint} + request, err := http.NewRequestWithContext(ctx, http.MethodPost, conf.Url(), bytes.NewReader(payload)) + if err != nil { + return nil, err + } + request.Header.Set("Authorization", "Bearer "+credential) + request.Header.Set("Content-Type", "application/json") + client := s.Client + if client == nil { + client = http.DefaultClient + } + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("service-account query returned %s", response.Status) + } + var result struct { + Data struct { + Users struct { + Edges []struct { + Node struct{ ID, Email string } `json:"node"` + } `json:"edges"` + } `json:"users"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + return nil, err + } + if len(result.Errors) > 0 { + return nil, fmt.Errorf("service-account query failed: %s", result.Errors[0].Message) + } + accounts := make([]ServiceAccount, 0, len(result.Data.Users.Edges)) + for _, edge := range result.Data.Users.Edges { + accounts = append(accounts, ServiceAccount{ID: edge.Node.ID, Email: edge.Node.Email}) + } + return accounts, nil +} diff --git a/pkg/bridge/access/plural_service_accounts_test.go b/pkg/bridge/access/plural_service_accounts_test.go new file mode 100644 index 000000000..2f35ff3d8 --- /dev/null +++ b/pkg/bridge/access/plural_service_accounts_test.go @@ -0,0 +1,35 @@ +package access + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +func TestPluralServiceAccountSourceUsesActiveBaseCredential(t *testing.T) { + credentials := &memoryCredentials{values: map[string]string{"app": "base-secret"}} + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + if got := request.Header.Get("Authorization"); got != "Bearer base-secret" { + t.Fatalf("Authorization = %q", got) + } + body, _ := io.ReadAll(request.Body) + if !strings.Contains(string(body), `"serviceAccount":true`) || !strings.Contains(string(body), `"q":"deploy"`) { + t.Fatalf("request body = %s", body) + } + return &http.Response{StatusCode: 200, Status: "200 OK", Body: io.NopCloser(strings.NewReader(`{"data":{"users":{"edges":[{"node":{"id":"sa-1","email":"deploy@example.com"}}]}}}`)), Header: make(http.Header)}, nil + })} + source := PluralServiceAccountSource{Credentials: credentials, Client: client} + accounts, err := source.ListServiceAccounts(context.Background(), Profile{ID: "app", Endpoint: "app.plural.sh"}, "deploy") + if err != nil { + t.Fatalf("ListServiceAccounts() error = %v", err) + } + if len(accounts) != 1 || accounts[0].Email != "deploy@example.com" { + t.Fatalf("accounts = %#v", accounts) + } +} diff --git a/pkg/bridge/auth.go b/pkg/bridge/auth.go new file mode 100644 index 000000000..14077a5fb --- /dev/null +++ b/pkg/bridge/auth.go @@ -0,0 +1,91 @@ +package bridge + +import ( + "context" + "errors" + "time" +) + +func NewAuthService(clients AuthClientFactory, pollInterval time.Duration) *AuthService { + if pollInterval <= 0 { + pollInterval = 2 * time.Second + } + return &AuthService{clients: clients, pollInterval: pollInterval} +} + +func (s *AuthService) BeginDeviceLogin(ctx context.Context, endpoint string) (DeviceAuthorization, error) { + authorization, err := s.clients.New(ctx, endpoint, "").DeviceLogin(ctx) + if err != nil { + return DeviceAuthorization{}, s.operationError(OperationDeviceLogin, err) + } + return authorization, nil +} + +func (s *AuthService) AwaitDeviceLogin(ctx context.Context, endpoint, deviceToken string) (string, error) { + client := s.clients.New(ctx, endpoint, "") + for { + credential, err := client.PollLoginToken(ctx, deviceToken) + if err == nil { + return credential, nil + } + + timer := time.NewTimer(s.pollInterval) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return "", &Error{Code: ErrorCancelled, Operation: OperationPollLoginToken, Err: ctx.Err()} + case <-timer.C: + } + } +} + +func (s *AuthService) EstablishSession( + ctx context.Context, + endpoint, credential, serviceAccount string, + persist bool, + notify func(AuthEvent), +) (AuthSession, error) { + client := s.clients.New(ctx, endpoint, credential) + baseEmail, err := client.CurrentIdentity(ctx) + if err != nil { + return AuthSession{}, s.operationError(OperationCurrentIdentity, err) + } + if notify != nil { + notify(AuthEvent{Kind: AuthEventIdentified, Email: baseEmail, Credential: credential}) + } + + result := AuthSession{BaseEmail: baseEmail, EffectiveEmail: baseEmail, Credential: credential} + if serviceAccount != "" { + impersonatedCredential, effectiveEmail, err := client.ImpersonateServiceAccount(ctx, serviceAccount) + if err != nil { + return AuthSession{}, s.operationError(OperationImpersonateServiceAccount, err) + } + result.EffectiveEmail = effectiveEmail + result.Credential = impersonatedCredential + result.Impersonated = true + if notify != nil { + notify(AuthEvent{Kind: AuthEventImpersonated, Email: effectiveEmail, Credential: impersonatedCredential}) + } + client = s.clients.New(ctx, endpoint, impersonatedCredential) + if !persist { + return result, nil + } + } + + accessToken, err := client.GrabAccessToken(ctx) + if err != nil { + return AuthSession{}, s.operationError(OperationGrabAccessToken, err) + } + result.Credential = accessToken + return result, nil +} + +func (s *AuthService) operationError(operation Operation, err error) error { + code := ErrorUnavailable + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + code = ErrorCancelled + } + return &Error{Code: code, Operation: operation, Err: err} +} diff --git a/pkg/bridge/auth_test.go b/pkg/bridge/auth_test.go new file mode 100644 index 000000000..5f2f58626 --- /dev/null +++ b/pkg/bridge/auth_test.go @@ -0,0 +1,98 @@ +package bridge + +import ( + "context" + "errors" + "testing" + "time" +) + +type fakeAuthFactory struct{ client *fakeAuthClient } + +func (f fakeAuthFactory) New(context.Context, string, string) AuthClient { return f.client } + +type fakeAuthClient struct { + polls int + accessCalls int +} + +func (*fakeAuthClient) DeviceLogin(context.Context) (DeviceAuthorization, error) { + return DeviceAuthorization{LoginURL: "https://example.com/login", DeviceToken: "device"}, nil +} + +func (f *fakeAuthClient) PollLoginToken(context.Context, string) (string, error) { + f.polls++ + if f.polls < 2 { + return "", errors.New("pending") + } + return "jwt", nil +} + +func (*fakeAuthClient) CurrentIdentity(context.Context) (string, error) { + return "dev@example.com", nil +} + +func (*fakeAuthClient) ImpersonateServiceAccount(context.Context, string) (string, string, error) { + return "session-jwt", "deploy@example.com", nil +} + +func (f *fakeAuthClient) GrabAccessToken(context.Context) (string, error) { + f.accessCalls++ + return "access-token", nil +} + +func TestAwaitDeviceLoginRetriesAndCanComplete(t *testing.T) { + client := &fakeAuthClient{} + service := NewAuthService(fakeAuthFactory{client}, time.Millisecond) + credential, err := service.AwaitDeviceLogin(t.Context(), "", "device") + if err != nil { + t.Fatalf("AwaitDeviceLogin() error = %v", err) + } + if credential != "jwt" || client.polls != 2 { + t.Fatalf("credential = %q, polls = %d", credential, client.polls) + } +} + +func TestAwaitDeviceLoginHonorsCancellation(t *testing.T) { + client := &fakeAuthClient{polls: -100} + service := NewAuthService(fakeAuthFactory{client}, time.Hour) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err := service.AwaitDeviceLogin(ctx, "", "device") + if !IsCode(err, ErrorCancelled) { + t.Fatalf("AwaitDeviceLogin() error = %v", err) + } +} + +func TestSessionOnlyImpersonationDoesNotExchangeOrPersistBaseIdentity(t *testing.T) { + client := &fakeAuthClient{} + service := NewAuthService(fakeAuthFactory{client}, time.Millisecond) + var events []AuthEvent + session, err := service.EstablishSession(t.Context(), "", "base-jwt", "deploy@example.com", false, func(event AuthEvent) { + events = append(events, event) + }) + if err != nil { + t.Fatalf("EstablishSession() error = %v", err) + } + if session.BaseEmail != "dev@example.com" || session.EffectiveEmail != "deploy@example.com" { + t.Fatalf("session = %#v", session) + } + if session.Credential != "session-jwt" || client.accessCalls != 0 { + t.Fatalf("credential = %q, access calls = %d", session.Credential, client.accessCalls) + } + if len(events) != 2 || events[0].Kind != AuthEventIdentified || events[1].Kind != AuthEventImpersonated { + t.Fatalf("events = %#v", events) + } +} + +func TestPersistedImpersonationExchangesAccessToken(t *testing.T) { + client := &fakeAuthClient{} + service := NewAuthService(fakeAuthFactory{client}, time.Millisecond) + session, err := service.EstablishSession(t.Context(), "", "base-jwt", "deploy@example.com", true, nil) + if err != nil { + t.Fatalf("EstablishSession() error = %v", err) + } + if session.Credential != "access-token" || client.accessCalls != 1 { + t.Fatalf("session = %#v, access calls = %d", session, client.accessCalls) + } +} diff --git a/pkg/bridge/credentials.go b/pkg/bridge/credentials.go new file mode 100644 index 000000000..1c1b8dccc --- /dev/null +++ b/pkg/bridge/credentials.go @@ -0,0 +1,130 @@ +package bridge + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/zalando/go-keyring" +) + +const credentialService = "plural-cli" + +// KeyringCredentialStore stores secrets in the operating-system credential +// store. It contains no filesystem policy and is independently replaceable. +type KeyringCredentialStore struct{ Service string } + +func (s KeyringCredentialStore) service() string { + if s.Service != "" { + return s.Service + } + return credentialService +} +func (s KeyringCredentialStore) Get(_ context.Context, id string) (string, error) { + return keyring.Get(s.service(), id) +} +func (s KeyringCredentialStore) Set(_ context.Context, id, secret string) error { + return keyring.Set(s.service(), id, secret) +} +func (s KeyringCredentialStore) Delete(_ context.Context, id string) error { + return keyring.Delete(s.service(), id) +} + +// FileCredentialStore is the owner-only fallback for hosts without a usable +// keyring. IDs are hashed so untrusted profile names cannot escape its root. +type FileCredentialStore struct{ Dir string } + +func (s FileCredentialStore) path(id string) string { + hash := sha256.Sum256([]byte(id)) + return filepath.Join(s.Dir, fmt.Sprintf("%x", hash[:])+".credential") +} +func (s FileCredentialStore) Get(ctx context.Context, id string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + value, err := os.ReadFile(s.path(id)) + if err != nil { + return "", err + } + return strings.TrimSpace(string(value)), nil +} +func (s FileCredentialStore) Set(ctx context.Context, id, secret string) error { + if err := ctx.Err(); err != nil { + return err + } + if err := os.MkdirAll(s.Dir, 0700); err != nil { + return err + } + if err := os.Chmod(s.Dir, 0700); err != nil { + return err + } + target := s.path(id) + if err := os.WriteFile(target, []byte(secret+"\n"), 0600); err != nil { + return err + } + return os.Chmod(target, 0600) +} +func (s FileCredentialStore) Delete(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } + err := os.Remove(s.path(id)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +// ResilientCredentialStore prefers a keyring, falls back to owner-only files, +// and lazily migrates fallback secrets when the keyring becomes available. +type ResilientCredentialStore struct{ Primary, Fallback CredentialStore } + +func (s ResilientCredentialStore) Get(ctx context.Context, id string) (string, error) { + if s.Primary != nil { + if value, err := s.Primary.Get(ctx, id); err == nil { + return value, nil + } + } + if s.Fallback == nil { + return "", os.ErrNotExist + } + value, err := s.Fallback.Get(ctx, id) + if err != nil { + return "", err + } + if s.Primary != nil && s.Primary.Set(ctx, id, value) == nil { + _ = s.Fallback.Delete(ctx, id) + } + return value, nil +} +func (s ResilientCredentialStore) Set(ctx context.Context, id, secret string) error { + if s.Primary != nil && s.Primary.Set(ctx, id, secret) == nil { + if s.Fallback != nil { + _ = s.Fallback.Delete(ctx, id) + } + return nil + } + if s.Fallback == nil { + return errors.New("no credential store is available") + } + return s.Fallback.Set(ctx, id, secret) +} +func (s ResilientCredentialStore) Delete(ctx context.Context, id string) error { + var primaryErr error + if s.Primary != nil { + primaryErr = s.Primary.Delete(ctx, id) + } + if s.Fallback != nil { + if err := s.Fallback.Delete(ctx, id); err != nil { + return err + } + } + if errors.Is(primaryErr, keyring.ErrNotFound) { + return nil + } + return primaryErr +} diff --git a/pkg/bridge/errors.go b/pkg/bridge/errors.go new file mode 100644 index 000000000..7f7898149 --- /dev/null +++ b/pkg/bridge/errors.go @@ -0,0 +1,21 @@ +package bridge + +import ( + "errors" + "fmt" +) + +func (e *Error) Error() string { + if e.Operation == "" { + return fmt.Sprintf("%s: %v", e.Code, e.Err) + } + return fmt.Sprintf("%s: %s: %v", e.Operation, e.Code, e.Err) +} + +func (e *Error) Unwrap() error { return e.Err } + +// IsCode reports whether err contains a bridge error with code. +func IsCode(err error, code ErrorCode) bool { + var bridgeErr *Error + return errors.As(err, &bridgeErr) && bridgeErr.Code == code +} diff --git a/pkg/bridge/errors_test.go b/pkg/bridge/errors_test.go new file mode 100644 index 000000000..4dbb9e0ea --- /dev/null +++ b/pkg/bridge/errors_test.go @@ -0,0 +1,18 @@ +package bridge + +import ( + "errors" + "testing" +) + +func TestBridgeErrorSupportsTypedRecoveryAndUnwrap(t *testing.T) { + cause := errors.New("connection refused") + err := &Error{Code: ErrorUnavailable, Operation: "load profile", Err: cause} + + if !IsCode(err, ErrorUnavailable) { + t.Fatal("IsCode() = false") + } + if !errors.Is(err, cause) { + t.Fatal("bridge error does not unwrap its cause") + } +} diff --git a/pkg/bridge/identity.go b/pkg/bridge/identity.go new file mode 100644 index 000000000..e0d337a3e --- /dev/null +++ b/pkg/bridge/identity.go @@ -0,0 +1,28 @@ +package bridge + +import ( + "fmt" +) + +func (a AuthContext) Validate() error { + if a.Acting != nil && a.Base == nil { + return fmt.Errorf("acting identity requires a base profile") + } + if a.Base != nil && a.Base.ID == "" { + return fmt.Errorf("base profile ID is required") + } + if a.Console != nil && a.Console.ID == "" { + return fmt.Errorf("console profile ID is required") + } + return nil +} + +func (a AuthContext) EffectiveEmail() string { + if a.Acting != nil { + return a.Acting.Email + } + if a.Base != nil { + return a.Base.Email + } + return "" +} diff --git a/pkg/bridge/identity_test.go b/pkg/bridge/identity_test.go new file mode 100644 index 000000000..f0084a577 --- /dev/null +++ b/pkg/bridge/identity_test.go @@ -0,0 +1,27 @@ +package bridge + +import "testing" + +func TestAuthContextKeepsBaseAndActingIdentitySeparate(t *testing.T) { + ctx := AuthContext{ + Base: &Profile{ID: "personal", Email: "dev@example.com"}, + Acting: &Identity{Email: "deploy@example.com", ServiceAccount: true}, + } + + if err := ctx.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + if got := ctx.EffectiveEmail(); got != "deploy@example.com" { + t.Fatalf("EffectiveEmail() = %q", got) + } + if got := ctx.Base.Email; got != "dev@example.com" { + t.Fatalf("base profile was changed: %q", got) + } +} + +func TestAuthContextRejectsActingIdentityWithoutBase(t *testing.T) { + ctx := AuthContext{Acting: &Identity{Email: "deploy@example.com"}} + if err := ctx.Validate(); err == nil { + t.Fatal("Validate() expected an error") + } +} diff --git a/pkg/bridge/legacy_profile.go b/pkg/bridge/legacy_profile.go new file mode 100644 index 000000000..4d144a16d --- /dev/null +++ b/pkg/bridge/legacy_profile.go @@ -0,0 +1,26 @@ +package bridge + +import ( + "context" + + "github.com/pluralsh/plural-cli/pkg/config" +) + +// LegacyProfileStore preserves config.yml persistence while keeping it out of +// presentation handlers. +type LegacyProfileStore struct{} + +func (LegacyProfileStore) Persist(ctx context.Context, conf *config.Config) error { + if err := ctx.Err(); err != nil { + return err + } + return conf.Flush() +} + +func (LegacyProfileStore) Activate(ctx context.Context, conf *config.Config) error { + if err := ctx.Err(); err != nil { + return err + } + config.SetConfig(conf) + return nil +} diff --git a/pkg/bridge/legacy_profile_test.go b/pkg/bridge/legacy_profile_test.go new file mode 100644 index 000000000..a0a8f1fd9 --- /dev/null +++ b/pkg/bridge/legacy_profile_test.go @@ -0,0 +1,26 @@ +package bridge + +import ( + "os" + "path/filepath" + "testing" + + "github.com/pluralsh/plural-cli/pkg/config" +) + +func TestLegacyProfileStorePersistsCredentialsWithOwnerOnlyPermissions(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + conf := &config.Config{Email: "dev@example.com", Token: "secret"} + if err := (LegacyProfileStore{}).Persist(t.Context(), conf); err != nil { + t.Fatalf("Persist() error = %v", err) + } + info, err := os.Stat(filepath.Join(home, ".plural", config.ConfigName)) + if err != nil { + t.Fatalf("stat config: %v", err) + } + if got := info.Mode().Perm(); got != 0600 { + t.Fatalf("config permissions = %04o", got) + } +} diff --git a/pkg/bridge/plural_auth.go b/pkg/bridge/plural_auth.go new file mode 100644 index 000000000..d92f192e8 --- /dev/null +++ b/pkg/bridge/plural_auth.go @@ -0,0 +1,46 @@ +package bridge + +import ( + "context" + + "github.com/pluralsh/plural-cli/pkg/api" + "github.com/pluralsh/plural-cli/pkg/config" +) + +// PluralAuthFactory adapts the legacy GraphQL client to AuthClientFactory. +type PluralAuthFactory struct{} + +func (PluralAuthFactory) New(ctx context.Context, endpoint, credential string) AuthClient { + conf := &config.Config{Endpoint: endpoint, Token: credential} + return pluralAuthClient{client: api.FromConfigWithContext(ctx, conf)} +} + +type pluralAuthClient struct{ client api.Client } + +func (c pluralAuthClient) DeviceLogin(context.Context) (DeviceAuthorization, error) { + device, err := c.client.DeviceLogin() + if err != nil { + return DeviceAuthorization{}, err + } + return DeviceAuthorization{LoginURL: device.LoginUrl, DeviceToken: device.DeviceToken}, nil +} + +func (c pluralAuthClient) PollLoginToken(_ context.Context, deviceToken string) (string, error) { + return c.client.PollLoginToken(deviceToken) +} + +func (c pluralAuthClient) CurrentIdentity(context.Context) (string, error) { + me, err := c.client.Me() + if err != nil { + return "", err + } + return me.Email, nil +} + +func (c pluralAuthClient) ImpersonateServiceAccount(_ context.Context, email string) (string, string, error) { + return c.client.ImpersonateServiceAccount(email) +} + +func (c pluralAuthClient) GrabAccessToken(context.Context) (string, error) { + return c.client.GrabAccessToken() +} diff --git a/pkg/bridge/types.go b/pkg/bridge/types.go new file mode 100644 index 000000000..a033b6f7b --- /dev/null +++ b/pkg/bridge/types.go @@ -0,0 +1,172 @@ +// Package bridge defines the frontend-independent boundary between Plural CLI +// presentation layers and authentication infrastructure. +// +// Authentication starts with an AuthClientFactory, which supplies transport +// implementations to AuthService. AuthService produces an AuthSession while +// reporting optional AuthEvents. Persisted Profile metadata is deliberately +// separated from secrets, which are resolved through CredentialStore. +// AuthContext then combines the selected base profile, an optional ephemeral +// acting identity, and an independently selected Console profile. +// +// View-oriented aggregation lives in the bridge/access and bridge/welcome +// subpackages. This package contains the shared vocabulary and infrastructure +// contracts used by those services and by legacy CLI commands. +package bridge + +import ( + "context" + "time" +) + +// Operation identifies an authentication operation that can be attached to an +// Error for diagnostics and recovery decisions. +type Operation string + +const ( + // OperationDeviceLogin starts device authorization. + OperationDeviceLogin Operation = "DeviceLogin" + // OperationPollLoginToken waits for device authorization to complete. + OperationPollLoginToken Operation = "PollLoginToken" + // OperationCurrentIdentity resolves the identity associated with a credential. + OperationCurrentIdentity Operation = "Me" + // OperationImpersonateServiceAccount exchanges a base credential for an acting identity. + OperationImpersonateServiceAccount Operation = "ImpersonateServiceAccount" + // OperationGrabAccessToken exchanges an authenticated session for a durable access token. + OperationGrabAccessToken Operation = "GrabAccessToken" +) + +// ErrorCode is a stable category that presentation layers can map to recovery +// actions without matching backend error strings. +type ErrorCode string + +const ( + // ErrorUnauthenticated indicates that valid authentication is required. + ErrorUnauthenticated ErrorCode = "unauthenticated" + // ErrorUnauthorized indicates that the identity lacks permission. + ErrorUnauthorized ErrorCode = "unauthorized" + // ErrorInvalid indicates invalid caller input or persisted state. + ErrorInvalid ErrorCode = "invalid" + // ErrorUnavailable indicates a dependency or operation is unavailable. + ErrorUnavailable ErrorCode = "unavailable" + // ErrorCancelled indicates cancellation or deadline expiry. + ErrorCancelled ErrorCode = "cancelled" +) + +// Error adds operation and recovery semantics while preserving its cause. +type Error struct { + Code ErrorCode + Operation Operation + Err error +} + +// DeviceAuthorization contains the user-facing URL and opaque token for a +// device-login flow. +type DeviceAuthorization struct { + LoginURL string + DeviceToken string +} + +// AuthClient is the context-aware authentication transport required by the +// bridge. Implementations adapt concrete APIs without leaking them to callers. +type AuthClient interface { + DeviceLogin(ctx context.Context) (DeviceAuthorization, error) + PollLoginToken(ctx context.Context, deviceToken string) (string, error) + CurrentIdentity(ctx context.Context) (string, error) + ImpersonateServiceAccount(ctx context.Context, email string) (credential, effectiveEmail string, err error) + GrabAccessToken(ctx context.Context) (string, error) +} + +// AuthClientFactory creates an authentication client for an endpoint and an +// optional existing credential. +type AuthClientFactory interface { + New(ctx context.Context, endpoint, credential string) AuthClient +} + +// AuthService coordinates authentication clients, device-login polling, token +// exchange, and optional service-account impersonation. +type AuthService struct { + clients AuthClientFactory + pollInterval time.Duration +} + +// AuthEventKind identifies a meaningful transition during session creation. +type AuthEventKind string + +const ( + // AuthEventIdentified reports the base identity resolved from a credential. + AuthEventIdentified AuthEventKind = "identified" + // AuthEventImpersonated reports a successful service-account exchange. + AuthEventImpersonated AuthEventKind = "impersonated" +) + +// AuthEvent reports an identity or credential transition to interested callers. +type AuthEvent struct { + Kind AuthEventKind + Email string + Credential string +} + +// AuthSession is the result of authentication and optional impersonation. +type AuthSession struct { + BaseEmail string + EffectiveEmail string + Credential string + Impersonated bool +} + +// Profile identifies a Plural App login. Its credential is stored separately +// and resolved through CredentialStore by profile ID. +type Profile struct { + ID string + Name string + Email string + Endpoint string +} + +// Identity is the effective actor for the current session. +type Identity struct { + Email string + ServiceAccount bool +} + +// ConsoleProfile identifies a Console connection selected independently from +// the Plural App profile. +type ConsoleProfile struct { + ID string + Name string + URL string + Actor string +} + +// AuthContext keeps persisted base identity separate from the effective +// in-memory actor and the independently selected Console connection. +type AuthContext struct { + Base *Profile + Acting *Identity + Console *ConsoleProfile +} + +// ProfileRepository stores non-secret identity metadata. +type ProfileRepository interface { + List(ctx context.Context) ([]Profile, error) + Get(ctx context.Context, id string) (Profile, error) + Save(ctx context.Context, profile Profile) error +} + +// CredentialStore stores secret material independently from profile metadata. +type CredentialStore interface { + Get(ctx context.Context, profileID string) (string, error) + Set(ctx context.Context, profileID, credential string) error + Delete(ctx context.Context, profileID string) error +} + +// SessionExchanger creates an ephemeral acting identity without replacing the +// persisted credential of its base profile. +type SessionExchanger interface { + Impersonate(ctx context.Context, base Profile, serviceAccount string) (Identity, string, error) +} + +// AuthContextLoader resolves active identity state for a caller-owned context. +type AuthContextLoader interface { + Load(ctx context.Context) (AuthContext, error) +} diff --git a/pkg/bridge/welcome/local.go b/pkg/bridge/welcome/local.go new file mode 100644 index 000000000..ff8bdb426 --- /dev/null +++ b/pkg/bridge/welcome/local.go @@ -0,0 +1,96 @@ +package welcome + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/samber/lo" + "k8s.io/client-go/tools/clientcmd" + + "github.com/pluralsh/plural-cli/pkg/config" + "github.com/pluralsh/plural-cli/pkg/console" + "github.com/pluralsh/plural-cli/pkg/manifest" + "github.com/pluralsh/plural-cli/pkg/utils" +) + +// LocalSource reads existing CLI state without returning credentials or +// contacting remote APIs. +type LocalSource struct{ version string } + +func NewLocalSource(version string) LocalSource { + return LocalSource{version: version} +} + +func (s LocalSource) Read(ctx context.Context) (Snapshot, error) { + if err := ctx.Err(); err != nil { + return Snapshot{}, err + } + + snapshot := Snapshot{Version: s.version} + snapshot.App = s.readAppProfile() + snapshot.Console = s.readConsole() + s.readWorkspace(&snapshot) + s.readKubeContext(&snapshot) + return snapshot, nil +} + +func (s LocalSource) readAppProfile() AppProfile { + if !config.Exists() { + return AppProfile{} + } + + conf := config.Read() + profile := AppProfile{ + Configured: conf.Email != "" || conf.Token != "", + Name: lo.CoalesceOrEmpty(conf.ProfileName(), "active"), + Email: conf.Email, + Endpoint: conf.BaseUrl(), + } + if profiles, err := config.Profiles(); err == nil { + profile.SavedProfiles = len(profiles) + } + return profile +} + +func (s LocalSource) readConsole() ConsoleConnection { + conf := console.ReadConfig() + return ConsoleConnection{ + Configured: conf.Url != "" || conf.Token != "", + URL: conf.Url, + } +} + +func (s LocalSource) readWorkspace(snapshot *Snapshot) { + root, found := utils.ProjectRoot() + if !found { + return + } + + project, err := manifest.ReadProject(filepath.Join(root, "workspace.yaml")) + if err != nil { + snapshot.Diagnostics = append(snapshot.Diagnostics, fmt.Sprintf("workspace: %v", err)) + return + } + snapshot.Workspace = Workspace{ + Configured: true, + Path: root, + Name: project.Cluster, + Project: project.Project, + Provider: project.Provider, + Region: project.Region, + } + if project.Owner != nil { + snapshot.Workspace.Owner = project.Owner.Email + } +} + +func (s LocalSource) readKubeContext(snapshot *Snapshot) { + rules := clientcmd.NewDefaultClientConfigLoadingRules() + raw, err := rules.Load() + if err != nil { + snapshot.Diagnostics = append(snapshot.Diagnostics, fmt.Sprintf("kubeconfig: %v", err)) + return + } + snapshot.KubeContext = raw.CurrentContext +} diff --git a/pkg/bridge/welcome/local_test.go b/pkg/bridge/welcome/local_test.go new file mode 100644 index 000000000..3e14a997c --- /dev/null +++ b/pkg/bridge/welcome/local_test.go @@ -0,0 +1,75 @@ +package welcome + +import ( + "os" + "path/filepath" + "testing" + + "github.com/pluralsh/plural-cli/pkg/config" + "github.com/pluralsh/plural-cli/pkg/console" + "github.com/pluralsh/plural-cli/pkg/manifest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +func TestLocalContextSourceReadsStateWithoutCredentials(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + config.SetConfig(nil) + t.Cleanup(func() { config.SetConfig(nil) }) + + appConfig := &config.Config{Email: "dev@example.com", Token: "app-secret"} + if err := appConfig.Flush(); err != nil { + t.Fatalf("save app config: %v", err) + } + consoleConfig := &console.Config{Url: "https://console.example.com", Token: "console-secret"} + if err := consoleConfig.Save(); err != nil { + t.Fatalf("save console config: %v", err) + } + + root := filepath.Join(t.TempDir(), "workspace") + nested := filepath.Join(root, "services", "api") + if err := os.MkdirAll(nested, 0755); err != nil { + t.Fatalf("mkdir workspace: %v", err) + } + project := &manifest.ProjectManifest{ + Cluster: "platform-prod", Project: "acme", Provider: "aws", Region: "eu-west-1", + Owner: &manifest.Owner{Email: "deploy@example.com"}, + } + if err := project.Write(filepath.Join(root, "workspace.yaml")); err != nil { + t.Fatalf("write workspace: %v", err) + } + oldWorkingDirectory, err := os.Getwd() + if err != nil { + t.Fatalf("get working directory: %v", err) + } + if err := os.Chdir(nested); err != nil { + t.Fatalf("change working directory: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(oldWorkingDirectory) }) + + kubeconfig := filepath.Join(home, "kubeconfig") + kube := clientcmdapi.NewConfig() + kube.CurrentContext = "plural-platform-prod" + if err := clientcmd.WriteToFile(*kube, kubeconfig); err != nil { + t.Fatalf("write kubeconfig: %v", err) + } + t.Setenv("KUBECONFIG", kubeconfig) + + snapshot, err := NewLocalSource("v1").Read(t.Context()) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + if snapshot.Version != "v1" || snapshot.App.Email != "dev@example.com" || snapshot.App.Endpoint != "https://app.plural.sh" { + t.Fatalf("app snapshot = %#v", snapshot.App) + } + if snapshot.Console.URL != "https://console.example.com" { + t.Fatalf("console snapshot = %#v", snapshot.Console) + } + if snapshot.Workspace.Name != "platform-prod" || snapshot.Workspace.Owner != "deploy@example.com" { + t.Fatalf("workspace snapshot = %#v", snapshot.Workspace) + } + if snapshot.KubeContext != "plural-platform-prod" { + t.Fatalf("kube context = %q", snapshot.KubeContext) + } +} diff --git a/pkg/bridge/welcome/welcome.go b/pkg/bridge/welcome/welcome.go new file mode 100644 index 000000000..769253f66 --- /dev/null +++ b/pkg/bridge/welcome/welcome.go @@ -0,0 +1,60 @@ +package welcome + +import "context" + +type AppProfile struct { + Configured bool + Name string + Email string + Endpoint string + SavedProfiles int +} + +type ConsoleConnection struct { + Configured bool + URL string +} + +type Workspace struct { + Configured bool + Path string + Name string + Project string + Provider string + Region string + Owner string +} + +// Snapshot is the credential-free local state shown by the welcome +// screen. +type Snapshot struct { + Version string + App AppProfile + Console ConsoleConnection + Workspace Workspace + KubeContext string + Diagnostics []string +} + +// Source reads local state without presentation concerns. +type Source interface { + Read(ctx context.Context) (Snapshot, error) +} + +// Loader is the narrow dependency consumed by the TUI. +type Loader interface { + Load(ctx context.Context) (Snapshot, error) +} + +type Service struct{ source Source } + +func NewService(source Source) *Service { + return &Service{source: source} +} + +func (s *Service) Load(ctx context.Context) (Snapshot, error) { + if err := ctx.Err(); err != nil { + return Snapshot{}, err + } + return s.source.Read(ctx) +} diff --git a/pkg/bridge/welcome/welcome_test.go b/pkg/bridge/welcome/welcome_test.go new file mode 100644 index 000000000..9fc67d493 --- /dev/null +++ b/pkg/bridge/welcome/welcome_test.go @@ -0,0 +1,22 @@ +package welcome + +import ( + "context" + "testing" +) + +type welcomeSourceFunc func(context.Context) (Snapshot, error) + +func (f welcomeSourceFunc) Read(ctx context.Context) (Snapshot, error) { return f(ctx) } + +func TestWelcomeServiceReturnsReadOnlySnapshot(t *testing.T) { + want := Snapshot{Version: "v1", App: AppProfile{Configured: true, Email: "dev@example.com"}} + service := NewService(welcomeSourceFunc(func(context.Context) (Snapshot, error) { return want, nil })) + got, err := service.Load(t.Context()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.App.Email != want.App.Email { + t.Fatalf("Load() = %#v", got) + } +} diff --git a/pkg/common/common.go b/pkg/common/common.go index a2ed5c2b6..d9e65597e 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -1,19 +1,21 @@ package common import ( + "context" + "errors" "fmt" "net/url" "os" "os/exec" "path/filepath" "strings" - "time" "github.com/google/uuid" "github.com/pkg/browser" "github.com/urfave/cli" "github.com/pluralsh/plural-cli/pkg/api" + "github.com/pluralsh/plural-cli/pkg/bridge" "github.com/pluralsh/plural-cli/pkg/config" "github.com/pluralsh/plural-cli/pkg/crypto" "github.com/pluralsh/plural-cli/pkg/provider" @@ -25,7 +27,9 @@ import ( ) var ( - loggedIn = false + loggedIn = false + newAuthService = func() *bridge.AuthService { return bridge.NewAuthService(bridge.PluralAuthFactory{}, 0) } + openLoginURL = browser.OpenURL ) func HandleLogin(c *cli.Context) error { @@ -39,77 +43,68 @@ func HandleLogin(c *cli.Context) error { conf := &config.Config{} conf.Token = "" conf.Endpoint = c.String("endpoint") - client := api.FromConfig(conf) + auth := newAuthService() + ctx := context.Background() persist := c.Command.Name == "login" if config.Exists() { conf := config.Read() if Affirm(fmt.Sprintf("It looks like your current Plural user is %s, use this profile?", conf.Email), "PLURAL_LOGIN_AFFIRM_CURRENT_USER") { - client = api.FromConfig(&conf) - return postLogin(&conf, client, c, persist) + return establishLogin(ctx, auth, &conf, c.String("service-account"), persist) } } - device, err := client.DeviceLogin() + device, err := auth.BeginDeviceLogin(ctx, conf.Endpoint) if err != nil { - return api.GetErrorResponse(err, "DeviceLogin") + return authError(err) } - fmt.Printf("logging into Plural at %s\n", device.LoginUrl) - if err := browser.OpenURL(device.LoginUrl); err != nil { - fmt.Printf("Open %s in your browser to proceed\n", device.LoginUrl) + fmt.Printf("logging into Plural at %s\n", device.LoginURL) + if err := openLoginURL(device.LoginURL); err != nil { + fmt.Printf("Open %s in your browser to proceed\n", device.LoginURL) } - var jwt string - for { - result, err := client.PollLoginToken(device.DeviceToken) - if err == nil { - jwt = result - break - } - - time.Sleep(2 * time.Second) + jwt, err := auth.AwaitDeviceLogin(ctx, conf.Endpoint, device.DeviceToken) + if err != nil { + return authError(err) } conf.Token = jwt conf.ReportErrors = Affirm("Would you be willing to report any errors to Plural to help with debugging?", "PLURAL_LOGIN_AFFIRM_REPORT_ERRORS") - client = api.FromConfig(conf) - return postLogin(conf, client, c, persist) + return establishLogin(ctx, auth, conf, c.String("service-account"), persist) } -func postLogin(conf *config.Config, client api.Client, c *cli.Context, persist bool) error { - me, err := client.Me() +func establishLogin(ctx context.Context, auth *bridge.AuthService, conf *config.Config, serviceAccount string, persist bool) error { + profiles := bridge.LegacyProfileStore{} + session, err := auth.EstablishSession(ctx, conf.Endpoint, conf.Token, serviceAccount, persist, func(event bridge.AuthEvent) { + switch event.Kind { + case bridge.AuthEventIdentified: + conf.Email = event.Email + fmt.Printf("\nLogged in as %s!\n", event.Email) + case bridge.AuthEventImpersonated: + conf.Email = event.Email + conf.Token = event.Credential + fmt.Printf("Assumed service account %s\n", serviceAccount) + _ = profiles.Activate(ctx, conf) + } + }) if err != nil { - return api.GetErrorResponse(err, "Me") + return authError(err) } - conf.Email = me.Email - fmt.Printf("\nLogged in as %s!\n", me.Email) - - saEmail := c.String("service-account") - if saEmail != "" { - jwt, email, err := client.ImpersonateServiceAccount(saEmail) - if err != nil { - return api.GetErrorResponse(err, "ImpersonateServiceAccount") - } - - conf.Email = email - conf.Token = jwt - fmt.Printf("Assumed service account %s\n", saEmail) - config.SetConfig(conf) - client = api.FromConfig(conf) - if !persist { - return nil - } + conf.Email = session.EffectiveEmail + conf.Token = session.Credential + if session.Impersonated && !persist { + return profiles.Activate(ctx, conf) } + return profiles.Persist(ctx, conf) +} - accessToken, err := client.GrabAccessToken() - if err != nil { - return api.GetErrorResponse(err, "GrabAccessToken") +func authError(err error) error { + if appErr, ok := errors.AsType[*bridge.Error](err); ok { + return api.GetErrorResponse(appErr.Err, string(appErr.Operation)) } - - conf.Token = accessToken - return conf.Flush() + return err } func Preflights(c *cli.Context) error { _, err := RunPreflights(c) @@ -201,8 +196,7 @@ func IsUUIDv4(input string) bool { func GetIdAndName(input string) (id, name *string) { switch { case strings.HasPrefix(input, "@"): - h := strings.Trim(input, "@") - name = &h + name = new(strings.Trim(input, "@")) case IsUUIDv4(input): id = &input default: diff --git a/pkg/common/login_test.go b/pkg/common/login_test.go new file mode 100644 index 000000000..d6edcf874 --- /dev/null +++ b/pkg/common/login_test.go @@ -0,0 +1,100 @@ +package common + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/config" + "github.com/urfave/cli" +) + +type loginFactory struct{ client *loginClient } + +func (f loginFactory) New(context.Context, string, string) bridge.AuthClient { return f.client } + +type loginClient struct{} + +func (*loginClient) DeviceLogin(context.Context) (bridge.DeviceAuthorization, error) { + return bridge.DeviceAuthorization{LoginURL: "https://example.com/device", DeviceToken: "device"}, nil +} +func (*loginClient) PollLoginToken(context.Context, string) (string, error) { + return "device-jwt", nil +} +func (*loginClient) CurrentIdentity(context.Context) (string, error) { + return "dev@example.com", nil +} +func (*loginClient) ImpersonateServiceAccount(context.Context, string) (string, string, error) { + return "", "", nil +} +func (*loginClient) GrabAccessToken(context.Context) (string, error) { + return "access-token", nil +} + +func TestHandleLoginKeepsLegacyPresentationAndPersistsResult(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("PLURAL_LOGIN_AFFIRM_REPORT_ERRORS", "true") + + oldService, oldOpen, oldLoggedIn := newAuthService, openLoginURL, loggedIn + newAuthService = func() *bridge.AuthService { + return bridge.NewAuthService(loginFactory{client: &loginClient{}}, time.Millisecond) + } + openLoginURL = func(string) error { return nil } + loggedIn = false + t.Cleanup(func() { + newAuthService, openLoginURL, loggedIn = oldService, oldOpen, oldLoggedIn + config.SetConfig(nil) + }) + + app := cli.NewApp() + app.Commands = []cli.Command{{ + Name: "login", + Action: HandleLogin, + Flags: []cli.Flag{ + cli.StringFlag{Name: "endpoint"}, + cli.StringFlag{Name: "service-account"}, + }, + }} + + output, err := captureLoginOutput(func() error { + return app.Run([]string{"plural", "login", "--endpoint", "example.com"}) + }) + if err != nil { + t.Fatalf("login error = %v", err) + } + want := "logging into Plural at https://example.com/device\n\nLogged in as dev@example.com!\n" + if output != want { + t.Fatalf("output changed\nwant: %q\n got: %q", want, output) + } + + stored := config.Import(filepath.Join(home, ".plural", config.ConfigName)) + if stored.Email != "dev@example.com" || stored.Token != "access-token" || stored.Endpoint != "example.com" || !stored.ReportErrors { + t.Fatalf("stored config = %#v", stored) + } +} + +func captureLoginOutput(run func() error) (string, error) { + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + return "", err + } + os.Stdout = w + defer func() { os.Stdout = old }() + + runErr := run() + _ = w.Close() + var output bytes.Buffer + _, copyErr := io.Copy(&output, r) + _ = r.Close() + if runErr != nil { + return "", runErr + } + return output.String(), copyErr +} diff --git a/pkg/config/config.go b/pkg/config/config.go index aceb18b8d..3c4730050 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -48,6 +48,14 @@ type VersionedConfig struct { Spec *Config `yaml:"spec"` } +// ProfileName exposes non-secret profile metadata to application services. +func (c *Config) ProfileName() string { + if c.metadata == nil { + return "" + } + return c.metadata.Name +} + func SetConfig(conf *Config) { config = conf } @@ -199,7 +207,12 @@ func (c *Config) Save(filename string) error { return err } - return os.WriteFile(f, io, 0644) + if err := os.WriteFile(f, io, 0600); err != nil { + return err + } + // WriteFile preserves permissions on an existing file. Enforce the + // credential fallback contract when upgrading legacy 0644 profiles. + return os.Chmod(f, 0600) } func (c *Config) Flush() error { diff --git a/pkg/console/config.go b/pkg/console/config.go index c620892c1..4a2d5d20d 100644 --- a/pkg/console/config.go +++ b/pkg/console/config.go @@ -76,5 +76,8 @@ func (conf *Config) Save() error { return err } - return os.WriteFile(f, io, 0644) + if err := os.WriteFile(f, io, 0600); err != nil { + return err + } + return os.Chmod(f, 0600) } diff --git a/tui/app/model.go b/tui/app/model.go new file mode 100644 index 000000000..2ec377f55 --- /dev/null +++ b/tui/app/model.go @@ -0,0 +1,93 @@ +// Package app composes the root Bubble Tea model and runs the TUI process. +package app + +import ( + "context" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + "github.com/pluralsh/plural-cli/tui/navigation" + accessscreen "github.com/pluralsh/plural-cli/tui/screens/access" + diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" + welcomescreen "github.com/pluralsh/plural-cli/tui/screens/welcome" + "github.com/pluralsh/plural-cli/tui/theme" +) + +// Dependencies contains the services required by TUI screens. +type Dependencies struct { + Welcome welcomebridge.Loader + Access accessbridge.Manager +} + +// Model is the root TUI model. It owns global input and delegates screen state +// to the active screen model. +type Model struct { + width int + height int + + theme theme.Theme + quit key.Binding + + welcome welcomescreen.Model + access accessscreen.Model + diagnostics diagnosticsscreen.Model + route navigation.Route +} + +// New composes the root model with caller-provided dependencies. +func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { + return Model{ + theme: t, + welcome: welcomescreen.New(ctx, dependencies.Welcome, t), + access: accessscreen.New(ctx, dependencies.Access, t), + diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t), + route: navigation.Welcome, + quit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + } +} + +func (m Model) Init() tea.Cmd { return m.welcome.Init() } + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case navigation.NavigateMsg: + m.route = msg.Route + switch m.route { + case navigation.Access: + return m, m.access.Init() + case navigation.Diagnostics: + return m, m.diagnostics.Init() + default: + return m, m.welcome.Init() + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case tea.KeyPressMsg: + if key.Matches(msg, m.quit) { + if m.route == navigation.Access && m.access.HasCancellableOperation() { + var cmd tea.Cmd + m.access, cmd = m.access.Update(msg) + return m, cmd + } + return m, tea.Quit + } + } + + var cmd tea.Cmd + switch m.route { + case navigation.Access: + m.access, cmd = m.access.Update(msg) + case navigation.Diagnostics: + m.diagnostics, cmd = m.diagnostics.Update(msg) + default: + m.welcome, cmd = m.welcome.Update(msg) + } + return m, cmd +} diff --git a/tui/app/model_test.go b/tui/app/model_test.go new file mode 100644 index 000000000..365edcf26 --- /dev/null +++ b/tui/app/model_test.go @@ -0,0 +1,86 @@ +package app + +import ( + "context" + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + + welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type welcomeLoaderFunc func(context.Context) (welcomebridge.Snapshot, error) + +func (f welcomeLoaderFunc) Load(ctx context.Context) (welcomebridge.Snapshot, error) { return f(ctx) } + +func TestModelHandlesResizeAndQuit(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), Dependencies{}) + updated, cmd := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + if cmd != nil { + t.Fatal("resize returned a command") + } + resized := updated.(Model) + if resized.width != 100 || resized.height != 30 { + t.Fatalf("size = %dx%d", resized.width, resized.height) + } + + _, cmd = resized.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if cmd == nil { + t.Fatal("quit key did not return a command") + } + if msg := cmd(); msg == nil { + t.Fatal("quit command returned nil") + } +} + +func TestModelRoutesScreensWithoutRebuildingShell(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), Dependencies{}) + updated, _ := model.Update(navigation.NavigateMsg{Route: navigation.Diagnostics}) + routed := updated.(Model) + if routed.route != navigation.Diagnostics || !strings.Contains(routed.View().Content, "Diagnostics") { + t.Fatalf("route/view = %q\n%s", routed.route, routed.View().Content) + } + updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Welcome}) + if got := updated.(Model).route; got != navigation.Welcome { + t.Fatalf("route = %q", got) + } +} + +func TestRunRejectsMissingTerminal(t *testing.T) { + if err := Run(t.Context(), nil, nil, Dependencies{}); !errors.Is(err, ErrNoTerminal) { + t.Fatalf("Run() error = %v", err) + } +} + +func TestModelLoadsWelcomeSnapshot(t *testing.T) { + loader := welcomeLoaderFunc(func(context.Context) (welcomebridge.Snapshot, error) { + return welcomebridge.Snapshot{ + Version: "v1.0.0", + App: welcomebridge.AppProfile{Configured: true, Email: "dev@example.com"}, + }, nil + }) + model := New(t.Context(), theme.New(colorprofile.ASCII), Dependencies{Welcome: loader}) + cmd := model.Init() + if cmd == nil { + t.Fatal("Init() did not load the welcome snapshot") + } + updated := tea.Model(model) + msg := cmd() + if batch, ok := msg.(tea.BatchMsg); ok { + for _, batchCmd := range batch { + updated, _ = updated.Update(batchCmd()) + } + } else { + updated, _ = updated.Update(msg) + } + loaded := updated.(Model) + loaded.width = 80 + if got := loaded.View().Content; !strings.Contains(got, "dev@example.com") { + t.Fatalf("welcome view does not contain loaded identity:\n%s", got) + } +} diff --git a/tui/app/run.go b/tui/app/run.go new file mode 100644 index 000000000..e9b56c92a --- /dev/null +++ b/tui/app/run.go @@ -0,0 +1,35 @@ +package app + +import ( + "context" + "errors" + "os" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/term" + + "github.com/pluralsh/plural-cli/tui/theme" +) + +// ErrNoTerminal is returned when the explicit TUI entrypoint has no usable +// interactive terminal. +var ErrNoTerminal = errors.New("plural tui requires an interactive terminal") + +// Run validates the terminal contract and starts the root model with +// caller-owned cancellation. +func Run(ctx context.Context, input, output *os.File, dependencies Dependencies) error { + if input == nil || output == nil || !term.IsTerminal(input.Fd()) || !term.IsTerminal(output.Fd()) { + return ErrNoTerminal + } + + profile := colorprofile.Detect(output, os.Environ()) + program := tea.NewProgram( + New(ctx, theme.New(profile), dependencies), + tea.WithContext(ctx), + tea.WithInput(input), + tea.WithOutput(output), + ) + _, err := program.Run() + return err +} diff --git a/tui/app/view.go b/tui/app/view.go new file mode 100644 index 000000000..ec87c4c8f --- /dev/null +++ b/tui/app/view.go @@ -0,0 +1,25 @@ +package app + +import ( + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/tui/navigation" +) + +const windowTitle = "Plural" + +func (m Model) View() tea.View { + content := m.welcome.View(m.width, m.height) + switch m.route { + case navigation.Access: + content = m.access.View(m.width, m.height) + case navigation.Diagnostics: + content = m.diagnostics.View(m.width, m.height) + } + view := tea.NewView(content) + view.AltScreen = true + view.WindowTitle = windowTitle + view.BackgroundColor = m.theme.Colors.Background + view.ForegroundColor = m.theme.Colors.Text + return view +} diff --git a/tui/assets/assets.go b/tui/assets/assets.go new file mode 100644 index 000000000..a595b64a4 --- /dev/null +++ b/tui/assets/assets.go @@ -0,0 +1,9 @@ +package assets + +import _ "embed" + +// Logo is the terminal-cell interpretation of plural-logo.png. It is embedded +// so installed binaries do not depend on their working directory. +// +//go:embed logo.txt +var Logo string diff --git a/tui/assets/logo.txt b/tui/assets/logo.txt new file mode 100644 index 000000000..e5cd8a500 --- /dev/null +++ b/tui/assets/logo.txt @@ -0,0 +1,5 @@ +███████ ██ +██ ██ +██ ██ ██ +██ ██ +██ ███████ \ No newline at end of file diff --git a/tui/components/commandbar/commandbar.go b/tui/components/commandbar/commandbar.go new file mode 100644 index 000000000..5cd48c570 --- /dev/null +++ b/tui/components/commandbar/commandbar.go @@ -0,0 +1,236 @@ +// Package commandbar provides the reusable command input shown at the bottom +// of TUI screens. +package commandbar + +import ( + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/tui/theme" +) + +const ( + minimumWidth = 12 + title = "Command" + popupTitle = "Available commands" + maximumPopupRows = 6 +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionNextSuggestion + keyActionPreviousSuggestion + keyActionSubmit + keyActionDismiss +) + +var keyActionKeystrokes = map[keyAction]string{ + keyActionNextSuggestion: "down", + keyActionPreviousSuggestion: "up", + keyActionSubmit: "enter", + keyActionDismiss: "esc", +} + +func actionForKeystroke(keystroke string) keyAction { + for action, candidate := range keyActionKeystrokes { + if keystroke == candidate { + return action + } + } + + return keyActionNone +} + +// Model owns command entry, completion, selection, and rendering. +type Model struct { + theme theme.Theme + input textinput.Model + selected string + suggestions []string + popupOpen bool + popupCursor int +} + +// SubmittedMsg is emitted when the user submits a command. The shell or +// screen decides what the command means; the input component only owns entry. +type SubmittedMsg struct{ Command string } + +// New creates a focused command bar with the provided completion candidates. +func New(t theme.Theme, suggestions []string) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "Search commands…" + input.CharLimit = 80 + input.ShowSuggestions = true + input.SetSuggestions(suggestions) + input.SetVirtualCursor(true) + + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Focused.Suggestion = t.Muted + styles.Blurred = styles.Focused + styles.Cursor.Color = t.Colors.Primary + styles.Cursor.Shape = tea.CursorBar + styles.Cursor.Blink = false + input.SetStyles(styles) + input.Focus() + + return Model{theme: t, input: input, suggestions: suggestions} +} + +// Update handles completion, selection, clearing, and text entry. +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + if key, ok := msg.(tea.KeyPressMsg); ok { + switch actionForKeystroke(key.Keystroke()) { + case keyActionNextSuggestion: + matches := m.filteredSuggestions() + if len(matches) == 0 { + return m, nil + } + if m.popupOpen { + m.popupCursor = (m.popupCursor + 1) % len(matches) + } else { + m.popupOpen = true + m.popupCursor = 0 + } + return m, nil + case keyActionPreviousSuggestion: + matches := m.filteredSuggestions() + if len(matches) == 0 { + return m, nil + } + if m.popupOpen { + m.popupCursor = (m.popupCursor - 1 + len(matches)) % len(matches) + } else { + m.popupOpen = true + m.popupCursor = len(matches) - 1 + } + return m, nil + case keyActionSubmit: + if m.popupOpen { + matches := m.filteredSuggestions() + if len(matches) > 0 { + m.popupCursor = min(m.popupCursor, len(matches)-1) + m.selected = matches[m.popupCursor] + m.input.SetValue(m.selected) + } + m.popupOpen = false + } else { + m.selected = lo.CoalesceOrEmpty(strings.TrimSpace(m.input.Value()), m.input.CurrentSuggestion()) + } + if m.selected == "" { + return m, nil + } + selected := m.selected + return m, func() tea.Msg { return SubmittedMsg{Command: selected} } + case keyActionDismiss: + if m.popupOpen { + m.popupOpen = false + return m, nil + } + m.input.Reset() + m.selected = "" + return m, nil + } + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + if matches := m.filteredSuggestions(); len(matches) == 0 { + m.popupCursor = 0 + } else { + m.popupCursor = min(m.popupCursor, len(matches)-1) + } + return m, cmd +} + +// Selected returns the most recently selected command. +func (m Model) Selected() string { return m.selected } + +// Value returns the current command input value. +func (m Model) Value() string { return m.input.Value() } + +// CurrentSuggestion returns the active completion candidate. +func (m Model) CurrentSuggestion() string { return m.input.CurrentSuggestion() } + +// View renders the framed input and its contextual key help. +func (m Model) View(width int) string { + width = max(width, minimumWidth) + input := m.input + input.SetWidth(max(1, width-7)) + + help := "tab complete · ↑/↓ suggestions · enter select · esc clear · ctrl+c quit" + if m.popupOpen { + help = "↑/↓ choose · enter open · esc close · type to filter · ctrl+c quit" + } else if m.selected != "" { + help = "Opening “" + m.selected + "”…" + } + help = m.theme.Muted.Render(ansi.Truncate(help, max(1, width-2), "…")) + + command := renderBox(input.View(), width) + "\n " + help + if !m.popupOpen { + return command + } + return m.renderPopup(width) + "\n" + command +} + +func (m Model) filteredSuggestions() []string { + query := strings.ToLower(strings.TrimSpace(m.input.Value())) + if query == "" { + return m.suggestions + } + result := make([]string, 0, len(m.suggestions)) + for _, suggestion := range m.suggestions { + if strings.Contains(strings.ToLower(suggestion), query) { + result = append(result, suggestion) + } + } + return result +} + +func (m Model) renderPopup(width int) string { + matches := m.filteredSuggestions() + rows := min(maximumPopupRows, len(matches)) + popupWidth := min(width, 38) + innerWidth := popupWidth - 4 + title := ansi.Truncate(popupTitle, max(1, popupWidth-5), "…") + rule := strings.Repeat("─", max(0, popupWidth-5-lipgloss.Width(title))) + lines := []string{"╭─ " + title + " " + rule + "╮"} + start := 0 + if m.popupCursor >= rows { + start = m.popupCursor - rows + 1 + } + for i := 0; i < rows; i++ { + index := start + i + line := " " + matches[index] + if index == m.popupCursor { + line = m.theme.Title.Render("› " + matches[index]) + } + line = ansi.Truncate(line, innerWidth, "…") + lines = append(lines, "│ "+line+strings.Repeat(" ", max(0, innerWidth-lipgloss.Width(line)))+" │") + } + lines = append(lines, "╰"+strings.Repeat("─", popupWidth-2)+"╯") + return strings.Join(lines, "\n") +} + +// renderBox draws the frame directly so text input escape sequences remain on +// one line and its width stays predictable. +func renderBox(line string, width int) string { + innerWidth := width - 4 + topRule := strings.Repeat("─", max(0, width-5-lipgloss.Width(title))) + top := "╭─ " + title + " " + topRule + "╮" + + line = ansi.Truncate(line, innerWidth, "…") + body := "│ " + line + strings.Repeat(" ", max(0, innerWidth-lipgloss.Width(line))) + " │" + bottom := "╰" + strings.Repeat("─", width-2) + "╯" + return strings.Join([]string{top, body, bottom}, "\n") +} diff --git a/tui/components/commandbar/commandbar_test.go b/tui/components/commandbar/commandbar_test.go new file mode 100644 index 000000000..ad87a0731 --- /dev/null +++ b/tui/components/commandbar/commandbar_test.go @@ -0,0 +1,97 @@ +package commandbar + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestCompletesAndSubmitsSelection(t *testing.T) { + model := New(theme.New(colorprofile.ASCII), []string{"access", "diagnostics"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + if got := model.CurrentSuggestion(); got != "diagnostics" { + t.Fatalf("suggestion = %q, want diagnostics", got) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + if got := model.Value(); got != "diagnostics" { + t.Fatalf("completed value = %q, want diagnostics", got) + } + + var cmd tea.Cmd + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("selecting a command did not emit submission") + } + if got := cmd().(SubmittedMsg).Command; got != "diagnostics" { + t.Fatalf("submitted command = %q, want diagnostics", got) + } + if got := model.Selected(); got != "diagnostics" { + t.Fatalf("selected command = %q, want diagnostics", got) + } +} + +func TestViewIsStandaloneAndWidthBounded(t *testing.T) { + model := New(theme.New(colorprofile.ASCII), []string{"access"}) + view := model.View(76) + lines := strings.Split(ansi.Strip(view), "\n") + if len(lines) != 4 { + t.Fatalf("view height = %d, want 4", len(lines)) + } + if !strings.Contains(lines[0], "Command") || !strings.Contains(lines[3], "ctrl+c quit") { + t.Fatalf("command bar is incomplete:\n%s", view) + } + for _, line := range lines { + if got := lipgloss.Width(line); got > 76 { + t.Fatalf("line width %d exceeds 76: %q", got, line) + } + } +} + +func TestArrowKeysOpenAndSelectCommandPopup(t *testing.T) { + model := New(theme.New(colorprofile.ASCII), []string{"access", "diagnostics", "profiles"}) + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + if cmd != nil || !model.popupOpen || model.popupCursor != 0 { + t.Fatalf("first down did not open popup: %#v", model) + } + view := ansi.Strip(model.View(76)) + if !strings.Contains(view, "Available commands") || !strings.Contains(view, "› access") || !strings.Contains(view, " diagnostics") { + t.Fatalf("command popup is incomplete:\n%s", view) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("popup selection did not submit") + } + if got := cmd().(SubmittedMsg).Command; got != "diagnostics" { + t.Fatalf("submitted command = %q, want diagnostics", got) + } + if model.popupOpen { + t.Fatal("popup remained open after submit") + } +} + +func TestCommandPopupFiltersAndEscClosesBeforeClearing(t *testing.T) { + model := New(theme.New(colorprofile.ASCII), []string{"access", "diagnostics", "profiles"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'p', Text: "p"}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + view := ansi.Strip(model.View(76)) + if !strings.Contains(view, "› profiles") || strings.Contains(view, "diagnostics") { + t.Fatalf("filtered popup is incorrect:\n%s", view) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if model.popupOpen || model.Value() != "p" { + t.Fatalf("first esc should only close popup: open=%v value=%q", model.popupOpen, model.Value()) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if model.Value() != "" { + t.Fatalf("second esc did not clear input: %q", model.Value()) + } +} diff --git a/tui/components/page/page.go b/tui/components/page/page.go new file mode 100644 index 000000000..ccc8fdf5f --- /dev/null +++ b/tui/components/page/page.go @@ -0,0 +1,103 @@ +// Package page provides shared routed-screen chrome: the same two-cell gutter, +// semantic rule, framed surfaces, responsive minimum, and bottom-anchored key +// help used throughout the TUI. +package page + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/theme" +) + +const ( + DefaultWidth = 100 + DefaultHeight = 30 + MinimumWidth = 80 + MinimumHeight = 24 + SideMargin = 2 +) + +// Size applies deterministic defaults for model tests and initial renders. +func Size(width, height int) (int, int) { + if width <= 0 { + width = DefaultWidth + } + if height <= 0 { + height = DefaultHeight + } + return width, height +} + +// ContentWidth returns the width available inside the shared side gutters. +func ContentWidth(width int) int { return max(1, width-2*SideMargin) } + +// Render composes routed-screen content and anchors help to the final row. +func Render(t theme.Theme, width, height int, title, status, body, help string) string { + width, height = Size(width, height) + if width < MinimumWidth || height < MinimumHeight { + return Unsupported(t, width, height) + } + contentWidth := ContentWidth(width) + header := renderHeader(t, contentWidth, title, status) + occupied := lipgloss.Height(header) + 1 + lipgloss.Height(body) + separation := max(2, height-occupied) + help = t.Muted.Render(ansi.Truncate(help, contentWidth, "…")) + content := header + "\n\n" + body + strings.Repeat("\n", separation) + help + return indent(content, SideMargin) +} + +// Panel renders a fixed-height semantic surface. Content is truncated rather +// than allowed to push key help off-screen. +func Panel(t theme.Theme, title string, lines []string, width, height int, focused bool) string { + width = max(8, width) + height = max(3, height) + innerWidth := width - 4 + border := lipgloss.NewStyle().Foreground(t.Colors.Border) + if focused { + border = lipgloss.NewStyle().Foreground(t.Colors.Primary) + } + styledTitle := t.Body.Render(title) + if focused { + styledTitle = t.Title.Render("› " + title) + } + ruleWidth := max(1, width-lipgloss.Width(styledTitle)-5) + result := []string{border.Render("╭─ ") + styledTitle + border.Render(" "+strings.Repeat("─", ruleWidth)+"╮")} + visible := height - 2 + for i := 0; i < visible; i++ { + line := "" + if i < len(lines) { + line = lines[i] + } + if i == visible-1 && len(lines) > visible { + line = t.Muted.Render("…") + } + line = ansi.Truncate(line, innerWidth, "…") + result = append(result, border.Render("│")+" "+line+strings.Repeat(" ", max(0, innerWidth-lipgloss.Width(line)))+" "+border.Render("│")) + } + result = append(result, border.Render("╰"+strings.Repeat("─", width-2)+"╯")) + return strings.Join(result, "\n") +} + +func Unsupported(t theme.Theme, width, height int) string { + message := "Unsupported terminal size: " + dimensions(width, height) + " · minimum " + dimensions(MinimumWidth, MinimumHeight) + message = t.Body.Render(ansi.Truncate(message, max(1, width), "…")) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, message) +} + +func renderHeader(t theme.Theme, width int, title, status string) string { + left := t.Title.Render("Plural") + " " + t.Body.Render(title) + status = ansi.Truncate(status, max(0, width-lipgloss.Width(left)-2), "…") + gap := strings.Repeat(" ", max(1, width-lipgloss.Width(left)-lipgloss.Width(status))) + line := ansi.Truncate(left+gap+status, width, "…") + return line + "\n" + lipgloss.NewStyle().Foreground(t.Colors.Primary).Render(strings.Repeat("─", width)) +} + +func indent(content string, width int) string { + padding := strings.Repeat(" ", width) + return padding + strings.ReplaceAll(content, "\n", "\n"+padding) +} +func dimensions(width, height int) string { return fmt.Sprintf("%d×%d", width, height) } diff --git a/tui/components/page/page_test.go b/tui/components/page/page_test.go new file mode 100644 index 000000000..aa16a0939 --- /dev/null +++ b/tui/components/page/page_test.go @@ -0,0 +1,37 @@ +package page + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestRenderAnchorsSharedChromeAndHelp(t *testing.T) { + theme := theme.New(colorprofile.ASCII) + body := Panel(theme, "Content", []string{"one", "two"}, ContentWidth(80), 6, true) + view := ansi.Strip(Render(theme, 80, 24, "Screen", "✓ ready", body, "esc back")) + lines := strings.Split(view, "\n") + if len(lines) != 24 { + t.Fatalf("height = %d, want 24", len(lines)) + } + if !strings.HasPrefix(lines[0], " Plural Screen") || !strings.Contains(lines[len(lines)-1], "esc back") { + t.Fatalf("shared chrome is incomplete:\n%s", view) + } + for _, line := range lines { + if got := lipgloss.Width(line); got > 80 { + t.Fatalf("line width %d exceeds 80: %q", got, line) + } + } +} + +func TestUnsupportedUsesWelcomeMinimum(t *testing.T) { + view := ansi.Strip(Render(theme.New(colorprofile.ASCII), 79, 23, "Screen", "", "", "")) + if !strings.Contains(view, "79×23") || !strings.Contains(view, "80×24") { + t.Fatalf("unsupported dimensions missing:\n%s", view) + } +} diff --git a/tui/components/spinner/spinner.go b/tui/components/spinner/spinner.go new file mode 100644 index 000000000..59efc11c9 --- /dev/null +++ b/tui/components/spinner/spinner.go @@ -0,0 +1,23 @@ +package spinner + +import ( + "time" + + "charm.land/bubbles/v2/spinner" + + "github.com/pluralsh/plural-cli/tui/theme" +) + +// Mark follows the logo's top-left bracket, center dot, and bottom-right +// bracket. Every frame occupies one terminal cell, so nearby text stays put. +var Mark = spinner.Spinner{ + Frames: []string{"▛", "●", "▟", "●"}, + FPS: 120 * time.Millisecond, +} + +func New(t theme.Theme) spinner.Model { + return spinner.New( + spinner.WithSpinner(Mark), + spinner.WithStyle(t.Title), + ) +} diff --git a/tui/components/spinner/spinner_test.go b/tui/components/spinner/spinner_test.go new file mode 100644 index 000000000..025faacd6 --- /dev/null +++ b/tui/components/spinner/spinner_test.go @@ -0,0 +1,29 @@ +package spinner + +import ( + "reflect" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestMarkFramesHaveStableCompactWidth(t *testing.T) { + if len(Mark.Frames) == 0 { + t.Fatal("spinner has no frames") + } + for _, frame := range Mark.Frames { + if width := lipgloss.Width(frame); width != 1 { + t.Fatalf("frame %q has width %d, want 1", frame, width) + } + } +} + +func TestNewUsesPluralMark(t *testing.T) { + model := New(theme.New(colorprofile.ASCII)) + if got, want := model.Spinner.Frames, Mark.Frames; !reflect.DeepEqual(got, want) { + t.Fatalf("got frames %q, want %q", got, want) + } +} diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go new file mode 100644 index 000000000..186ed074f --- /dev/null +++ b/tui/navigation/navigation.go @@ -0,0 +1,21 @@ +// Package navigation defines route messages shared by otherwise independent +// screens. Keeping route ownership out of individual screens lets new features +// be developed without importing the root application package. +package navigation + +import tea "charm.land/bubbletea/v2" + +// Route identifies a top-level TUI screen. +type Route string + +const ( + Welcome Route = "welcome" + Access Route = "access" + Diagnostics Route = "diagnostics" +) + +// NavigateMsg requests a top-level route change. +type NavigateMsg struct{ Route Route } + +// Navigate returns a typed route command. +func Navigate(route Route) tea.Cmd { return func() tea.Msg { return NavigateMsg{Route: route} } } diff --git a/tui/screens/access/golden_test.go b/tui/screens/access/golden_test.go new file mode 100644 index 000000000..075789dcb --- /dev/null +++ b/tui/screens/access/golden_test.go @@ -0,0 +1,74 @@ +package access + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestAccessGoldens(t *testing.T) { + personal := accessbridge.Profile{ID: "app-personal", Name: "personal", Email: "alex@acme.io", Endpoint: "app.plural.sh"} + consulting := accessbridge.Profile{ID: "app-consulting", Name: "consulting", Email: "alex@consulting.dev", Endpoint: "cloud.plural.example"} + production := accessbridge.ConsoleProfile{ID: "console-production", Name: "production", URL: "https://console.acme.io"} + staging := accessbridge.ConsoleProfile{ID: "console-staging", Name: "staging", URL: "https://console.staging.acme.io"} + snapshot := accessbridge.Snapshot{ + State: accessbridge.State{ + Profiles: []accessbridge.Profile{personal, consulting}, ActiveProfileID: personal.ID, + ConsoleProfiles: []accessbridge.ConsoleProfile{production, staging}, ActiveConsoleID: production.ID, + }, + Context: accessbridge.AuthContext{Base: &personal, Acting: &accessbridge.Identity{Email: "deploy@acme.io", ServiceAccount: true}, Console: &production}, + } + + for _, width := range []int{80, 120} { + t.Run(strconv.Itoa(width), func(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model.snapshot = snapshot + height := 24 + if width == 120 { + height = 30 + } + got := normalizeGoldenView(model.View(width, height)) + golden := filepath.Join("testdata", "access-"+strconv.Itoa(width)+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + assertGoldenDimensions(t, got, width, height) + if strings.Contains(got, "super-secret") { + t.Fatal("Access golden exposed a credential") + } + }) + } +} + +func normalizeGoldenView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} +func assertGoldenDimensions(t *testing.T, view string, width, height int) { + t.Helper() + lines := strings.Split(view, "\n") + if len(lines) != height { + t.Fatalf("view height = %d, want %d", len(lines), height) + } + for _, line := range lines { + if got := lipgloss.Width(line); got > width { + t.Fatalf("line width %d exceeds %d: %q", got, width, line) + } + } +} diff --git a/tui/screens/access/model.go b/tui/screens/access/model.go new file mode 100644 index 000000000..5c0505c2e --- /dev/null +++ b/tui/screens/access/model.go @@ -0,0 +1,371 @@ +// Package access implements the Phase 1 identity and connection screen. It +// depends only on access.Manager and can be developed independently of +// the root shell. +package access + +import ( + "context" + "errors" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type loadedMsg struct { + snapshot accessbridge.Snapshot + err error +} +type changedMsg struct{ err error } +type accountsMsg struct { + accounts []accessbridge.ServiceAccount + err error +} +type authorizedMsg struct { + authorization accessbridge.DeviceAuthorization + requestID uint64 + err error +} +type loggedInMsg struct { + profile accessbridge.Profile + requestID uint64 + err error +} + +type mode uint8 + +const ( + modeProfiles mode = iota + modeAccounts + modeConsoleForm + modeDeviceLogin +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionCancelOperation + keyActionNextPanel + keyActionPreviousPanel + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionDeviceLogin + keyActionAddConsole + keyActionImpersonate + keyActionStopImpersonating +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionCancelOperation: {"ctrl+c"}, + keyActionNextPanel: {"tab"}, + keyActionPreviousPanel: {"shift+tab"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionRefresh: {"r"}, + keyActionDeviceLogin: {"n"}, + keyActionAddConsole: {"c"}, + keyActionImpersonate: {"i"}, + keyActionStopImpersonating: {"x"}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + + return keyActionNone +} + +// Model owns only Access-screen interaction state. +type Model struct { + ctx context.Context + manager accessbridge.Manager + theme theme.Theme + loading bool + snapshot accessbridge.Snapshot + err error + panel int + appCursor int + consoleCursor int + accountCursor int + mode mode + authorization accessbridge.DeviceAuthorization + operationCtx context.Context + cancel context.CancelFunc + form []textinput.Model + formIndex int + loginRequest uint64 +} + +func New(ctx context.Context, manager accessbridge.Manager, t theme.Theme) Model { + return Model{ctx: ctx, manager: manager, theme: t, loading: manager != nil, form: newConsoleForm(t)} +} + +func newConsoleForm(t theme.Theme) []textinput.Model { + values := make([]textinput.Model, 3) + for i, placeholder := range []string{"Profile name", "https://console.example.com", "Console token"} { + values[i] = textinput.New() + values[i].Prompt = "› " + values[i].Placeholder = placeholder + values[i].CharLimit = 256 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + values[i].SetStyles(styles) + } + values[2].EchoMode = textinput.EchoPassword + return values +} + +func (m Model) Init() tea.Cmd { + if m.manager == nil { + return nil + } + return m.load +} +func (m Model) load() tea.Msg { + snapshot, err := m.manager.Load(m.ctx) + return loadedMsg{snapshot, err} +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case loadedMsg: + m.loading = false + m.err = msg.err + if msg.err == nil { + m.snapshot = msg.snapshot + } + return m, nil + case changedMsg: + m.loading = false + m.err = msg.err + if msg.err == nil { + m.mode = modeProfiles + return m, m.load + } + return m, nil + case accountsMsg: + m.loading = false + m.err = msg.err + if msg.err == nil { + m.snapshot.ServiceAccounts = msg.accounts + m.mode = modeAccounts + m.accountCursor = 0 + } + return m, nil + case authorizedMsg: + if msg.requestID != m.loginRequest { + return m, nil + } + m.loading = false + m.err = msg.err + if msg.err != nil { + m.cancel = nil + m.operationCtx = nil + m.mode = modeProfiles + return m, nil + } + m.authorization = msg.authorization + m.mode = modeDeviceLogin + m.loading = true + loginCtx := m.operationCtx + if loginCtx == nil { + var cancel context.CancelFunc + loginCtx, cancel = context.WithCancel(m.ctx) + m.operationCtx, m.cancel = loginCtx, cancel + } + return m, func() tea.Msg { + profile, err := m.manager.CompleteDeviceLogin(loginCtx, "default", msg.authorization, "app.plural.sh") + return loggedInMsg{profile: profile, requestID: msg.requestID, err: err} + } + case loggedInMsg: + if msg.requestID != m.loginRequest { + return m, nil + } + m.loading = false + m.cancel = nil + m.operationCtx = nil + m.err = msg.err + m.mode = modeProfiles + if msg.err == nil { + return m, m.load + } + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeConsoleForm { + return m.updateForm(msg) + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + if action == keyActionBack || (action == keyActionCancelOperation && m.cancel != nil) { + if m.cancel != nil { + m.cancel() + m.loginRequest++ + m.cancel = nil + m.operationCtx = nil + m.loading = false + m.mode = modeProfiles + return m, nil + } + if m.mode != modeProfiles { + m.mode = modeProfiles + m.err = nil + return m, nil + } + return m, navigation.Navigate(navigation.Welcome) + } + if m.loading { + return m, nil + } + if m.manager == nil { + m.err = errors.New("access services are unavailable") + return m, nil + } + if m.mode == modeConsoleForm { + return m.updateForm(key) + } + if m.mode == modeAccounts { + return m.updateAccounts(key) + } + switch action { + case keyActionNextPanel: + m.panel = (m.panel + 1) % 2 + case keyActionPreviousPanel: + m.panel = (m.panel + 1) % 2 + case keyActionMoveUp: + m = m.move(-1) + case keyActionMoveDown: + m = m.move(1) + case keyActionConfirm: + return m.activate() + case keyActionRefresh: + m.loading = true + return m, m.load + case keyActionDeviceLogin: + m.loading = true + m.loginRequest++ + requestID := m.loginRequest + loginCtx, cancel := context.WithCancel(m.ctx) + m.operationCtx, m.cancel = loginCtx, cancel + return m, func() tea.Msg { + authorization, err := m.manager.BeginDeviceLogin(loginCtx, "app.plural.sh") + return authorizedMsg{authorization: authorization, requestID: requestID, err: err} + } + case keyActionAddConsole: + m.mode = modeConsoleForm + m.formIndex = 0 + for i := range m.form { + m.form[i].Reset() + m.form[i].Blur() + } + m.form[0].Focus() + case keyActionImpersonate: + m.loading = true + return m, func() tea.Msg { + accounts, err := m.manager.SearchServiceAccounts(m.ctx, "") + return accountsMsg{accounts, err} + } + case keyActionStopImpersonating: + m.manager.StopImpersonating() + m.loading = true + return m, m.load + } + return m, nil +} + +// HasCancellableOperation lets the shell route Ctrl+C to this screen before +// applying its global quit binding. +func (m Model) HasCancellableOperation() bool { return m.cancel != nil } + +func (m Model) updateAccounts(key tea.KeyPressMsg) (Model, tea.Cmd) { + count := len(m.snapshot.ServiceAccounts) + switch actionForKeystroke(key.Keystroke()) { + case keyActionMoveUp: + m.accountCursor = clampCursor(m.accountCursor-1, count) + case keyActionMoveDown: + m.accountCursor = clampCursor(m.accountCursor+1, count) + case keyActionConfirm: + if count > 0 { + email := m.snapshot.ServiceAccounts[m.accountCursor].Email + m.loading = true + return m, func() tea.Msg { return changedMsg{err: m.manager.Impersonate(m.ctx, email)} } + } + } + return m, nil +} + +func (m Model) updateForm(msg tea.Msg) (Model, tea.Cmd) { + if key, ok := msg.(tea.KeyPressMsg); ok && actionForKeystroke(key.Keystroke()) == keyActionConfirm { + if m.formIndex < len(m.form)-1 { + m.form[m.formIndex].Blur() + m.formIndex++ + m.form[m.formIndex].Focus() + return m, nil + } + name, url, token := m.form[0].Value(), m.form[1].Value(), m.form[2].Value() + m.loading = true + m.mode = modeProfiles + return m, func() tea.Msg { + _, err := m.manager.AddConsoleProfile(m.ctx, name, url, token) + return changedMsg{err: err} + } + } + var cmd tea.Cmd + m.form[m.formIndex], cmd = m.form[m.formIndex].Update(msg) + return m, cmd +} + +func (m Model) move(delta int) Model { + if m.panel == 0 { + m.appCursor = clampCursor(m.appCursor+delta, len(m.snapshot.State.Profiles)) + } else { + m.consoleCursor = clampCursor(m.consoleCursor+delta, len(m.snapshot.State.ConsoleProfiles)) + } + return m +} +func clampCursor(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} +func (m Model) activate() (Model, tea.Cmd) { + if m.panel == 0 && len(m.snapshot.State.Profiles) > 0 { + id := m.snapshot.State.Profiles[m.appCursor].ID + m.loading = true + return m, func() tea.Msg { return changedMsg{err: m.manager.ActivateProfile(m.ctx, id)} } + } + if m.panel == 1 && len(m.snapshot.State.ConsoleProfiles) > 0 { + id := m.snapshot.State.ConsoleProfiles[m.consoleCursor].ID + m.loading = true + return m, func() tea.Msg { return changedMsg{err: m.manager.ActivateConsole(m.ctx, id)} } + } + return m, nil +} diff --git a/tui/screens/access/model_test.go b/tui/screens/access/model_test.go new file mode 100644 index 000000000..6ca922d07 --- /dev/null +++ b/tui/screens/access/model_test.go @@ -0,0 +1,137 @@ +package access + +import ( + "context" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + + accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeManager struct { + snapshot accessbridge.Snapshot + activatedApp, activatedConsole, impersonated string + stopped bool +} + +func (f *fakeManager) Load(context.Context) (accessbridge.Snapshot, error) { return f.snapshot, nil } +func (f *fakeManager) BeginDeviceLogin(context.Context, string) (accessbridge.DeviceAuthorization, error) { + return accessbridge.DeviceAuthorization{LoginURL: "https://login.example.com", DeviceToken: "device"}, nil +} +func (f *fakeManager) CompleteDeviceLogin(context.Context, string, accessbridge.DeviceAuthorization, string) (accessbridge.Profile, error) { + return accessbridge.Profile{ID: "new"}, nil +} +func (f *fakeManager) AddConsoleProfile(context.Context, string, string, string) (accessbridge.ConsoleProfile, error) { + return accessbridge.ConsoleProfile{}, nil +} +func (f *fakeManager) ActivateProfile(_ context.Context, id string) error { + f.activatedApp = id + return nil +} +func (f *fakeManager) ActivateConsole(_ context.Context, id string) error { + f.activatedConsole = id + return nil +} +func (f *fakeManager) SearchServiceAccounts(context.Context, string) ([]accessbridge.ServiceAccount, error) { + return []accessbridge.ServiceAccount{{ID: "sa", Email: "deploy@example.com"}}, nil +} +func (f *fakeManager) Impersonate(_ context.Context, email string) error { + f.impersonated = email + return nil +} +func (f *fakeManager) StopImpersonating() { f.stopped = true } + +func loadedModel(t *testing.T, manager *fakeManager) Model { + model := New(t.Context(), manager, theme.New(colorprofile.ASCII)) + cmd := model.Init() + if cmd == nil { + t.Fatal("Init() returned nil") + } + model, _ = model.Update(cmd()) + return model +} + +func TestFirstRunCanSkipConsoleAndReturnToWelcome(t *testing.T) { + model := loadedModel(t, &fakeManager{}) + view := model.View(100, 30) + if !strings.Contains(view, "Skipped for now") || !strings.Contains(view, "Press n to sign in") { + t.Fatalf("first-run guidance missing:\n%s", view) + } + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if cmd == nil || cmd().(navigation.NavigateMsg).Route != navigation.Welcome { + t.Fatal("esc did not return to welcome") + } +} + +func TestPanelsActivateIndependently(t *testing.T) { + manager := &fakeManager{snapshot: accessbridge.Snapshot{State: accessbridge.State{ + Profiles: []accessbridge.Profile{{ID: "app-a"}, {ID: "app-b"}}, ActiveProfileID: "app-a", + ConsoleProfiles: []accessbridge.ConsoleProfile{{ID: "console-a"}, {ID: "console-b"}}, ActiveConsoleID: "console-a", + }}} + model := loadedModel(t, manager) + if model.loading || model.mode != modeProfiles || model.panel != 0 || len(model.snapshot.State.Profiles) != 2 { + t.Fatalf("loaded model = loading:%v mode:%v panel:%d profiles:%d", model.loading, model.mode, model.panel, len(model.snapshot.State.Profiles)) + } + down := tea.KeyPressMsg{Code: 'j', Text: "j"} + model, _ = model.Update(down) + if model.appCursor != 1 { + t.Fatalf("App cursor = %d after string=%q keystroke=%q (error %v)", model.appCursor, down.String(), down.Keystroke(), model.err) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + cmd() + if manager.activatedApp != "app-b" { + t.Fatalf("activated App = %q", manager.activatedApp) + } + model.loading = false + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'j', Text: "j"}) + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + cmd() + if manager.activatedConsole != "console-b" { + t.Fatalf("activated Console = %q", manager.activatedConsole) + } +} + +func TestServiceAccountPickerCreatesSessionAction(t *testing.T) { + manager := &fakeManager{snapshot: accessbridge.Snapshot{State: accessbridge.State{Profiles: []accessbridge.Profile{{ID: "app"}}, ActiveProfileID: "app"}}} + model := loadedModel(t, manager) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'i', Text: "i"}) + model, _ = model.Update(cmd()) + if model.mode != modeAccounts { + t.Fatal("service-account picker did not open") + } + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + cmd() + if manager.impersonated != "deploy@example.com" { + t.Fatalf("impersonated = %q", manager.impersonated) + } +} + +func TestConsoleTokenIsMasked(t *testing.T) { + model := New(t.Context(), &fakeManager{}, theme.New(colorprofile.ASCII)) + model.mode = modeConsoleForm + model.form[2].SetValue("super-secret") + model.formIndex = 2 + model.form[2].Focus() + view := model.View(100, 30) + if strings.Contains(view, "super-secret") { + t.Fatalf("Console token leaked in view:\n%s", view) + } +} + +func TestDeviceLoginCanBeCancelledBeforeGlobalQuit(t *testing.T) { + model := loadedModel(t, &fakeManager{}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'n', Text: "n"}) + if !model.HasCancellableOperation() { + t.Fatal("device login is not cancellable") + } + model, cmd := model.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if cmd != nil || model.HasCancellableOperation() || model.mode != modeProfiles { + t.Fatalf("cancel left operation active: %#v", model) + } +} diff --git a/tui/screens/access/testdata/access-120.golden b/tui/screens/access/testdata/access-120.golden new file mode 100644 index 000000000..093bf1b2a --- /dev/null +++ b/tui/screens/access/testdata/access-120.golden @@ -0,0 +1,30 @@ + Plural Identity & connections ✓ context ready + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Plural App profiles ───────────────────────────────╮ ╭─ Console profiles ─────────────────────────────────────╮ + │ › personal ACTIVE │ │ production ACTIVE │ + │ alex@acme.io │ │ https://console.acme.io │ + │ consulting │ │ staging │ + │ alex@consulting.dev │ │ https://console.staging.acme.io │ + │ │ │ │ + │ │ │ │ + ╰───────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────╯ + + ╭─ Effective context ──────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Base account alex@acme.io via personal │ + │ Acting as deploy@acme.io · session only │ + │ Console production · https://console.acme.io │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + tab panel · ↑/↓ select · enter activate · n App login · c Console · i act as · x stop · r refresh · esc back diff --git a/tui/screens/access/testdata/access-80.golden b/tui/screens/access/testdata/access-80.golden new file mode 100644 index 000000000..bbf9c36d5 --- /dev/null +++ b/tui/screens/access/testdata/access-80.golden @@ -0,0 +1,24 @@ + Plural Identity & connections ✓ context ready + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Plural App profiles ───────────╮ ╭─ Console profiles ─────────────────╮ + │ › personal ACTIVE │ │ production ACTIVE │ + │ alex@acme.io │ │ https://console.acme.io │ + │ consulting │ │ staging │ + │ alex@consulting.dev │ │ https://console.staging.acme.… │ + │ │ │ │ + │ │ │ │ + ╰───────────────────────────────────╯ ╰────────────────────────────────────╯ + + ╭─ Effective context ──────────────────────────────────────────────────────╮ + │ Base account alex@acme.io via personal │ + │ Acting as deploy@acme.io · session only │ + │ Console production · https://console.acme.io │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + tab panel · ↑/↓ select · enter use · n App · c Console · i act as · esc back diff --git a/tui/screens/access/view.go b/tui/screens/access/view.go new file mode 100644 index 000000000..d4ac3f20f --- /dev/null +++ b/tui/screens/access/view.go @@ -0,0 +1,153 @@ +package access + +import ( + "fmt" + + "charm.land/lipgloss/v2" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +const profilePanelHeight = 8 + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + status := m.headerStatus() + + var body, help string + switch m.mode { + case modeDeviceLogin: + body = page.Panel(m.theme, "Plural App device login", m.deviceLoginLines(), contentWidth, 9, true) + help = "esc cancel · ctrl+c cancel" + case modeConsoleForm: + body = page.Panel(m.theme, "Add Console connection", m.consoleFormLines(), contentWidth, 9, true) + help = "enter next/save · esc cancel · token is stored securely" + case modeAccounts: + body = page.Panel(m.theme, "Choose acting identity", m.accountLines(), contentWidth, 10, true) + help = "↑/↓ select · enter use for this session · esc cancel" + default: + body = m.profileOverview(contentWidth) + if contentWidth < 100 { + help = "tab panel · ↑/↓ select · enter use · n App · c Console · i act as · esc back" + } else { + help = "tab panel · ↑/↓ select · enter activate · n App login · c Console · i act as · x stop · r refresh · esc back" + } + } + return page.Render(m.theme, width, height, "Identity & connections", status, body, help) +} + +func (m Model) headerStatus() string { + if m.loading { + return m.theme.Warning.Render("◌ working") + } + if m.err != nil { + return m.theme.Danger.Render("✗ attention required") + } + if m.snapshot.Context.Base == nil && m.snapshot.Context.Console == nil { + return m.theme.Warning.Render("○ setup available") + } + return m.theme.Success.Render("✓ context ready") +} + +func (m Model) profileOverview(width int) string { + gap := 1 + leftWidth := (width - gap) / 2 + rightWidth := width - gap - leftWidth + profiles := page.Panel(m.theme, "Plural App profiles", m.profileLines(), leftWidth, profilePanelHeight, m.panel == 0) + consoles := page.Panel(m.theme, "Console profiles", m.consoleLines(), rightWidth, profilePanelHeight, m.panel == 1) + columns := lipgloss.JoinHorizontal(lipgloss.Top, profiles, " ", consoles) + return columns + "\n\n" + page.Panel(m.theme, "Effective context", m.contextLines(), width, 6, false) +} + +func (m Model) profileLines() []string { + if len(m.snapshot.State.Profiles) == 0 { + return []string{m.theme.Warning.Render("○ Not connected"), m.theme.Muted.Render(" Press n to sign in with a device code.")} + } + lines := make([]string, 0, 2*len(m.snapshot.State.Profiles)) + for i, profile := range m.snapshot.State.Profiles { + cursor := " " + if m.panel == 0 && i == m.appCursor { + cursor = "› " + } + active := "" + if profile.ID == m.snapshot.State.ActiveProfileID { + active = " " + m.theme.Success.Render("ACTIVE") + } + lines = append(lines, cursor+profile.Name+active, " "+m.theme.Muted.Render(profile.Email)) + } + return lines +} + +func (m Model) consoleLines() []string { + if len(m.snapshot.State.ConsoleProfiles) == 0 { + return []string{m.theme.Warning.Render("○ Skipped for now"), m.theme.Muted.Render(" Press c to connect later.")} + } + lines := make([]string, 0, 2*len(m.snapshot.State.ConsoleProfiles)) + for i, profile := range m.snapshot.State.ConsoleProfiles { + cursor := " " + if m.panel == 1 && i == m.consoleCursor { + cursor = "› " + } + active := "" + if profile.ID == m.snapshot.State.ActiveConsoleID { + active = " " + m.theme.Success.Render("ACTIVE") + } + lines = append(lines, cursor+profile.Name+active, " "+m.theme.Muted.Render(profile.URL)) + } + return lines +} + +func (m Model) contextLines() []string { + base, acting, console := "not connected", "self", "not connected" + if m.snapshot.Context.Base != nil { + base = m.snapshot.Context.Base.Email + " via " + m.snapshot.Context.Base.Name + } + if m.snapshot.Context.Acting != nil { + acting = m.theme.Warning.Render(m.snapshot.Context.Acting.Email) + m.theme.Muted.Render(" · session only") + } + if m.snapshot.Context.Console != nil { + console = m.snapshot.Context.Console.Name + " · " + m.snapshot.Context.Console.URL + } + lines := []string{"Base account " + base, "Acting as " + acting, "Console " + console} + if m.err != nil { + lines = append(lines, m.theme.Danger.Render("Error "+m.err.Error())) + } + return lines +} + +func (m Model) accountLines() []string { + if len(m.snapshot.ServiceAccounts) == 0 { + return []string{m.theme.Warning.Render("○ No service accounts available."), m.theme.Muted.Render(" esc returns to profile selection")} + } + lines := make([]string, 0, len(m.snapshot.ServiceAccounts)+1) + for i, account := range m.snapshot.ServiceAccounts { + cursor := " " + if i == m.accountCursor { + cursor = "› " + } + lines = append(lines, cursor+account.Email) + } + lines = append(lines, "", m.theme.Muted.Render("The exchanged credential remains in memory only.")) + return lines +} + +func (m Model) consoleFormLines() []string { + labels := []string{"Name", "URL", "Token"} + lines := []string{m.theme.Muted.Render("Console is optional; press esc to finish setup without it."), ""} + for i := range m.form { + marker := " " + if i == m.formIndex { + marker = "› " + } + lines = append(lines, fmt.Sprintf("%s%-7s %s", marker, labels[i], m.form[i].View())) + } + return lines +} + +func (m Model) deviceLoginLines() []string { + return []string{m.theme.Muted.Render("Open this URL in your browser:"), m.theme.Link.Render(m.authorization.LoginURL), "", m.theme.Warning.Render("◌ Waiting for authorization…"), "", m.theme.Muted.Render("Console can be skipped and connected later from this screen.")} +} diff --git a/tui/screens/diagnostics/golden_test.go b/tui/screens/diagnostics/golden_test.go new file mode 100644 index 000000000..f3fe8a2de --- /dev/null +++ b/tui/screens/diagnostics/golden_test.go @@ -0,0 +1,67 @@ +package diagnostics + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + bridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestDiagnosticsGoldens(t *testing.T) { + snapshot := bridge.Snapshot{ + Version: "v0.13.0", + App: bridge.AppProfile{Configured: true, Name: "personal", Email: "alex@acme.io", Endpoint: "https://app.plural.sh"}, + Console: bridge.ConsoleConnection{Configured: true, URL: "https://console.acme.io"}, + Workspace: bridge.Workspace{Configured: true, Path: "/work/path/to/a/very/long/workspace", Name: "plrl-dev-aws", Provider: "aws", Region: "eu-west-1"}, + KubeContext: "plural-platform-prod", + Diagnostics: []string{"workspace owner does not match the active identity"}, + } + for _, width := range []int{80, 120} { + t.Run(strconv.Itoa(width), func(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model.snapshot = snapshot + height := 24 + if width == 120 { + height = 30 + } + got := normalizeGoldenView(model.View(width, height)) + golden := filepath.Join("testdata", "diagnostics-"+strconv.Itoa(width)+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + assertGoldenDimensions(t, got, width, height) + }) + } +} + +func normalizeGoldenView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} +func assertGoldenDimensions(t *testing.T, view string, width, height int) { + t.Helper() + lines := strings.Split(view, "\n") + if len(lines) != height { + t.Fatalf("view height = %d, want %d", len(lines), height) + } + for _, line := range lines { + if got := lipgloss.Width(line); got > width { + t.Fatalf("line width %d exceeds %d: %q", got, width, line) + } + } +} diff --git a/tui/screens/diagnostics/model.go b/tui/screens/diagnostics/model.go new file mode 100644 index 000000000..2bb1bc4e7 --- /dev/null +++ b/tui/screens/diagnostics/model.go @@ -0,0 +1,78 @@ +// Package diagnostics renders credential-free local context and startup +// diagnostics behind the same loader used by the welcome screen. +package diagnostics + +import ( + "context" + + tea "charm.land/bubbletea/v2" + + welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type loadedMsg struct { + snapshot welcomebridge.Snapshot + err error +} + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionRefresh +) + +var keyActionKeystrokes = map[keyAction]string{ + keyActionBack: "esc", + keyActionRefresh: "r", +} + +func actionForKeystroke(keystroke string) keyAction { + for action, candidate := range keyActionKeystrokes { + if keystroke == candidate { + return action + } + } + + return keyActionNone +} + +type Model struct { + ctx context.Context + loader welcomebridge.Loader + theme theme.Theme + loading bool + snapshot welcomebridge.Snapshot + err error +} + +func New(ctx context.Context, loader welcomebridge.Loader, t theme.Theme) Model { + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil} +} +func (m Model) Init() tea.Cmd { + if m.loader == nil { + return nil + } + return m.load +} +func (m Model) load() tea.Msg { value, err := m.loader.Load(m.ctx); return loadedMsg{value, err} } +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case loadedMsg: + m.loading = false + m.snapshot = msg.snapshot + m.err = msg.err + case tea.KeyPressMsg: + switch actionForKeystroke(msg.Keystroke()) { + case keyActionBack: + return m, navigation.Navigate(navigation.Welcome) + case keyActionRefresh: + m.loading = true + return m, m.load + } + } + return m, nil +} diff --git a/tui/screens/diagnostics/model_test.go b/tui/screens/diagnostics/model_test.go new file mode 100644 index 000000000..1b502f170 --- /dev/null +++ b/tui/screens/diagnostics/model_test.go @@ -0,0 +1,33 @@ +package diagnostics + +import ( + "context" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + + bridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type loaderFunc func(context.Context) (bridge.Snapshot, error) + +func (f loaderFunc) Load(ctx context.Context) (bridge.Snapshot, error) { return f(ctx) } + +func TestDiagnosticsLoadsContextAndReturnsToWelcome(t *testing.T) { + model := New(t.Context(), loaderFunc(func(context.Context) (bridge.Snapshot, error) { + return bridge.Snapshot{App: bridge.AppProfile{Configured: true, Email: "dev@example.com"}, Diagnostics: []string{"workspace: invalid"}}, nil + }), theme.New(colorprofile.ASCII)) + model, _ = model.Update(model.Init()()) + view := model.View(100, 30) + if !strings.Contains(view, "dev@example.com") || !strings.Contains(view, "workspace: invalid") { + t.Fatalf("diagnostics missing context:\n%s", view) + } + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if cmd == nil || cmd().(navigation.NavigateMsg).Route != navigation.Welcome { + t.Fatal("esc did not return to welcome") + } +} diff --git a/tui/screens/diagnostics/testdata/diagnostics-120.golden b/tui/screens/diagnostics/testdata/diagnostics-120.golden new file mode 100644 index 000000000..f04334b6b --- /dev/null +++ b/tui/screens/diagnostics/testdata/diagnostics-120.golden @@ -0,0 +1,30 @@ + Plural Diagnostics ✓ local context ready + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ Local context ──────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Plural App ✓ OK alex@acme.io │ + │ Console ✓ OK https://console.acme.io │ + │ Workspace ✓ OK /work/path/to/a/very/long/workspace │ + │ Kubernetes ✓ OK plural-platform-prod │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ╭─ › Checks ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ! WARN workspace owner does not match the active identity │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + r refresh · esc back · ctrl+c quit diff --git a/tui/screens/diagnostics/testdata/diagnostics-80.golden b/tui/screens/diagnostics/testdata/diagnostics-80.golden new file mode 100644 index 000000000..d07a9a5a5 --- /dev/null +++ b/tui/screens/diagnostics/testdata/diagnostics-80.golden @@ -0,0 +1,24 @@ + Plural Diagnostics ✓ local context ready + ──────────────────────────────────────────────────────────────────────────── + + ╭─ Local context ──────────────────────────────────────────────────────────╮ + │ Plural App ✓ OK alex@acme.io │ + │ Console ✓ OK https://console.acme.io │ + │ Workspace ✓ OK /work/path/to/a/very/long/workspace │ + │ Kubernetes ✓ OK plural-platform-prod │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + ╭─ › Checks ───────────────────────────────────────────────────────────────╮ + │ ! WARN workspace owner does not match the active identity │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + r refresh · esc back · ctrl+c quit diff --git a/tui/screens/diagnostics/view.go b/tui/screens/diagnostics/view.go new file mode 100644 index 000000000..66116c5c2 --- /dev/null +++ b/tui/screens/diagnostics/view.go @@ -0,0 +1,65 @@ +package diagnostics + +import ( + "strings" + + "charm.land/lipgloss/v2" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + status := m.theme.Success.Render("✓ local context ready") + if m.loading { + status = m.theme.Warning.Render("◌ loading") + } + if m.err != nil { + status = m.theme.Danger.Render("✗ load failed") + } + + contextLines, checkLines := m.viewLines() + body := page.Panel(m.theme, "Local context", contextLines, contentWidth, 8, false) + "\n\n" + + page.Panel(m.theme, "Checks", checkLines, contentWidth, 5, len(m.snapshot.Diagnostics) > 0 || m.err != nil) + return page.Render(m.theme, width, height, "Diagnostics", status, body, "r refresh · esc back · ctrl+c quit") +} + +func (m Model) viewLines() ([]string, []string) { + if m.loading { + return []string{m.theme.Warning.Render("◌ Loading credential-free local context…")}, []string{m.theme.Muted.Render("Checks begin after local context loads.")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to read local context")}, []string{m.theme.Danger.Render("Error " + m.err.Error()), m.theme.Muted.Render("Press r to retry.")} + } + contextLines := []string{ + m.contextLine("Plural App", m.snapshot.App.Configured, m.snapshot.App.Email), + m.contextLine("Console", m.snapshot.Console.Configured, m.snapshot.Console.URL), + m.contextLine("Workspace", m.snapshot.Workspace.Configured, m.snapshot.Workspace.Path), + m.contextLine("Kubernetes", m.snapshot.KubeContext != "", m.snapshot.KubeContext), + } + checks := []string{m.theme.Success.Render("✓ No local diagnostics reported")} + if len(m.snapshot.Diagnostics) > 0 { + checks = make([]string, 0, len(m.snapshot.Diagnostics)) + for _, diagnostic := range m.snapshot.Diagnostics { + checks = append(checks, m.theme.Warning.Render("! WARN")+" "+diagnostic) + } + } + return contextLines, checks +} + +func (m Model) contextLine(label string, configured bool, detail string) string { + status := m.theme.Warning.Render("○ NOT CONFIGURED") + if configured { + status = m.theme.Success.Render("✓ OK") + } + if detail == "" { + detail = "—" + } + label += strings.Repeat(" ", max(1, 12-len(label))) + status += strings.Repeat(" ", max(1, 16-lipgloss.Width(status))) + return label + " " + status + " " + detail +} diff --git a/tui/screens/welcome/model.go b/tui/screens/welcome/model.go new file mode 100644 index 000000000..8c0fa8d39 --- /dev/null +++ b/tui/screens/welcome/model.go @@ -0,0 +1,90 @@ +package welcome + +import ( + "context" + + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + + welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + "github.com/pluralsh/plural-cli/tui/components/commandbar" + pluralspinner "github.com/pluralsh/plural-cli/tui/components/spinner" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type loadedMsg struct{ snapshot welcomebridge.Snapshot } +type failedMsg struct{ err error } + +type Model struct { + ctx context.Context + loader welcomebridge.Loader + theme theme.Theme + spinner spinner.Model + command commandbar.Model + loading bool + snapshot welcomebridge.Snapshot + err error +} + +func New(ctx context.Context, loader welcomebridge.Loader, t theme.Theme) Model { + commands := []string{"access", "console", "diagnostics", "help", "profiles", "workspace"} + return Model{ + ctx: ctx, + loader: loader, + theme: t, + spinner: pluralspinner.New(t), + command: commandbar.New(t, commands), + loading: loader != nil, + } +} + +func (m Model) Init() tea.Cmd { + if !m.loading { + return nil + } + return tea.Batch(m.spinner.Tick, m.loadSnapshot) +} + +func (m Model) loadSnapshot() tea.Msg { + snapshot, err := m.loader.Load(m.ctx) + if err != nil { + return failedMsg{err: err} + } + return loadedMsg{snapshot: snapshot} +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case loadedMsg: + m.loading = false + m.snapshot = msg.snapshot + m.err = nil + return m, nil + case failedMsg: + m.loading = false + m.err = msg.err + return m, nil + case spinner.TickMsg: + if !m.loading { + return m, nil + } + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case commandbar.SubmittedMsg: + switch msg.Command { + case "access", "console", "profiles": + return m, navigation.Navigate(navigation.Access) + case "diagnostics", "workspace": + return m, navigation.Navigate(navigation.Diagnostics) + } + return m, nil + default: + var cmd tea.Cmd + m.command, cmd = m.command.Update(msg) + return m, cmd + } +} + +func (m Model) Snapshot() welcomebridge.Snapshot { return m.snapshot } diff --git a/tui/screens/welcome/model_test.go b/tui/screens/welcome/model_test.go new file mode 100644 index 000000000..714e2e9ca --- /dev/null +++ b/tui/screens/welcome/model_test.go @@ -0,0 +1,323 @@ +package welcome + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + bridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + "github.com/pluralsh/plural-cli/tui/assets" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestReadOnlyWelcomeGoldens(t *testing.T) { + snapshot := bridge.Snapshot{ + Version: "v0.13.0", + App: bridge.AppProfile{ + Configured: true, Name: "personal", Email: "alex@acme.io", + Endpoint: "https://app.plural.sh", SavedProfiles: 2, + }, + Console: bridge.ConsoleConnection{Configured: true, URL: "https://console.acme.io"}, + Workspace: bridge.Workspace{ + Configured: true, Path: "/work/path/to/a/very/long/workspace", Name: "plrl-dev-aws", + Project: "acme", Provider: "aws", Region: "eu-west-1", Owner: "sebastian@plural.sh", + }, + KubeContext: "plural-platform-prod", + } + + for _, width := range []int{80, 120} { + t.Run(strconv.Itoa(width), func(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(loadedMsg{snapshot: snapshot}) + height := 24 + if width == 120 { + height = 30 + } + got := normalizeView(model.View(width, height)) + golden := filepath.Join("testdata", "welcome-"+strconv.Itoa(width)+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + if strings.Contains(got, "secret-token") { + t.Fatal("welcome view exposed a credential") + } + }) + } +} + +func TestWelcomeCommandPopupGolden(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(loadedMsg{snapshot: bridge.Snapshot{ + Version: "v0.13.0", + App: bridge.AppProfile{Configured: true, Name: "personal", Email: "alex@acme.io"}, + Console: bridge.ConsoleConnection{Configured: true, URL: "https://console.acme.io"}, + Workspace: bridge.Workspace{Configured: true, Path: "/work/plural", Name: "plrl-dev-aws", Provider: "aws", Region: "eu-west-1", Owner: "alex@acme.io"}, + }}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + got := normalizeView(model.View(80, 24)) + want, err := os.ReadFile(filepath.Join("testdata", "welcome-popup-80.golden")) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + if lines := strings.Split(got, "\n"); len(lines) != 24 { + t.Fatalf("popup view height = %d, want 24", len(lines)) + } +} + +func TestConsoleURLStaysOnOneHighlightedHyperlink(t *testing.T) { + consoleURL := "https://console.production.example.com/deployments/overview" + model := New(t.Context(), nil, theme.New(colorprofile.TrueColor)) + model, _ = model.Update(loadedMsg{snapshot: bridge.Snapshot{ + App: bridge.AppProfile{Configured: true, Email: "alex@example.com", Endpoint: "https://app.plural.sh"}, + Console: bridge.ConsoleConnection{Configured: true, URL: consoleURL}, + }}) + view := model.View(100, 30) + if !strings.Contains(view, "\x1b]8;;"+consoleURL) { + t.Fatalf("console URL is not an OSC-8 hyperlink:\n%q", view) + } + if !strings.Contains(ansi.Strip(view), consoleURL) { + t.Fatalf("console URL was wrapped or truncated:\n%s", ansi.Strip(view)) + } +} + +func TestWorkspacePathUsesEllipsisWhenRightPaneIsNarrow(t *testing.T) { + workspacePath := "/work/path/to/a/very/long/workspace/directory" + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(loadedMsg{snapshot: bridge.Snapshot{ + Workspace: bridge.Workspace{ + Configured: true, + Name: "plrl-dev-aws", + Path: workspacePath, + }, + }}) + + narrow := ansi.Strip(model.View(80, 24)) + if !strings.Contains(narrow, "/work/path/...") { + t.Fatalf("narrow workspace path has no ellipsis:\n%s", narrow) + } + if strings.Contains(narrow, workspacePath) { + t.Fatalf("narrow workspace path was not truncated:\n%s", narrow) + } + + wide := ansi.Strip(model.View(160, 30)) + if !strings.Contains(wide, workspacePath) { + t.Fatalf("wide workspace path did not use available space:\n%s", wide) + } +} + +func TestConnectionGroupsStackWhenURLsNeedTheWidth(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(loadedMsg{snapshot: bridge.Snapshot{ + App: bridge.AppProfile{Configured: true, Endpoint: "https://app.plural.sh"}, + Console: bridge.ConsoleConnection{ + Configured: true, + URL: "https://console.production.example.com/a/long/context/path", + }, + }}) + view := model.View(80, 30) + for _, line := range strings.Split(view, "\n") { + if strings.Contains(line, "Plural App account") && strings.Contains(line, "Console connection") { + t.Fatalf("connection groups did not stack:\n%s", view) + } + } +} + +func TestWelcomeLogoUsesStaticEmbeddedAsset(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.TrueColor)) + got := ansi.Strip(model.logo()) + want := strings.TrimSpace(assets.Logo) + if got != want { + t.Fatalf("welcome logo differs from embedded asset\nwant:\n%s\n\ngot:\n%s", want, got) + } + if lipgloss.Width(got) == 0 || lipgloss.Height(got) == 0 { + t.Fatal("welcome logo is empty") + } +} + +func TestHeroBorderUsesPrimaryColor(t *testing.T) { + theme := theme.New(colorprofile.TrueColor) + model := New(t.Context(), nil, theme) + border := lipgloss.NewStyle().Foreground(theme.Colors.Primary) + + wide := model.renderHero(80, "dev") + if !strings.HasPrefix(wide, border.Render("╭─ ")) { + t.Fatalf("wide hero top border does not use the primary color: %q", wide) + } + bottom := strings.Split(wide, "\n")[8] + if !strings.HasPrefix(bottom, border.Render("╰─ ")) || !strings.Contains(bottom, theme.Muted.Render("dev")) { + t.Fatalf("wide hero bottom border does not use the primary color: %q", wide) + } + +} + +func TestStatusAdaptsToTerminalColorCapability(t *testing.T) { + plain := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + if got := plain.status(true); got != "✓" { + t.Fatalf("plain success status = %q, want tick", got) + } + if got := plain.status(false); got != "✗" { + t.Fatalf("plain failure status = %q, want cross", got) + } + + color := New(t.Context(), nil, theme.New(colorprofile.TrueColor)) + if got := ansi.Strip(color.status(true)); got != "●" { + t.Fatalf("color success status = %q, want dot", got) + } + if got := ansi.Strip(color.status(false)); got != "●" { + t.Fatalf("color failure status = %q, want dot", got) + } +} + +func TestWelcomeForwardsInputToCommandBar(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + if got := model.command.CurrentSuggestion(); got != "diagnostics" { + t.Fatalf("suggestion = %q, want diagnostics", got) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + if got := model.command.Value(); got != "diagnostics" { + t.Fatalf("completed input = %q, want diagnostics", got) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("selecting a command did not emit submission") + } + model, routeCmd := model.Update(cmd()) + if routeCmd == nil { + t.Fatal("submitted command did not route") + } + if got := routeCmd().(navigation.NavigateMsg).Route; got != navigation.Diagnostics { + t.Fatalf("route = %q, want diagnostics", got) + } + if got := model.command.Selected(); got != "diagnostics" { + t.Fatalf("selected command = %q, want diagnostics", got) + } +} + +func TestCommandInputIsAnchoredAtBottom(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + lines := strings.Split(normalizeView(model.View(80, 24)), "\n") + if len(lines) != 24 { + t.Fatalf("view height = %d, want 24", len(lines)) + } + if !strings.Contains(lines[len(lines)-4], "Command") { + t.Fatalf("command bar is not anchored above help:\n%s", strings.Join(lines, "\n")) + } + if !strings.Contains(lines[len(lines)-1], "ctrl+c quit") { + t.Fatalf("keymap is not at the bottom: %q", lines[len(lines)-1]) + } +} + +func TestWideLayoutStretchesRightPaneAndStaysLeftAligned(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(loadedMsg{snapshot: bridge.Snapshot{ + App: bridge.AppProfile{Configured: true, Name: "personal", Email: "alex@example.com"}, + Console: bridge.ConsoleConnection{Configured: true, URL: "https://console.example.com"}, + Workspace: bridge.Workspace{Configured: true, Name: "platform"}, + }}) + + dividerColumn := -1 + for _, width := range []int{80, 160} { + lines := strings.Split(normalizeView(model.View(width, 30)), "\n") + top := []rune(lines[0]) + if len(top) != width-2 { + t.Fatalf("hero line width at %d columns = %d, want %d", width, len(top), width-2) + } + if len(top) < 3 || top[0] != ' ' || top[1] != ' ' || top[2] != '╭' { + t.Fatalf("hero is not anchored at the two-cell left gutter: %q", lines[0]) + } + + body := []rune(lines[1]) + column := -1 + seenOuterBorder := false + for i, r := range body { + if r != '│' { + continue + } + if seenOuterBorder { + column = i + break + } + seenOuterBorder = true + } + if dividerColumn == -1 { + dividerColumn = column + } else if column != dividerColumn { + t.Fatalf("logo rail divider moved from column %d to %d", dividerColumn, column) + } + } +} + +func TestWelcomeRejectsUnsupportedTerminalSize(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + + for _, size := range []struct { + width int + height int + }{ + {width: 79, height: 24}, + {width: 80, height: 23}, + {width: 54, height: 12}, + {width: 80, height: 2}, + } { + view := ansi.Strip(model.View(size.width, size.height)) + if !strings.Contains(view, model.dimensions(size.width, size.height)) { + t.Fatalf("unsupported view does not show detected size %dx%d:\n%s", size.width, size.height, view) + } + if !strings.Contains(view, model.dimensions(minimumWidth, minimumHeight)) { + t.Fatalf("unsupported view does not show minimum size:\n%s", view) + } + if strings.Contains(view, "App not connected") { + t.Fatalf("unsupported terminal rendered the welcome hero:\n%s", view) + } + lines := strings.Split(view, "\n") + if len(lines) != size.height { + t.Fatalf("unsupported view height = %d, want %d", len(lines), size.height) + } + for _, line := range lines { + if got := lipgloss.Width(line); got > size.width { + t.Fatalf("unsupported view line width %d exceeds %d: %q", got, size.width, line) + } + } + } +} + +func TestWelcomeNeverExceedsSupportedTerminalWidth(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(loadedMsg{snapshot: bridge.Snapshot{ + App: bridge.AppProfile{Configured: true, Email: "alex@example.com", Endpoint: "https://app.plural.sh"}, + Console: bridge.ConsoleConnection{ + Configured: true, + URL: "https://console.production.example.com/a/long/context/path", + }, + }}) + for _, width := range []int{80, 100, 120, 160} { + for _, line := range strings.Split(model.View(width, 30), "\n") { + if got := lipgloss.Width(line); got > width { + t.Fatalf("line width %d exceeds terminal width %d: %q", got, width, ansi.Strip(line)) + } + } + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/welcome/testdata/welcome-120.golden b/tui/screens/welcome/testdata/welcome-120.golden new file mode 100644 index 000000000..4a13a1660 --- /dev/null +++ b/tui/screens/welcome/testdata/welcome-120.golden @@ -0,0 +1,30 @@ + ╭─ Plural ───────────────────────────────────────────────────────────────────────────── ✓ personal · alex@acme.io ─╮ + │ │ ✓ Console │ + │ ███████ ██ │ URL https://console.acme.io │ + │ ██ ██ │ ─────────────────────────────────────────────────────────────────────────────────────────── │ + │ ██ ██ ██ │ ✓ Workspace · plrl-dev-aws · /work/path/to/a/very/long/workspace │ + │ ██ ██ │ Provider aws · eu-west-1 │ + │ ██ ███████ │ Owner sebastian@plural.sh │ + │ │ │ + ╰─ v0.13.0 ────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + + + + + + ╭─ Command ────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ › Search commands… │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + tab complete · ↑/↓ suggestions · enter select · esc clear · ctrl+c quit diff --git a/tui/screens/welcome/testdata/welcome-80.golden b/tui/screens/welcome/testdata/welcome-80.golden new file mode 100644 index 000000000..62d680d42 --- /dev/null +++ b/tui/screens/welcome/testdata/welcome-80.golden @@ -0,0 +1,24 @@ + ╭─ Plural ───────────────────────────────────── ✓ personal · alex@acme.io ─╮ + │ │ ✓ Console │ + │ ███████ ██ │ URL https://console.acme.io │ + │ ██ ██ │ ─────────────────────────────────────────────────── │ + │ ██ ██ ██ │ ✓ Workspace · plrl-dev-aws · /work/path/... │ + │ ██ ██ │ Provider aws · eu-west-1 │ + │ ██ ███████ │ Owner sebastian@plural.sh │ + │ │ │ + ╰─ v0.13.0 ────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + ╭─ Command ────────────────────────────────────────────────────────────────╮ + │ › Search commands… │ + ╰──────────────────────────────────────────────────────────────────────────╯ + tab complete · ↑/↓ suggestions · enter select · esc clear · ctrl+c quit diff --git a/tui/screens/welcome/testdata/welcome-popup-80.golden b/tui/screens/welcome/testdata/welcome-popup-80.golden new file mode 100644 index 000000000..77665134c --- /dev/null +++ b/tui/screens/welcome/testdata/welcome-popup-80.golden @@ -0,0 +1,24 @@ + ╭─ Plural ───────────────────────────────────── ✓ personal · alex@acme.io ─╮ + │ │ ✓ Console │ + │ ███████ ██ │ URL https://console.acme.io │ + │ ██ ██ │ ─────────────────────────────────────────────────── │ + │ ██ ██ ██ │ ✓ Workspace · plrl-dev-aws · /work/plural │ + │ ██ ██ │ Provider aws · eu-west-1 │ + │ ██ ███████ │ Owner alex@acme.io │ + │ │ │ + ╰─ v0.13.0 ────────────────────────────────────────────────────────────────╯ + + + + ╭─ Available commands ───────────────╮ + │ › access │ + │ console │ + │ diagnostics │ + │ help │ + │ profiles │ + │ workspace │ + ╰────────────────────────────────────╯ + ╭─ Command ────────────────────────────────────────────────────────────────╮ + │ › Search commands… │ + ╰──────────────────────────────────────────────────────────────────────────╯ + ↑/↓ choose · enter open · esc close · type to filter · ctrl+c quit diff --git a/tui/screens/welcome/view.go b/tui/screens/welcome/view.go new file mode 100644 index 000000000..9d005b057 --- /dev/null +++ b/tui/screens/welcome/view.go @@ -0,0 +1,273 @@ +package welcome + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/tui/assets" +) + +const ( + defaultWidth = 100 + defaultHeight = 24 + minimumWidth = 80 + minimumHeight = 24 + sideMargin = 2 + minimumVerticalGap = 1 + defaultVerticalGap = 2 + heroDetailRows = 7 + logoRailWidth = 20 + logoIndent = 4 +) + +func (m Model) View(width, height int) string { + width, height = m.viewportSize(width, height) + if width < minimumWidth || height < minimumHeight { + return m.renderUnsupportedTerminal(width, height) + } + + contentWidth := width - 2*sideMargin + version := lo.CoalesceOrEmpty(m.snapshot.Version, "dev") + header := m.renderHero(contentWidth, version) + command := m.command.View(contentWidth) + gap := m.verticalGap(height, header, command) + + return m.indent(header+strings.Repeat("\n", gap)+command, sideMargin) +} + +func (m Model) viewportSize(width, height int) (int, int) { + if width <= 0 { + width = defaultWidth + } + + if height <= 0 { + height = defaultHeight + } + + return width, height +} + +func (m Model) verticalGap(height int, blocks ...string) int { + if height <= 0 { + return defaultVerticalGap + } + + occupied := 0 + for _, block := range blocks { + occupied += lipgloss.Height(block) + } + + // Joining two blocks with newlines consumes one fewer row than summing + // their individual heights. + return max(minimumVerticalGap, height-occupied+len(blocks)-1) +} + +func (m Model) indent(content string, width int) string { + padding := strings.Repeat(" ", width) + return padding + strings.ReplaceAll(content, "\n", "\n"+padding) +} + +func (m Model) renderUnsupportedTerminal(width, height int) string { + detected := "Unsupported terminal size: " + m.dimensions(width, height) + required := "Minimum supported size: " + m.dimensions(minimumWidth, minimumHeight) + + if height < 4 { + message := "Unsupported " + m.dimensions(width, height) + " · minimum " + m.dimensions(minimumWidth, minimumHeight) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, ansi.Truncate(message, width, "…")) + } + + content := strings.Join([]string{ + m.theme.Title.Render(ansi.Truncate("Plural", width, "…")), + "", + m.theme.Body.Render(ansi.Truncate(detected, width, "…")), + m.theme.Muted.Render(ansi.Truncate(required, width, "…")), + }, "\n") + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content) +} + +func (m Model) dimensions(width, height int) string { return fmt.Sprintf("%d×%d", width, height) } + +// renderHero draws the full welcome treatment. The logo rail remains fixed +// while the connection details consume all additional width. +func (m Model) renderHero(width int, version string) string { + border := m.primaryBorder() + top := m.renderHeroTop(width, border) + rightWidth := width - 2 - logoRailWidth - 1 + rightLines := m.heroDetails(rightWidth - 2) + leftLines := m.logoRail(len(rightLines)) + + rows := make([]string, 0, len(rightLines)+2) + rows = append(rows, top) + for i, rightLine := range rightLines { + left := m.fit(leftLines[i], logoRailWidth) + right := m.fit(rightLine, rightWidth-2) + rows = append(rows, border.Render("│")+left+border.Render("│")+" "+right+" "+border.Render("│")) + } + + rows = append(rows, m.renderVersionFooter(width, version, border, m.theme.Muted)) + return strings.Join(rows, "\n") +} + +func (m Model) renderHeroTop(width int, border lipgloss.Style) string { + const trailingRuleWidth = 1 + + title := m.theme.Title.Render("Plural") + titleRail := "─ " + title + " " + identityWidth := min(44, max(16, width-lipgloss.Width(titleRail)-trailingRuleWidth-7)) + identity := m.heroIdentity(identityWidth) + identityRail := " " + identity + " " + strings.Repeat("─", trailingRuleWidth) + middleRuleWidth := max(1, width-2-lipgloss.Width(titleRail)-lipgloss.Width(identityRail)) + + return border.Render("╭─ ") + title + border.Render(" "+strings.Repeat("─", middleRuleWidth)) + + " " + identity + " " + border.Render(strings.Repeat("─", trailingRuleWidth)+"╮") +} + +func (m Model) renderVersionFooter(width int, version string, border, versionStyle lipgloss.Style) string { + ruleWidth := max(1, width-lipgloss.Width(version)-5) + return border.Render("╰─ ") + versionStyle.Render(version) + + border.Render(" "+strings.Repeat("─", ruleWidth)+"╯") +} + +func (m Model) logoRail(rowCount int) []string { + rows := make([]string, rowCount) + for i, line := range strings.Split(strings.TrimSpace(assets.Logo), "\n") { + row := i + 1 + if row >= rowCount { + break + } + rows[row] = strings.Repeat(" ", logoIndent) + m.theme.Logo.Render(line) + } + return rows +} + +func (m Model) heroIdentity(maxWidth int) string { + if !m.snapshot.App.Configured { + return m.status(false) + " App not connected" + } + profile := lo.CoalesceOrEmpty(m.snapshot.App.Name, "default") + email := lo.CoalesceOrEmpty(m.snapshot.App.Email, m.snapshot.App.Name, "saved account") + display := ansi.Truncate(profile+" · "+email, max(1, maxWidth-2), "…") + return m.status(true) + " " + display +} + +func (m Model) heroConsole() string { + if !m.snapshot.Console.Configured { + return m.status(false) + " Console not connected" + } + return m.status(true) + m.theme.Title.Render(" Console") +} + +func (m Model) heroDetails(maxWidth int) []string { + if m.loading { + return m.padLines([]string{ + m.theme.Body.Render("Local context"), + m.spinner.View() + " " + m.theme.Muted.Render("Loading…"), + }, heroDetailRows) + } + if m.err != nil { + return m.padLines([]string{ + m.theme.Body.Render("Local context"), + m.theme.Danger.Render(m.err.Error()), + }, heroDetailRows) + } + + lines := []string{ + m.heroConsole(), + m.heroConsoleURL(maxWidth), + m.theme.Title.Render(strings.Repeat("─", max(1, maxWidth))), + } + lines = append(lines, m.workspaceDetails(maxWidth)...) + return m.padLines(lines, heroDetailRows) +} + +func (m Model) workspaceDetails(maxWidth int) []string { + workspace := m.snapshot.Workspace + if !workspace.Configured { + return []string{m.status(false) + " No workspace detected"} + } + + name := lo.CoalesceOrEmpty(workspace.Name, m.filepathBase(workspace.Path)) + prefix := m.status(true) + m.theme.Title.Render(" Workspace ") + "· " + name + " · " + pathWidth := max(1, maxWidth-lipgloss.Width(prefix)) + if maxWidth < 60 { + pathWidth = min(pathWidth, 14) + } + provider := lo.CoalesceOrEmpty(strings.Join(lo.Compact([]string{workspace.Provider, workspace.Region}), " · "), "—") + + return []string{ + prefix + m.theme.Muted.Render(ansi.Truncate(workspace.Path, pathWidth, "...")), + "Provider " + provider, + "Owner " + lo.CoalesceOrEmpty(workspace.Owner, "—"), + "", + } +} + +func (m Model) heroConsoleURL(maxWidth int) string { + if !m.snapshot.Console.Configured { + return "" + } + prefix := "URL " + if lipgloss.Width(prefix)+lipgloss.Width(m.snapshot.Console.URL) > maxWidth { + prefix = "URL " + } + return prefix + m.url(m.snapshot.Console.URL, max(1, maxWidth-lipgloss.Width(prefix))) +} + +func (m Model) status(ok bool) string { + if m.theme.Color { + if ok { + return m.theme.Success.Render("●") + } + return m.theme.Danger.Render("●") + } + if ok { + return "✓" + } + return "✗" +} + +func (m Model) url(target string, maxWidth int) string { + if target == "" { + return m.theme.Muted.Render("unknown") + } + display := ansi.Truncate(target, max(1, maxWidth), "…") + style := m.theme.Link.Inline(true) + if m.theme.Hyperlinks { + style = style.Hyperlink(target) + } + return style.Render(display) +} + +func (m Model) primaryBorder() lipgloss.Style { + return lipgloss.NewStyle().Foreground(m.theme.Colors.Primary) +} + +// logo is deliberately static. Animation is reserved for the compact spinner +// used while work is in progress, never for the welcome-screen brand mark. +func (m Model) logo() string { + return m.theme.Logo.Render(strings.TrimSpace(assets.Logo)) +} + +func (m Model) fit(value string, width int) string { + value = ansi.Truncate(value, max(1, width), "…") + return value + strings.Repeat(" ", max(0, width-lipgloss.Width(value))) +} + +func (m Model) padLines(lines []string, count int) []string { + if len(lines) >= count { + return lines + } + return append(lines, make([]string, count-len(lines))...) +} + +func (m Model) filepathBase(path string) string { + path = strings.TrimRight(path, "/\\") + if i := strings.LastIndexAny(path, "/\\"); i >= 0 { + return path[i+1:] + } + return path +} diff --git a/tui/theme/testdata/ansi16.golden b/tui/theme/testdata/ansi16.golden new file mode 100644 index 000000000..5e949b55b --- /dev/null +++ b/tui/theme/testdata/ansi16.golden @@ -0,0 +1 @@ +"\x1b[1;94mPlural\x1b[m\n\x1b[97mterminal operations\x1b[m\n\x1b[37mmuted\x1b[m\n\x1b[92msuccess\x1b[m\n\x1b[93mwarning\x1b[m\n\x1b[91mdanger\x1b[m" diff --git a/tui/theme/testdata/ansi256.golden b/tui/theme/testdata/ansi256.golden new file mode 100644 index 000000000..112551f03 --- /dev/null +++ b/tui/theme/testdata/ansi256.golden @@ -0,0 +1 @@ +"\x1b[1;38;5;105mPlural\x1b[m\n\x1b[38;5;255mterminal operations\x1b[m\n\x1b[38;5;248mmuted\x1b[m\n\x1b[38;5;85msuccess\x1b[m\n\x1b[38;5;228mwarning\x1b[m\n\x1b[38;5;210mdanger\x1b[m" diff --git a/tui/theme/testdata/no-color.golden b/tui/theme/testdata/no-color.golden new file mode 100644 index 000000000..05dca2f3a --- /dev/null +++ b/tui/theme/testdata/no-color.golden @@ -0,0 +1 @@ +"Plural\nterminal operations\nmuted\nsuccess\nwarning\ndanger" diff --git a/tui/theme/testdata/truecolor.golden b/tui/theme/testdata/truecolor.golden new file mode 100644 index 000000000..59327b626 --- /dev/null +++ b/tui/theme/testdata/truecolor.golden @@ -0,0 +1 @@ +"\x1b[1;38;2;116;122;246mPlural\x1b[m\n\x1b[38;2;238;240;241mterminal operations\x1b[m\n\x1b[38;2;161;165;176mmuted\x1b[m\n\x1b[38;2;60;236;175msuccess\x1b[m\n\x1b[38;2;255;244;143mwarning\x1b[m\n\x1b[38;2;242;120;141mdanger\x1b[m" diff --git a/tui/theme/theme.go b/tui/theme/theme.go new file mode 100644 index 000000000..58651c225 --- /dev/null +++ b/tui/theme/theme.go @@ -0,0 +1,98 @@ +// Package theme owns the semantic terminal palette used by TUI screens. Raw +// Console colors stop here so screens can describe intent instead of styling. +package theme + +import ( + "image/color" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" +) + +// Colors is the small semantic subset needed by the shell. It is derived from +// Console's dark semantic palette and Cloud Shell accents. +type Colors struct { + Background color.Color + Surface color.Color + Border color.Color + Text color.Color + Muted color.Color + Primary color.Color + Info color.Color + Success color.Color + Warning color.Color + Danger color.Color +} + +// Theme bundles semantic colors and reusable shell styles. +type Theme struct { + Colors Colors + Logo lipgloss.Style + Title lipgloss.Style + Body lipgloss.Style + Muted lipgloss.Style + Success lipgloss.Style + Warning lipgloss.Style + Danger lipgloss.Style + Link lipgloss.Style + Color bool + Hyperlinks bool +} + +// New down-samples the Console palette to the terminal's supported profile. +// ASCII is also the explicit NO_COLOR representation. +func New(profile colorprofile.Profile) Theme { + resolve := func(hex string) color.Color { + if profile <= colorprofile.ASCII { + return lipgloss.NoColor{} + } + return profile.Convert(lipgloss.Color(hex)) + } + + colors := Colors{ + Background: resolve("#12151B"), // fill-zero + Surface: resolve("#1B1F27"), // fill-one + Border: resolve("#252932"), // border + Text: resolve("#EEF0F1"), // text + Muted: resolve("#A1A5B0"), // text-xlight + Primary: resolve("#747AF6"), // icon-primary + Info: resolve("#99DAFF"), // semanticBlue + Success: resolve("#3CECAF"), // cloud-shell-green + Warning: resolve("#FFF48F"), // cloud-shell-dark-yellow + Danger: resolve("#F2788D"), // cloud-shell-dark-red + } + + title := lipgloss.NewStyle().Foreground(colors.Primary) + link := lipgloss.NewStyle().Foreground(colors.Info) + if profile > colorprofile.ASCII { + title = title.Bold(true) + link = link.Underline(true) + } + + return Theme{ + Colors: colors, + Title: title, + Logo: lipgloss.NewStyle().Foreground(colors.Text), + Body: lipgloss.NewStyle().Foreground(colors.Text), + Muted: lipgloss.NewStyle().Foreground(colors.Muted), + Success: lipgloss.NewStyle().Foreground(colors.Success), + Warning: lipgloss.NewStyle().Foreground(colors.Warning), + Danger: lipgloss.NewStyle().Foreground(colors.Danger), + Link: link, + Color: profile > colorprofile.ASCII, + Hyperlinks: profile > colorprofile.ASCII, + } +} + +// Sample renders a stable palette specimen used by golden tests. +func (t Theme) Sample() string { + return strings.Join([]string{ + t.Title.Render("Plural"), + t.Body.Render("terminal operations"), + t.Muted.Render("muted"), + t.Success.Render("success"), + t.Warning.Render("warning"), + t.Danger.Render("danger"), + }, "\n") +} diff --git a/tui/theme/theme_test.go b/tui/theme/theme_test.go new file mode 100644 index 000000000..7777005c8 --- /dev/null +++ b/tui/theme/theme_test.go @@ -0,0 +1,37 @@ +package theme + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/charmbracelet/colorprofile" +) + +func TestThemeGoldens(t *testing.T) { + tests := []struct { + name string + profile colorprofile.Profile + }{ + {name: "truecolor", profile: colorprofile.TrueColor}, + {name: "ansi256", profile: colorprofile.ANSI256}, + {name: "ansi16", profile: colorprofile.ANSI}, + {name: "no-color", profile: colorprofile.ASCII}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := strconv.Quote(New(tt.profile).Sample()) + path := filepath.Join("testdata", tt.name+".golden") + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read golden: %v\nactual: %q", err, got) + } + if got != strings.TrimSpace(string(want)) { + t.Fatalf("theme output differs from %s\nwant: %q\n got: %q", path, want, got) + } + }) + } +} From e93d66fcdf433b26b54f3126a2f21a411d54b092 Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Wed, 29 Jul 2026 11:27:36 +0200 Subject: [PATCH 02/16] add services --- cmd/command/tui/tui.go | 8 +- pkg/bridge/access/access.go | 37 ++ pkg/bridge/access/access_test.go | 32 ++ pkg/bridge/access/local.go | 42 ++ pkg/bridge/access/local_test.go | 27 ++ pkg/bridge/services/errors.go | 10 + pkg/bridge/services/services.go | 303 +++++++++++++ pkg/bridge/services/services_test.go | 118 +++++ tui/app/model.go | 13 +- tui/app/model_test.go | 5 + tui/app/view.go | 2 + tui/components/commandbar/commandbar.go | 2 +- tui/navigation/navigation.go | 1 + tui/screens/access/model_test.go | 3 + tui/screens/services/golden_test.go | 149 +++++++ tui/screens/services/model.go | 411 ++++++++++++++++++ tui/screens/services/model_test.go | 123 ++++++ .../testdata/services-clusters-120.golden | 30 ++ .../testdata/services-clusters-80.golden | 24 + .../testdata/services-detail-120.golden | 30 ++ .../testdata/services-detail-80.golden | 24 + .../testdata/services-list-120.golden | 30 ++ .../services/testdata/services-list-80.golden | 24 + tui/screens/services/view.go | 259 +++++++++++ tui/screens/welcome/model.go | 4 +- .../welcome/testdata/welcome-popup-80.golden | 2 +- 26 files changed, 1707 insertions(+), 6 deletions(-) create mode 100644 pkg/bridge/services/errors.go create mode 100644 pkg/bridge/services/services.go create mode 100644 pkg/bridge/services/services_test.go create mode 100644 tui/screens/services/golden_test.go create mode 100644 tui/screens/services/model.go create mode 100644 tui/screens/services/model_test.go create mode 100644 tui/screens/services/testdata/services-clusters-120.golden create mode 100644 tui/screens/services/testdata/services-clusters-80.golden create mode 100644 tui/screens/services/testdata/services-detail-120.golden create mode 100644 tui/screens/services/testdata/services-detail-80.golden create mode 100644 tui/screens/services/testdata/services-list-120.golden create mode 100644 tui/screens/services/testdata/services-list-80.golden create mode 100644 tui/screens/services/view.go diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go index 7aeb871ca..e9cffa658 100644 --- a/cmd/command/tui/tui.go +++ b/cmd/command/tui/tui.go @@ -8,6 +8,7 @@ import ( "github.com/pluralsh/plural-cli/pkg/bridge" accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" "github.com/pluralsh/plural-cli/pkg/common" tuiapp "github.com/pluralsh/plural-cli/tui/app" @@ -19,7 +20,12 @@ func Command() cli.Command { welcome := welcomebridge.NewService(welcomebridge.NewLocalSource(common.Version)) auth := bridge.NewAuthService(bridge.PluralAuthFactory{}, 0) access := accessbridge.NewLocalManager("", auth, nil) - return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{Welcome: welcome, Access: access}) + services := servicesbridge.NewService(access) + return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{ + Welcome: welcome, + Access: access, + Services: services, + }) }) } diff --git a/pkg/bridge/access/access.go b/pkg/bridge/access/access.go index afd939f40..935883a3d 100644 --- a/pkg/bridge/access/access.go +++ b/pkg/bridge/access/access.go @@ -10,6 +10,7 @@ import ( "sync" "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" ) type Profile = bridge.Profile @@ -55,6 +56,7 @@ type Manager interface { AddConsoleProfile(context.Context, string, string, string) (ConsoleProfile, error) ActivateProfile(context.Context, string) error ActivateConsole(context.Context, string) error + ActiveConsole(context.Context) (url, token string, err error) SearchServiceAccounts(context.Context, string) ([]ServiceAccount, error) Impersonate(context.Context, string) error StopImpersonating() @@ -189,6 +191,32 @@ func (s *Service) ActivateConsole(ctx context.Context, id string) error { return s.repository.Save(ctx, state) } +// ActiveConsole resolves the active Console URL and token for API clients. +// It prefers the Access registry, then falls back to legacy console.yml so +// existing plural cd login sessions continue to work. +func (s *Service) ActiveConsole(ctx context.Context) (url, token string, err error) { + if err := ctx.Err(); err != nil { + return "", "", err + } + state, err := s.repository.Load(ctx) + if err != nil { + return "", "", err + } + if profile, ok := findConsole(state.ConsoleProfiles, state.ActiveConsoleID); ok && s.credentials != nil { + token, err := s.credentials.Get(ctx, profile.ID) + if err == nil && strings.TrimSpace(token) != "" && strings.TrimSpace(profile.URL) != "" { + return profile.URL, token, nil + } + } + if url, token, ok := readLegacyConsole(); ok { + return url, token, nil + } + return "", "", &bridge.Error{ + Code: bridge.ErrorUnauthenticated, + Err: errors.New("connect a Console profile before browsing Console resources"), + } +} + func (s *Service) SearchServiceAccounts(ctx context.Context, query string) ([]ServiceAccount, error) { snapshot, err := s.Load(ctx) if err != nil || snapshot.Context.Base == nil { @@ -285,3 +313,12 @@ func stableID(parts ...string) string { } return fmt.Sprintf("%s-%x", parts[0], hash) } + +// readLegacyConsole loads ~/.plural/console.yml for callers that have not +// migrated into the Access registry yet. Tests may replace it. +var readLegacyConsole = func() (url, token string, ok bool) { + conf := console.ReadConfig() + url = strings.TrimSpace(conf.Url) + token = strings.TrimSpace(conf.Token) + return url, token, url != "" && token != "" +} diff --git a/pkg/bridge/access/access_test.go b/pkg/bridge/access/access_test.go index 7415fc4c4..3290e5ceb 100644 --- a/pkg/bridge/access/access_test.go +++ b/pkg/bridge/access/access_test.go @@ -103,6 +103,38 @@ func TestAccessProfilesSwitchIndependentlyAndClearActingIdentity(t *testing.T) { } } +func TestActiveConsolePrefersRegistryThenLegacy(t *testing.T) { + repository := &memoryAccessRepository{state: State{ + ConsoleProfiles: []ConsoleProfile{{ID: "console-a", Name: "production", URL: "https://console.example.com"}}, + ActiveConsoleID: "console-a", + }} + credentials := &memoryCredentials{values: map[string]string{"console-a": "registry-token"}} + service := NewService(repository, credentials, nil, nil) + + url, token, err := service.ActiveConsole(t.Context()) + if err != nil { + t.Fatalf("ActiveConsole() error = %v", err) + } + if url != "https://console.example.com" || token != "registry-token" { + t.Fatalf("ActiveConsole() = %q, %q", url, token) + } + + empty := NewService(&memoryAccessRepository{}, &memoryCredentials{}, nil, nil) + original := readLegacyConsole + t.Cleanup(func() { readLegacyConsole = original }) + readLegacyConsole = func() (string, string, bool) { return "https://legacy.example.com", "legacy-token", true } + url, token, err = empty.ActiveConsole(t.Context()) + if err != nil || url != "https://legacy.example.com" || token != "legacy-token" { + t.Fatalf("legacy ActiveConsole() = %q, %q, %v", url, token, err) + } + + readLegacyConsole = func() (string, string, bool) { return "", "", false } + _, _, err = empty.ActiveConsole(t.Context()) + if !bridge.IsCode(err, bridge.ErrorUnauthenticated) { + t.Fatalf("missing console error = %v", err) + } +} + func TestCompleteDeviceLoginStoresBaseCredentialOutsideMetadata(t *testing.T) { repository := &memoryAccessRepository{} credentials := &memoryCredentials{} diff --git a/pkg/bridge/access/local.go b/pkg/bridge/access/local.go index 29cb2569a..019b39ad0 100644 --- a/pkg/bridge/access/local.go +++ b/pkg/bridge/access/local.go @@ -42,6 +42,15 @@ func (r *LocalRepository) Load(ctx context.Context) (State, error) { if err := yaml.Unmarshal(contents, &state); err != nil { return State{}, err } + state, changed, err := r.ensureLegacyConsole(ctx, state) + if err != nil { + return State{}, err + } + if changed { + if err := r.save(state); err != nil { + return State{}, err + } + } return state, nil } if !errors.Is(err, os.ErrNotExist) { @@ -176,6 +185,39 @@ func (r *LocalRepository) importLegacy(ctx context.Context) (State, error) { return state, nil } +// ensureLegacyConsole imports ~/.plural/console.yml when the Access registry +// has no usable Console profile yet (common after plural cd login while +// access.yml already existed from App login). +func (r *LocalRepository) ensureLegacyConsole(ctx context.Context, state State) (State, bool, error) { + if _, ok := findConsole(state.ConsoleProfiles, state.ActiveConsoleID); ok { + return state, false, nil + } + contents, err := os.ReadFile(filepath.Join(r.Dir, "console.yml")) + if errors.Is(err, os.ErrNotExist) { + if state.ActiveConsoleID != "" && len(state.ConsoleProfiles) == 0 { + state.ActiveConsoleID = "" + return state, true, nil + } + return state, false, nil + } + if err != nil { + return state, false, err + } + var legacy legacyConsoleConfig + if yaml.Unmarshal(contents, &legacy) != nil || legacy.Spec.URL == "" { + return state, false, nil + } + profile := ConsoleProfile{ID: stableID("console", "default", legacy.Spec.URL), Name: "default", URL: legacy.Spec.URL} + state.ConsoleProfiles = upsertConsole(state.ConsoleProfiles, profile) + state.ActiveConsoleID = profile.ID + if legacy.Spec.Token != "" && r.Credentials != nil { + if err := r.Credentials.Set(ctx, profile.ID, legacy.Spec.Token); err != nil { + return state, false, err + } + } + return state, true, nil +} + // NewLocalManager builds the production persistence stack. Callers can // still inject every boundary separately in tests or alternate frontends. func NewLocalManager(home string, auth *bridge.AuthService, serviceAccounts ServiceAccountSource) *Service { diff --git a/pkg/bridge/access/local_test.go b/pkg/bridge/access/local_test.go index 145349433..9ac9a5d9a 100644 --- a/pkg/bridge/access/local_test.go +++ b/pkg/bridge/access/local_test.go @@ -44,6 +44,33 @@ func TestLocalAccessRepositoryImportsLegacyProfilesOnce(t *testing.T) { } } +func TestLocalAccessRepositoryImportsLegacyConsoleIntoExistingRegistry(t *testing.T) { + home := t.TempDir() + dir := filepath.Join(home, ".plural") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, accessRegistryName), []byte("profiles: []\nconsoleProfiles: []\nactiveConsole: https://stale.example/gql\n"), 0600); err != nil { + t.Fatal(err) + } + legacy := "apiVersion: platform.plural.sh/v1alpha1\nkind: Console\nspec:\n url: https://console.example.com/gql\n token: console-secret\n" + if err := os.WriteFile(filepath.Join(dir, "console.yml"), []byte(legacy), 0600); err != nil { + t.Fatal(err) + } + credentials := &memoryCredentials{} + repository := NewLocalRepository(home, credentials) + state, err := repository.Load(t.Context()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if len(state.ConsoleProfiles) != 1 || state.ActiveConsoleID == "" || state.ActiveConsoleID == "https://stale.example/gql" { + t.Fatalf("state = %#v", state) + } + if credentials.values[state.ActiveConsoleID] != "console-secret" { + t.Fatalf("stored credential = %q", credentials.values[state.ActiveConsoleID]) + } +} + func containsSecret(value, secret string) bool { for i := 0; i+len(secret) <= len(value); i++ { if value[i:i+len(secret)] == secret { diff --git a/pkg/bridge/services/errors.go b/pkg/bridge/services/errors.go new file mode 100644 index 000000000..f748f17e6 --- /dev/null +++ b/pkg/bridge/services/errors.go @@ -0,0 +1,10 @@ +package services + +import "errors" + +var ( + errNoConsole = errors.New("connect a Console profile before browsing Console resources") + errMissingID = errors.New("service id is required") + errMissingCluster = errors.New("cluster id is required") + errMissingService = errors.New("service was not found") +) diff --git a/pkg/bridge/services/services.go b/pkg/bridge/services/services.go new file mode 100644 index 000000000..af64bf5a1 --- /dev/null +++ b/pkg/bridge/services/services.go @@ -0,0 +1,303 @@ +// Package services exposes read-only Console service list/get use cases to +// presentation layers without importing TUI code. +package services + +import ( + "context" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +const defaultPageSize int64 = 50 + +// Cluster is a credential-free Console cluster summary. +type Cluster struct { + ID string + Name string + Handle string +} + +// Summary is the credential-free list row for a service deployment. +type Summary struct { + ID string + Name string + Namespace string + Status string + GitRef string + GitFolder string +} + +// ServiceError is a redacted Console component/sync error. +type ServiceError struct { + Source string + Message string +} + +// Detail is the credential-free detail payload for a service deployment. +type Detail struct { + Summary + ClusterName string + ClusterHandle string + RevisionSHA string + RevisionRef string + Components int + Synced int + Errors []ServiceError +} + +// Page is one cursor page of service summaries for a single cluster. +type Page struct { + Items []Summary + EndCursor string + HasNext bool + TotalShown int +} + +// Loader is the narrow contract consumed by the Services screen. +type Loader interface { + ListClusters(ctx context.Context, query string) ([]Cluster, error) + List(ctx context.Context, clusterID string, after *string, query string) (Page, error) + Get(ctx context.Context, id string) (Detail, error) +} + +// ConsoleResolver supplies the active Console URL and token. +type ConsoleResolver interface { + ActiveConsole(ctx context.Context) (url, token string, err error) +} + +// API is the Console surface required by this package. +type API interface { + ListClusters() (*gqlclient.ListClusters, error) + ListClusterServices(clusterId, handle *string) ([]*gqlclient.ServiceDeploymentEdgeFragment, error) + GetClusterService(serviceId, serviceName, clusterName *string) (*gqlclient.ServiceDeploymentExtended, error) +} + +// ClientFactory builds a Console API for an authenticated endpoint. +type ClientFactory func(token, url string) (API, error) + +// Service implements Loader against Console GraphQL. +type Service struct { + resolve ConsoleResolver + newClient ClientFactory + pageSize int64 +} + +// NewService wires production Console credentials and client construction. +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + pageSize: defaultPageSize, + } +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s.resolve == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + url, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return nil, err + } + factory := s.newClient + if factory == nil { + factory = func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + } + } + return factory(token, url) +} + +func (s *Service) ListClusters(ctx context.Context, query string) ([]Cluster, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + client, err := s.client(ctx) + if err != nil { + return nil, err + } + result, err := client.ListClusters() + if err != nil { + return nil, err + } + if result == nil || result.Clusters == nil { + return nil, nil + } + clusters := make([]Cluster, 0, len(result.Clusters.Edges)) + for _, edge := range result.Clusters.Edges { + if edge == nil || edge.Node == nil { + continue + } + cluster := Cluster{ID: edge.Node.ID, Name: edge.Node.Name} + if edge.Node.Handle != nil { + cluster.Handle = *edge.Node.Handle + } + if !matchesCluster(cluster, query) { + continue + } + clusters = append(clusters, cluster) + } + return clusters, nil +} + +func (s *Service) List(ctx context.Context, clusterID string, after *string, query string) (Page, error) { + if err := ctx.Err(); err != nil { + return Page{}, err + } + clusterID = strings.TrimSpace(clusterID) + if clusterID == "" { + return Page{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingCluster} + } + client, err := s.client(ctx) + if err != nil { + return Page{}, err + } + edges, err := client.ListClusterServices(&clusterID, nil) + if err != nil { + return Page{}, err + } + items := make([]Summary, 0, len(edges)) + for _, edge := range edges { + if edge == nil || edge.Node == nil { + continue + } + summary := summaryFromBase(edge.Node) + if !matchesQuery(summary, query) { + continue + } + items = append(items, summary) + } + return pageItems(items, after, s.pageSize), nil +} + +func (s *Service) Get(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + service, err := client.GetClusterService(&id, nil, nil) + if err != nil { + return Detail{}, err + } + if service == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService} + } + return detailFromExtended(service), nil +} + +func pageItems(items []Summary, after *string, pageSize int64) Page { + if pageSize <= 0 { + pageSize = defaultPageSize + } + start := 0 + if after != nil && *after != "" { + for i, item := range items { + if item.ID == *after { + start = i + 1 + break + } + } + } + if start > len(items) { + start = len(items) + } + end := start + int(pageSize) + if end > len(items) { + end = len(items) + } + page := Page{Items: items[start:end], TotalShown: end - start, HasNext: end < len(items)} + if len(page.Items) > 0 { + page.EndCursor = page.Items[len(page.Items)-1].ID + } + return page +} + +func summaryFromBase(node *gqlclient.ServiceDeploymentBaseFragment) Summary { + summary := Summary{ + ID: node.ID, + Name: node.Name, + Namespace: node.Namespace, + Status: string(node.Status), + } + if node.Git != nil { + summary.GitRef = node.Git.Ref + summary.GitFolder = node.Git.Folder + } + return summary +} + +func detailFromExtended(service *gqlclient.ServiceDeploymentExtended) Detail { + detail := Detail{ + Summary: Summary{ + ID: service.ID, + Name: service.Name, + Namespace: service.Namespace, + Status: string(service.Status), + }, + Components: len(service.Components), + } + if service.Git != nil { + detail.GitRef = service.Git.Ref + detail.GitFolder = service.Git.Folder + } + if service.Cluster != nil { + detail.ClusterName = service.Cluster.Name + if service.Cluster.Handle != nil { + detail.ClusterHandle = *service.Cluster.Handle + } + } + if service.Revision != nil { + if service.Revision.Sha != nil { + detail.RevisionSHA = *service.Revision.Sha + } + if detail.RevisionSHA == "" { + detail.RevisionSHA = service.Revision.ID + } + if service.Revision.Git != nil { + detail.RevisionRef = service.Revision.Git.Ref + } + } + for _, component := range service.Components { + if component != nil && component.Synced { + detail.Synced++ + } + } + for _, item := range service.Errors { + if item == nil { + continue + } + detail.Errors = append(detail.Errors, ServiceError{Source: item.Source, Message: item.Message}) + } + return detail +} + +func matchesQuery(summary Summary, query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{summary.Name, summary.Namespace, summary.Status, summary.GitRef, summary.GitFolder}, " ")) + return strings.Contains(haystack, query) +} + +func matchesCluster(cluster Cluster, query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{cluster.Name, cluster.Handle, cluster.ID}, " ")) + return strings.Contains(haystack, query) +} diff --git a/pkg/bridge/services/services_test.go b/pkg/bridge/services/services_test.go new file mode 100644 index 000000000..29dc6bef6 --- /dev/null +++ b/pkg/bridge/services/services_test.go @@ -0,0 +1,118 @@ +package services + +import ( + "context" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeResolver struct { + url, token string + err error +} + +func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) { + return f.url, f.token, f.err +} + +type fakeAPI struct { + clusters *gqlclient.ListClusters + edges []*gqlclient.ServiceDeploymentEdgeFragment + listErr error + detail *gqlclient.ServiceDeploymentExtended + getErr error + clusterID string +} + +func (f *fakeAPI) ListClusters() (*gqlclient.ListClusters, error) { + return f.clusters, f.listErr +} +func (f *fakeAPI) ListClusterServices(clusterId, _ *string) ([]*gqlclient.ServiceDeploymentEdgeFragment, error) { + if clusterId != nil { + f.clusterID = *clusterId + } + return f.edges, f.listErr +} +func (f *fakeAPI) GetClusterService(*string, *string, *string) (*gqlclient.ServiceDeploymentExtended, error) { + return f.detail, f.getErr +} + +func TestListClustersAndScopedServices(t *testing.T) { + handle := "prod-eu" + api := &fakeAPI{ + clusters: &gqlclient.ListClusters{Clusters: &gqlclient.ListClusters_Clusters{Edges: []*gqlclient.ClusterEdgeFragment{ + {Node: &gqlclient.ClusterFragment{ID: "c1", Name: "production", Handle: &handle}}, + {Node: &gqlclient.ClusterFragment{ID: "c2", Name: "staging"}}, + }}}, + edges: []*gqlclient.ServiceDeploymentEdgeFragment{ + {Node: &gqlclient.ServiceDeploymentBaseFragment{ID: "1", Name: "api", Namespace: "default", Status: gqlclient.ServiceDeploymentStatusHealthy}}, + {Node: &gqlclient.ServiceDeploymentBaseFragment{ID: "2", Name: "worker", Namespace: "jobs", Status: gqlclient.ServiceDeploymentStatusFailed}}, + }, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + } + + clusters, err := service.ListClusters(t.Context(), "prod") + if err != nil || len(clusters) != 1 || clusters[0].Handle != "prod-eu" { + t.Fatalf("ListClusters() = %#v, %v", clusters, err) + } + + page, err := service.List(t.Context(), "c1", nil, "api") + if err != nil { + t.Fatalf("List() error = %v", err) + } + if api.clusterID != "c1" || len(page.Items) != 1 || page.Items[0].Name != "api" { + t.Fatalf("scoped page = %#v cluster=%q", page.Items, api.clusterID) + } +} + +func TestListRequiresCluster(t *testing.T) { + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return &fakeAPI{}, nil }, + } + _, err := service.List(t.Context(), "", nil, "") + if !bridge.IsCode(err, bridge.ErrorInvalid) { + t.Fatalf("List() error = %v", err) + } +} + +func TestGetMapsDetail(t *testing.T) { + handle := "prod-eu" + sha := "abc123" + api := &fakeAPI{detail: &gqlclient.ServiceDeploymentExtended{ + ID: "svc-1", Name: "api", Namespace: "default", Status: gqlclient.ServiceDeploymentStatusFailed, + Git: &gqlclient.GitRefFragment{Ref: "main", Folder: "services/api"}, + Cluster: &gqlclient.BaseClusterFragment{Name: "prod", Handle: &handle}, + Revision: &gqlclient.RevisionFragment{ID: "rev-1", Sha: &sha, Git: &gqlclient.RevisionFragment_Git{Ref: "main"}}, + Components: []*gqlclient.ServiceDeploymentExtended_Components{ + {Synced: true}, + {Synced: false}, + }, + Errors: []*gqlclient.ErrorFragment{{Source: "sync", Message: "rollout timed out"}}, + }} + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + } + detail, err := service.Get(t.Context(), "svc-1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if detail.ClusterHandle != "prod-eu" || detail.RevisionSHA != "abc123" || detail.Components != 2 || detail.Synced != 1 { + t.Fatalf("detail = %#v", detail) + } +} + +func TestMissingConsoleReturnsTypedError(t *testing.T) { + service := NewService(fakeResolver{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole}}) + _, err := service.ListClusters(t.Context(), "") + if !bridge.IsCode(err, bridge.ErrorUnauthenticated) { + t.Fatalf("ListClusters() error = %v", err) + } +} diff --git a/tui/app/model.go b/tui/app/model.go index 2ec377f55..a690ef772 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -8,18 +8,21 @@ import ( tea "charm.land/bubbletea/v2" accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" "github.com/pluralsh/plural-cli/tui/navigation" accessscreen "github.com/pluralsh/plural-cli/tui/screens/access" diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" + servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" welcomescreen "github.com/pluralsh/plural-cli/tui/screens/welcome" "github.com/pluralsh/plural-cli/tui/theme" ) // Dependencies contains the services required by TUI screens. type Dependencies struct { - Welcome welcomebridge.Loader - Access accessbridge.Manager + Welcome welcomebridge.Loader + Access accessbridge.Manager + Services servicesbridge.Loader } // Model is the root TUI model. It owns global input and delegates screen state @@ -34,6 +37,7 @@ type Model struct { welcome welcomescreen.Model access accessscreen.Model diagnostics diagnosticsscreen.Model + services servicesscreen.Model route navigation.Route } @@ -44,6 +48,7 @@ func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { welcome: welcomescreen.New(ctx, dependencies.Welcome, t), access: accessscreen.New(ctx, dependencies.Access, t), diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t), + services: servicesscreen.New(ctx, dependencies.Services, t), route: navigation.Welcome, quit: key.NewBinding( key.WithKeys("ctrl+c"), @@ -63,6 +68,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.access.Init() case navigation.Diagnostics: return m, m.diagnostics.Init() + case navigation.Services: + return m, m.services.Init() default: return m, m.welcome.Init() } @@ -86,6 +93,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.access, cmd = m.access.Update(msg) case navigation.Diagnostics: m.diagnostics, cmd = m.diagnostics.Update(msg) + case navigation.Services: + m.services, cmd = m.services.Update(msg) default: m.welcome, cmd = m.welcome.Update(msg) } diff --git a/tui/app/model_test.go b/tui/app/model_test.go index 365edcf26..f9f32d9ad 100644 --- a/tui/app/model_test.go +++ b/tui/app/model_test.go @@ -45,6 +45,11 @@ func TestModelRoutesScreensWithoutRebuildingShell(t *testing.T) { if routed.route != navigation.Diagnostics || !strings.Contains(routed.View().Content, "Diagnostics") { t.Fatalf("route/view = %q\n%s", routed.route, routed.View().Content) } + updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Services}) + routed = updated.(Model) + if routed.route != navigation.Services || !strings.Contains(routed.View().Content, "Services") { + t.Fatalf("services route/view = %q\n%s", routed.route, routed.View().Content) + } updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Welcome}) if got := updated.(Model).route; got != navigation.Welcome { t.Fatalf("route = %q", got) diff --git a/tui/app/view.go b/tui/app/view.go index ec87c4c8f..87b8bc3a5 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -15,6 +15,8 @@ func (m Model) View() tea.View { content = m.access.View(m.width, m.height) case navigation.Diagnostics: content = m.diagnostics.View(m.width, m.height) + case navigation.Services: + content = m.services.View(m.width, m.height) } view := tea.NewView(content) view.AltScreen = true diff --git a/tui/components/commandbar/commandbar.go b/tui/components/commandbar/commandbar.go index 5cd48c570..36d94b026 100644 --- a/tui/components/commandbar/commandbar.go +++ b/tui/components/commandbar/commandbar.go @@ -18,7 +18,7 @@ const ( minimumWidth = 12 title = "Command" popupTitle = "Available commands" - maximumPopupRows = 6 + maximumPopupRows = 8 ) type keyAction uint8 diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index 186ed074f..070df9281 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -12,6 +12,7 @@ const ( Welcome Route = "welcome" Access Route = "access" Diagnostics Route = "diagnostics" + Services Route = "services" ) // NavigateMsg requests a top-level route change. diff --git a/tui/screens/access/model_test.go b/tui/screens/access/model_test.go index 6ca922d07..b7c5f4328 100644 --- a/tui/screens/access/model_test.go +++ b/tui/screens/access/model_test.go @@ -37,6 +37,9 @@ func (f *fakeManager) ActivateConsole(_ context.Context, id string) error { f.activatedConsole = id return nil } +func (f *fakeManager) ActiveConsole(context.Context) (string, string, error) { + return "https://console.example.com", "token", nil +} func (f *fakeManager) SearchServiceAccounts(context.Context, string) ([]accessbridge.ServiceAccount, error) { return []accessbridge.ServiceAccount{{ID: "sa", Email: "deploy@example.com"}}, nil } diff --git a/tui/screens/services/golden_test.go b/tui/screens/services/golden_test.go new file mode 100644 index 000000000..08f7dc94d --- /dev/null +++ b/tui/screens/services/golden_test.go @@ -0,0 +1,149 @@ +package services + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestServicesGoldens(t *testing.T) { + clusters := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + clusters.loading = false + clusters.mode = modeClusters + clusters.clusters = []servicesbridge.Cluster{ + {ID: "c1", Name: "production", Handle: "prod-eu"}, + {ID: "c2", Name: "staging", Handle: "staging"}, + {ID: "c3", Name: "edge"}, + } + + list := clusters + list.mode = modeList + list.cluster = clusters.clusters[0] + list.page = servicesbridge.Page{Items: []servicesbridge.Summary{ + {ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY", GitRef: "main", GitFolder: "services/api"}, + {ID: "2", Name: "worker", Namespace: "jobs", Status: "FAILED", GitRef: "main", GitFolder: "services/worker"}, + {ID: "3", Name: "canary", Namespace: "default", Status: "SYNCED", GitRef: "release", GitFolder: "services/canary"}, + }, HasNext: true, EndCursor: "cursor-1"} + + detail := list + detail.mode = modeDetail + detail.detail = servicesbridge.Detail{ + Summary: servicesbridge.Summary{ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY", GitRef: "main", GitFolder: "services/api"}, + ClusterHandle: "prod-eu", + ClusterName: "production", + RevisionSHA: "91ca21f0deadbeef", + RevisionRef: "main", + Components: 14, + Synced: 13, + Errors: []servicesbridge.ServiceError{{Source: "Deployment/api", Message: "exceeded rollout deadline"}}, + } + + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"clusters-80", clusters, 80, 24}, + {"clusters-120", clusters, 120, 30}, + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeGoldenView(tc.model.View(tc.width, tc.height)) + golden := filepath.Join("testdata", "services-"+tc.name+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + assertGoldenDimensions(t, got, tc.width, tc.height) + }) + } +} + +func normalizeGoldenView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} + +func assertGoldenDimensions(t *testing.T, view string, width, height int) { + t.Helper() + lines := strings.Split(view, "\n") + if len(lines) != height { + t.Fatalf("view height = %d, want %d", len(lines), height) + } + for _, line := range lines { + if got := lipgloss.Width(line); got > width { + t.Fatalf("line width %d exceeds %d: %q", got, width, line) + } + } +} + +func TestWriteServicesGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + clusters := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + clusters.loading = false + clusters.mode = modeClusters + clusters.clusters = []servicesbridge.Cluster{ + {ID: "c1", Name: "production", Handle: "prod-eu"}, + {ID: "c2", Name: "staging", Handle: "staging"}, + {ID: "c3", Name: "edge"}, + } + list := clusters + list.mode = modeList + list.cluster = clusters.clusters[0] + list.page = servicesbridge.Page{Items: []servicesbridge.Summary{ + {ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY", GitRef: "main", GitFolder: "services/api"}, + {ID: "2", Name: "worker", Namespace: "jobs", Status: "FAILED", GitRef: "main", GitFolder: "services/worker"}, + {ID: "3", Name: "canary", Namespace: "default", Status: "SYNCED", GitRef: "release", GitFolder: "services/canary"}, + }, HasNext: true, EndCursor: "cursor-1"} + detail := list + detail.mode = modeDetail + detail.detail = servicesbridge.Detail{ + Summary: servicesbridge.Summary{ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY", GitRef: "main", GitFolder: "services/api"}, + ClusterHandle: "prod-eu", + ClusterName: "production", + RevisionSHA: "91ca21f0deadbeef", + RevisionRef: "main", + Components: 14, + Synced: 13, + Errors: []servicesbridge.ServiceError{{Source: "Deployment/api", Message: "exceeded rollout deadline"}}, + } + _ = os.MkdirAll("testdata", 0o755) + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"clusters-80", clusters, 80, 24}, + {"clusters-120", clusters, 120, 30}, + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + got := normalizeGoldenView(tc.model.View(tc.width, tc.height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "services-"+tc.name+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} diff --git a/tui/screens/services/model.go b/tui/screens/services/model.go new file mode 100644 index 000000000..fa3b3e818 --- /dev/null +++ b/tui/screens/services/model.go @@ -0,0 +1,411 @@ +// Package services implements the Phase 2 read-only Console services browser. +package services + +import ( + "context" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeClusters mode = iota + modeList + modeDetail + modeFilter +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionFilter + keyActionNextPage + keyActionPrevPage + keyActionConnectConsole +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionRefresh: {"r"}, + keyActionFilter: {"/"}, + keyActionNextPage: {"n", "right"}, + keyActionPrevPage: {"p", "left"}, + keyActionConnectConsole: {"c"}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + return keyActionNone +} + +type initMsg struct{} +type clustersMsg struct { + clusters []servicesbridge.Cluster + err error + request uint64 +} +type listedMsg struct { + page servicesbridge.Page + err error + request uint64 +} +type detailMsg struct { + detail servicesbridge.Detail + err error + request uint64 +} + +// Model owns only Services-screen interaction state. +type Model struct { + ctx context.Context + loader servicesbridge.Loader + theme theme.Theme + mode mode + loading bool + err error + needsAuth bool + + clusters []servicesbridge.Cluster + clusterCursor int + cluster servicesbridge.Cluster + clusterFilter string + serviceFilter string + filterInput textinput.Model + filteringCluster bool + + page servicesbridge.Page + cursor int + after *string + prevCursors []string + request uint64 + + detail servicesbridge.Detail + detailID string + listCursor int + listAfter *string + listFilter string + listPrev []string +} + +func New(ctx context.Context, loader servicesbridge.Loader, t theme.Theme) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "filter" + input.CharLimit = 128 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + input.SetStyles(styles) + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, mode: modeClusters} +} + +func (m Model) Init() tea.Cmd { + return func() tea.Msg { return initMsg{} } +} + +func (m *Model) beginClusters() tea.Cmd { + m.loading = true + m.request++ + request := m.request + query := m.clusterFilter + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + clusters, err := loader.ListClusters(ctx, query) + return clustersMsg{clusters: clusters, err: err, request: request} + } +} + +func (m *Model) beginList(after *string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + query := m.serviceFilter + clusterID := m.cluster.ID + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + page, err := loader.List(ctx, clusterID, after, query) + return listedMsg{page: page, err: err, request: request} + } +} + +func (m *Model) beginDetail(id string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + detail, err := loader.Get(ctx, id) + return detailMsg{detail: detail, err: err, request: request} + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.mode = modeClusters + m.cluster = servicesbridge.Cluster{} + m.clusters = nil + m.clusterCursor = 0 + m.page = servicesbridge.Page{} + m.after = nil + m.prevCursors = nil + m.cursor = 0 + m.err = nil + m.needsAuth = false + if m.loader == nil { + m.loading = false + return m, nil + } + return m, m.beginClusters() + case clustersMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.clusters = msg.clusters + m.clusterCursor = clampCursor(m.clusterCursor, len(m.clusters)) + m.mode = modeClusters + } + return m, nil + case listedMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.page = msg.page + m.cursor = clampCursor(m.cursor, len(m.page.Items)) + m.mode = modeList + } + return m, nil + case detailMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.detail = msg.detail + m.mode = modeDetail + } + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeFilter { + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + if m.mode == modeFilter { + switch action { + case keyActionBack: + m.mode = modeList + if m.filteringCluster { + m.mode = modeClusters + } + m.filterInput.Blur() + return m, nil + case keyActionConfirm: + value := strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + if m.filteringCluster { + m.clusterFilter = value + m.mode = modeClusters + m.clusterCursor = 0 + return m, m.beginClusters() + } + m.serviceFilter = value + m.mode = modeList + m.after = nil + m.prevCursors = nil + m.cursor = 0 + return m, m.beginList(nil) + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd + } + if action == keyActionBack { + switch m.mode { + case modeDetail: + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.after = m.listAfter + m.serviceFilter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) + return m, nil + case modeList: + m.mode = modeClusters + m.page = servicesbridge.Page{} + m.cluster = servicesbridge.Cluster{} + m.err = nil + m.after = nil + m.prevCursors = nil + return m, nil + default: + return m, navigation.Navigate(navigation.Welcome) + } + } + if m.loading { + return m, nil + } + if m.needsAuth && action == keyActionConnectConsole { + return m, navigation.Navigate(navigation.Access) + } + if m.mode == modeDetail { + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + return m, nil + } + if m.mode == modeClusters { + return m.updateClusters(action) + } + return m.updateList(action) +} + +func (m Model) updateClusters(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionMoveUp: + m.clusterCursor = clampCursor(m.clusterCursor-1, len(m.clusters)) + case keyActionMoveDown: + m.clusterCursor = clampCursor(m.clusterCursor+1, len(m.clusters)) + case keyActionConfirm: + if len(m.clusters) == 0 { + return m, nil + } + m.cluster = m.clusters[m.clusterCursor] + m.serviceFilter = "" + m.after = nil + m.prevCursors = nil + m.cursor = 0 + return m, m.beginList(nil) + case keyActionRefresh: + return m, m.beginClusters() + case keyActionFilter: + m.mode = modeFilter + m.filteringCluster = true + m.filterInput.Placeholder = "filter clusters" + m.filterInput.SetValue(m.clusterFilter) + m.filterInput.Focus() + } + return m, nil +} + +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionMoveUp: + m.cursor = clampCursor(m.cursor-1, len(m.page.Items)) + case keyActionMoveDown: + m.cursor = clampCursor(m.cursor+1, len(m.page.Items)) + case keyActionConfirm: + if len(m.page.Items) == 0 { + return m, nil + } + m.listCursor = m.cursor + m.listAfter = m.after + m.listFilter = m.serviceFilter + m.listPrev = append([]string(nil), m.prevCursors...) + m.detailID = m.page.Items[m.cursor].ID + return m, m.beginDetail(m.detailID) + case keyActionRefresh: + return m, m.beginList(m.after) + case keyActionFilter: + m.mode = modeFilter + m.filteringCluster = false + m.filterInput.Placeholder = "filter services" + m.filterInput.SetValue(m.serviceFilter) + m.filterInput.Focus() + case keyActionNextPage: + if !m.page.HasNext || m.page.EndCursor == "" { + return m, nil + } + if m.after != nil { + m.prevCursors = append(m.prevCursors, *m.after) + } else { + m.prevCursors = append(m.prevCursors, "") + } + cursor := m.page.EndCursor + m.after = &cursor + m.cursor = 0 + return m, m.beginList(m.after) + case keyActionPrevPage: + if len(m.prevCursors) == 0 { + return m, nil + } + previous := m.prevCursors[len(m.prevCursors)-1] + m.prevCursors = m.prevCursors[:len(m.prevCursors)-1] + if previous == "" { + m.after = nil + } else { + m.after = &previous + } + m.cursor = 0 + return m, m.beginList(m.after) + } + return m, nil +} + +func clampCursor(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} + +func clusterLabel(cluster servicesbridge.Cluster) string { + if cluster.Handle != "" { + return "@" + cluster.Handle + } + if cluster.Name != "" { + return cluster.Name + } + return cluster.ID +} diff --git a/tui/screens/services/model_test.go b/tui/screens/services/model_test.go new file mode 100644 index 000000000..09d2c2091 --- /dev/null +++ b/tui/screens/services/model_test.go @@ -0,0 +1,123 @@ +package services + +import ( + "context" + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + + "github.com/pluralsh/plural-cli/pkg/bridge" + servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + clusters []servicesbridge.Cluster + page servicesbridge.Page + detail servicesbridge.Detail + err error + listedID string +} + +func (f *fakeLoader) ListClusters(context.Context, string) ([]servicesbridge.Cluster, error) { + return f.clusters, f.err +} +func (f *fakeLoader) List(_ context.Context, clusterID string, _ *string, _ string) (servicesbridge.Page, error) { + f.listedID = clusterID + return f.page, f.err +} +func (f *fakeLoader) Get(context.Context, string) (servicesbridge.Detail, error) { + return f.detail, f.err +} + +func loadClusters(t *testing.T, model Model) Model { + t.Helper() + cmd := model.Init() + model, cmd = model.Update(cmd()) + if cmd == nil { + t.Fatal("expected clusters command") + } + model, _ = model.Update(cmd()) + return model +} + +func TestSelectClusterThenOpenService(t *testing.T) { + loader := &fakeLoader{ + clusters: []servicesbridge.Cluster{ + {ID: "c1", Name: "production", Handle: "prod-eu"}, + {ID: "c2", Name: "staging", Handle: "staging"}, + }, + page: servicesbridge.Page{Items: []servicesbridge.Summary{ + {ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY"}, + }}, + detail: servicesbridge.Detail{ + Summary: servicesbridge.Summary{ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY"}, + ClusterHandle: "prod-eu", + }, + } + model := loadClusters(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if model.mode != modeClusters || len(model.clusters) != 2 { + t.Fatalf("clusters state = mode=%d count=%d", model.mode, len(model.clusters)) + } + view := model.View(80, 24) + if !strings.Contains(view, "@prod-eu") || !strings.Contains(view, "Choose a cluster") { + t.Fatalf("cluster view missing handle:\n%s", view) + } + + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("enter did not load services") + } + model, _ = model.Update(cmd()) + if model.mode != modeList || loader.listedID != "c1" || len(model.page.Items) != 1 { + t.Fatalf("list state = mode=%d id=%q items=%d", model.mode, loader.listedID, len(model.page.Items)) + } + if !strings.Contains(model.View(80, 24), "@prod-eu") { + t.Fatalf("service list missing cluster label:\n%s", model.View(80, 24)) + } + + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail || model.detail.ClusterHandle != "prod-eu" { + t.Fatalf("detail state = %#v", model.detail) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeList { + t.Fatalf("mode after detail esc = %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeClusters { + t.Fatalf("mode after list esc = %d", model.mode) + } +} + +func TestNoConsoleNavigatesToAccess(t *testing.T) { + loader := &fakeLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} + model := loadClusters(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !model.needsAuth { + t.Fatal("expected needsAuth") + } + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c'}) + if cmd == nil { + t.Fatal("expected access navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Access}) { + t.Fatalf("msg = %#v", msg) + } +} + +func TestBackFromClustersReturnsWelcome(t *testing.T) { + model := loadClusters(t, New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII))) + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil { + t.Fatal("expected welcome navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Welcome}) { + t.Fatalf("msg = %#v", msg) + } +} diff --git a/tui/screens/services/testdata/services-clusters-120.golden b/tui/screens/services/testdata/services-clusters-120.golden new file mode 100644 index 000000000..1095f872a --- /dev/null +++ b/tui/screens/services/testdata/services-clusters-120.golden @@ -0,0 +1,30 @@ + Plural Services 3 clusters + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Choose a cluster ─────────────────────────────────────────────────────────────────────────────────────────────╮ + │ HANDLE NAME ID │ + │ › @prod-eu production c1 │ + │ @staging staging c2 │ + │ — edge c3 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · enter open cluster · / filter · r refresh · esc back diff --git a/tui/screens/services/testdata/services-clusters-80.golden b/tui/screens/services/testdata/services-clusters-80.golden new file mode 100644 index 000000000..e3a532052 --- /dev/null +++ b/tui/screens/services/testdata/services-clusters-80.golden @@ -0,0 +1,24 @@ + Plural Services 3 clusters + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Choose a cluster ─────────────────────────────────────────────────────╮ + │ HANDLE NAME ID │ + │ › @prod-eu production c1 │ + │ @staging staging c2 │ + │ — edge c3 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / filter · esc back diff --git a/tui/screens/services/testdata/services-detail-120.golden b/tui/screens/services/testdata/services-detail-120.golden new file mode 100644 index 000000000..fe9d6e42d --- /dev/null +++ b/tui/screens/services/testdata/services-detail-120.golden @@ -0,0 +1,30 @@ + Plural Services HEALTHY + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › api ──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Status HEALTHY │ + │ Namespace default │ + │ Cluster prod-eu · production │ + │ Revision main · 91ca21f0 │ + │ Git main / services/api │ + │ Components 13 / 14 synced │ + │ │ + │ Errors │ + │ ✗ Deployment/api exceeded rollout deadline │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + r refresh · esc back · ctrl+c quit diff --git a/tui/screens/services/testdata/services-detail-80.golden b/tui/screens/services/testdata/services-detail-80.golden new file mode 100644 index 000000000..3b6813cbd --- /dev/null +++ b/tui/screens/services/testdata/services-detail-80.golden @@ -0,0 +1,24 @@ + Plural Services HEALTHY + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › api ──────────────────────────────────────────────────────────────────╮ + │ Status HEALTHY │ + │ Namespace default │ + │ Cluster prod-eu · production │ + │ Revision main · 91ca21f0 │ + │ Git main / services/api │ + │ Components 13 / 14 synced │ + │ │ + │ Errors │ + │ ✗ Deployment/api exceeded rollout deadline │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + r refresh · esc back · ctrl+c quit diff --git a/tui/screens/services/testdata/services-list-120.golden b/tui/screens/services/testdata/services-list-120.golden new file mode 100644 index 000000000..a4eb8fbd8 --- /dev/null +++ b/tui/screens/services/testdata/services-list-120.golden @@ -0,0 +1,30 @@ + Plural Services 3 services · @prod-eu + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Services · @prod-eu ──────────────────────────────────────────────────────────────────────────────────────────╮ + │ NAME NAMESPACE STATUS GIT │ + │ › api default HEALTHY main services/api │ + │ worker jobs FAILED main services/worker │ + │ canary default SYNCED release services/canary │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · enter open · / filter · n/p page · r refresh · esc clusters diff --git a/tui/screens/services/testdata/services-list-80.golden b/tui/screens/services/testdata/services-list-80.golden new file mode 100644 index 000000000..ac83618ff --- /dev/null +++ b/tui/screens/services/testdata/services-list-80.golden @@ -0,0 +1,24 @@ + Plural Services 3 services · @prod-eu + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Services · @prod-eu ──────────────────────────────────────────────────╮ + │ NAME NAMESPACE STATUS GIT │ + │ › api default HEALTHY main services/api │ + │ worker jobs FAILED main services/wor… │ + │ canary default SYNCED release services/… │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / filter · n/p page · esc clusters diff --git a/tui/screens/services/view.go b/tui/screens/services/view.go new file mode 100644 index 000000000..d27ce4e89 --- /dev/null +++ b/tui/screens/services/view.go @@ -0,0 +1,259 @@ +package services + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + status := m.headerStatus() + body, help := m.bodyAndHelp(contentWidth) + return page.Render(m.theme, width, height, "Services", status, body, help) +} + +func (m Model) headerStatus() string { + if m.loading { + return m.theme.Warning.Render("◌ loading") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + if m.err != nil { + return m.theme.Danger.Render("✗ load failed") + } + switch m.mode { + case modeDetail: + return m.statusBadge(m.detail.Status) + case modeList: + if m.serviceFilter != "" { + return m.theme.Muted.Render(fmt.Sprintf("%d matching · %s", len(m.page.Items), clusterLabel(m.cluster))) + } + return m.theme.Success.Render(fmt.Sprintf("%d services · %s", len(m.page.Items), clusterLabel(m.cluster))) + case modeClusters: + if m.clusterFilter != "" { + return m.theme.Muted.Render(fmt.Sprintf("%d matching clusters", len(m.clusters))) + } + return m.theme.Success.Render(fmt.Sprintf("%d clusters", len(m.clusters))) + default: + return m.theme.Muted.Render("select cluster") + } +} + +func (m Model) bodyAndHelp(width int) (string, string) { + if m.mode == modeFilter { + title := "Filter services" + hint := "Filter by name, namespace, status, or git path." + if m.filteringCluster { + title = "Filter clusters" + hint = "Filter by handle, name, or id." + } + lines := []string{m.theme.Muted.Render(hint), "", m.filterInput.View()} + return page.Panel(m.theme, title, lines, width, 6, true), "enter apply · esc cancel" + } + if m.needsAuth { + lines := []string{ + m.theme.Warning.Render("○ Console is not connected"), + m.theme.Muted.Render(" Connect a Console profile to browse services."), + "", + m.theme.Body.Render("Press c to open Access."), + } + return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" + } + if m.mode == modeDetail { + return page.Panel(m.theme, m.detail.Name, m.detailLines(), width, 14, true), "r refresh · esc back · ctrl+c quit" + } + if m.mode == modeClusters { + help := "↑/↓ select · enter open cluster · / filter · r refresh · esc back" + if width < 100 { + help = "↑/↓ · enter · / filter · esc back" + } + return page.Panel(m.theme, m.clusterTitle(), m.clusterLines(width), width, 14, true), help + } + help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc clusters" + if width < 100 { + help = "↑/↓ · enter · / filter · n/p page · esc clusters" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help +} + +func (m Model) clusterTitle() string { + if m.clusterFilter != "" { + return "Clusters · filter “" + m.clusterFilter + "”" + } + return "Choose a cluster" +} + +func (m Model) listTitle() string { + title := "Services · " + clusterLabel(m.cluster) + if m.serviceFilter != "" { + title += " · filter “" + m.serviceFilter + "”" + } + return title +} + +func (m Model) clusterLines(width int) []string { + if m.loading && len(m.clusters) == 0 { + return []string{m.theme.Warning.Render("◌ Loading clusters…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load clusters"), m.theme.Danger.Render("Error " + m.err.Error()), m.theme.Muted.Render("Press r to retry.")} + } + if len(m.clusters) == 0 { + return []string{m.theme.Warning.Render("○ No clusters found"), m.theme.Muted.Render(" Adjust the filter or connect another Console.")} + } + handleWidth := max(12, min(24, width/3)) + lines := []string{m.theme.Muted.Render(" " + pad("HANDLE", handleWidth) + " " + pad("NAME", 24) + " ID")} + for i, cluster := range m.clusters { + cursor := " " + if i == m.clusterCursor { + cursor = "› " + } + handle := cluster.Handle + if handle == "" { + handle = "—" + } else { + handle = "@" + handle + } + row := cursor + pad(handle, handleWidth) + " " + pad(cluster.Name, 24) + " " + cluster.ID + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + return lines +} + +func (m Model) listLines(width int) []string { + if m.loading && len(m.page.Items) == 0 { + return []string{m.theme.Warning.Render("◌ Loading services for " + clusterLabel(m.cluster) + "…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load services"), m.theme.Danger.Render("Error " + m.err.Error()), m.theme.Muted.Render("Press r to retry.")} + } + if len(m.page.Items) == 0 { + return []string{m.theme.Warning.Render("○ No services found"), m.theme.Muted.Render(" Adjust the filter or choose another cluster.")} + } + nameWidth := max(12, min(28, width/3)) + lines := []string{m.theme.Muted.Render(" " + pad("NAME", nameWidth) + " " + pad("NAMESPACE", 14) + " " + pad("STATUS", 10) + " GIT")} + for i, item := range m.page.Items { + cursor := " " + if i == m.cursor { + cursor = "› " + } + git := strings.TrimSpace(item.GitRef + " " + item.GitFolder) + if git == "" { + git = "—" + } + row := cursor + pad(item.Name, nameWidth) + " " + pad(item.Namespace, 14) + " " + pad(item.Status, 10) + " " + git + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + if m.page.HasNext || len(m.prevCursors) > 0 { + pager := "page" + if len(m.prevCursors) > 0 { + pager += " · p prev" + } + if m.page.HasNext { + pager += " · n next" + } + lines = append(lines, "", m.theme.Muted.Render(pager)) + } + return lines +} + +func (m Model) detailLines() []string { + if m.loading { + return []string{m.theme.Warning.Render("◌ Loading service detail…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load service"), m.theme.Danger.Render(m.err.Error())} + } + cluster := m.detail.ClusterName + if m.detail.ClusterHandle != "" { + cluster = m.detail.ClusterHandle + if m.detail.ClusterName != "" && m.detail.ClusterName != m.detail.ClusterHandle { + cluster = m.detail.ClusterHandle + " · " + m.detail.ClusterName + } + } + if cluster == "" { + cluster = clusterLabel(m.cluster) + } + revision := m.detail.RevisionSHA + if m.detail.RevisionRef != "" { + revision = m.detail.RevisionRef + if m.detail.RevisionSHA != "" { + revision += " · " + shortSHA(m.detail.RevisionSHA) + } + } + if revision == "" { + revision = "—" + } + git := strings.TrimSpace(m.detail.GitRef + " / " + m.detail.GitFolder) + if git == " / " || git == "" { + git = "—" + } + lines := []string{ + m.labelValue("Status", m.statusBadge(m.detail.Status)), + m.labelValue("Namespace", m.detail.Namespace), + m.labelValue("Cluster", cluster), + m.labelValue("Revision", revision), + m.labelValue("Git", git), + m.labelValue("Components", fmt.Sprintf("%d / %d synced", m.detail.Synced, m.detail.Components)), + "", + } + if len(m.detail.Errors) == 0 { + lines = append(lines, m.theme.Success.Render("✓ No service errors")) + return lines + } + lines = append(lines, m.theme.Danger.Render("Errors")) + for _, item := range m.detail.Errors { + source := item.Source + if source == "" { + source = "sync" + } + lines = append(lines, m.theme.Danger.Render("✗ "+source)+" "+item.Message) + } + return lines +} + +func (m Model) labelValue(label, value string) string { + label = label + strings.Repeat(" ", max(1, 12-len(label))) + return label + " " + value +} + +func (m Model) statusBadge(status string) string { + switch strings.ToUpper(status) { + case "HEALTHY", "SYNCED": + return m.theme.Success.Render(status) + case "FAILED": + return m.theme.Danger.Render(status) + case "PAUSED", "STALE": + return m.theme.Warning.Render(status) + default: + if status == "" { + return m.theme.Muted.Render("UNKNOWN") + } + return m.theme.Muted.Render(status) + } +} + +func pad(value string, width int) string { + value = ansi.Truncate(value, width, "…") + if lipgloss.Width(value) >= width { + return value + } + return value + strings.Repeat(" ", width-lipgloss.Width(value)) +} + +func shortSHA(value string) string { + if len(value) > 8 { + return value[:8] + } + return value +} diff --git a/tui/screens/welcome/model.go b/tui/screens/welcome/model.go index 8c0fa8d39..6da587715 100644 --- a/tui/screens/welcome/model.go +++ b/tui/screens/welcome/model.go @@ -28,7 +28,7 @@ type Model struct { } func New(ctx context.Context, loader welcomebridge.Loader, t theme.Theme) Model { - commands := []string{"access", "console", "diagnostics", "help", "profiles", "workspace"} + commands := []string{"access", "console", "diagnostics", "help", "profiles", "services", "workspace"} return Model{ ctx: ctx, loader: loader, @@ -78,6 +78,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m, navigation.Navigate(navigation.Access) case "diagnostics", "workspace": return m, navigation.Navigate(navigation.Diagnostics) + case "services": + return m, navigation.Navigate(navigation.Services) } return m, nil default: diff --git a/tui/screens/welcome/testdata/welcome-popup-80.golden b/tui/screens/welcome/testdata/welcome-popup-80.golden index 77665134c..870246618 100644 --- a/tui/screens/welcome/testdata/welcome-popup-80.golden +++ b/tui/screens/welcome/testdata/welcome-popup-80.golden @@ -9,13 +9,13 @@ ╰─ v0.13.0 ────────────────────────────────────────────────────────────────╯ - ╭─ Available commands ───────────────╮ │ › access │ │ console │ │ diagnostics │ │ help │ │ profiles │ + │ services │ │ workspace │ ╰────────────────────────────────────╯ ╭─ Command ────────────────────────────────────────────────────────────────╮ From 58bb50e0b269221e40ff57e83f5960501c5703df Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Wed, 29 Jul 2026 13:02:30 +0200 Subject: [PATCH 03/16] change layout --- tui/app/model.go | 8 + tui/app/model_test.go | 5 + tui/app/view.go | 2 + tui/navigation/navigation.go | 1 + tui/screens/deployments/model.go | 132 +++++++++++++++ tui/screens/deployments/model_test.go | 94 +++++++++++ .../testdata/deployments-120.golden | 30 ++++ .../testdata/deployments-80.golden | 24 +++ tui/screens/deployments/view.go | 66 ++++++++ tui/screens/services/model.go | 14 +- tui/screens/services/model_test.go | 6 +- tui/screens/welcome/groups.go | 30 ++++ tui/screens/welcome/model.go | 90 ++++++++-- tui/screens/welcome/model_test.go | 156 +++++++++++++----- .../welcome/testdata/welcome-120.golden | 16 +- .../welcome/testdata/welcome-80.golden | 16 +- .../welcome/testdata/welcome-popup-80.golden | 24 +-- tui/screens/welcome/view.go | 60 ++++++- 18 files changed, 675 insertions(+), 99 deletions(-) create mode 100644 tui/screens/deployments/model.go create mode 100644 tui/screens/deployments/model_test.go create mode 100644 tui/screens/deployments/testdata/deployments-120.golden create mode 100644 tui/screens/deployments/testdata/deployments-80.golden create mode 100644 tui/screens/deployments/view.go create mode 100644 tui/screens/welcome/groups.go diff --git a/tui/app/model.go b/tui/app/model.go index a690ef772..770d6d5bf 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -12,6 +12,7 @@ import ( welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" "github.com/pluralsh/plural-cli/tui/navigation" accessscreen "github.com/pluralsh/plural-cli/tui/screens/access" + deploymentsscreen "github.com/pluralsh/plural-cli/tui/screens/deployments" diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" welcomescreen "github.com/pluralsh/plural-cli/tui/screens/welcome" @@ -37,6 +38,7 @@ type Model struct { welcome welcomescreen.Model access accessscreen.Model diagnostics diagnosticsscreen.Model + deployments deploymentsscreen.Model services servicesscreen.Model route navigation.Route } @@ -48,6 +50,7 @@ func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { welcome: welcomescreen.New(ctx, dependencies.Welcome, t), access: accessscreen.New(ctx, dependencies.Access, t), diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t), + deployments: deploymentsscreen.New(ctx, t, ""), services: servicesscreen.New(ctx, dependencies.Services, t), route: navigation.Welcome, quit: key.NewBinding( @@ -68,6 +71,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.access.Init() case navigation.Diagnostics: return m, m.diagnostics.Init() + case navigation.Deployments: + m.deployments.SetConsoleURL(m.welcome.Snapshot().Console.URL) + return m, m.deployments.Init() case navigation.Services: return m, m.services.Init() default: @@ -93,6 +99,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.access, cmd = m.access.Update(msg) case navigation.Diagnostics: m.diagnostics, cmd = m.diagnostics.Update(msg) + case navigation.Deployments: + m.deployments, cmd = m.deployments.Update(msg) case navigation.Services: m.services, cmd = m.services.Update(msg) default: diff --git a/tui/app/model_test.go b/tui/app/model_test.go index f9f32d9ad..0926d4f09 100644 --- a/tui/app/model_test.go +++ b/tui/app/model_test.go @@ -45,6 +45,11 @@ func TestModelRoutesScreensWithoutRebuildingShell(t *testing.T) { if routed.route != navigation.Diagnostics || !strings.Contains(routed.View().Content, "Diagnostics") { t.Fatalf("route/view = %q\n%s", routed.route, routed.View().Content) } + updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Deployments}) + routed = updated.(Model) + if routed.route != navigation.Deployments || !strings.Contains(routed.View().Content, "CD / Deployments") { + t.Fatalf("deployments route/view = %q\n%s", routed.route, routed.View().Content) + } updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Services}) routed = updated.(Model) if routed.route != navigation.Services || !strings.Contains(routed.View().Content, "Services") { diff --git a/tui/app/view.go b/tui/app/view.go index 87b8bc3a5..0ac2fcbd2 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -15,6 +15,8 @@ func (m Model) View() tea.View { content = m.access.View(m.width, m.height) case navigation.Diagnostics: content = m.diagnostics.View(m.width, m.height) + case navigation.Deployments: + content = m.deployments.View(m.width, m.height) case navigation.Services: content = m.services.View(m.width, m.height) } diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index 070df9281..4cea2e030 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -12,6 +12,7 @@ const ( Welcome Route = "welcome" Access Route = "access" Diagnostics Route = "diagnostics" + Deployments Route = "deployments" Services Route = "services" ) diff --git a/tui/screens/deployments/model.go b/tui/screens/deployments/model.go new file mode 100644 index 000000000..07f882a44 --- /dev/null +++ b/tui/screens/deployments/model.go @@ -0,0 +1,132 @@ +package deployments + +import ( + "context" + + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type resourceID uint8 + +const ( + resourceServices resourceID = iota + resourceClusters + resourceRepositories + resourcePipelines + resourceNotifications + resourceProviders +) + +type resource struct { + id resourceID + number string + shortcut string + title string + blurb string + soon bool + route navigation.Route +} + +func resources() []resource { + return []resource{ + {id: resourceServices, number: "1", shortcut: "s", title: "Services", blurb: "list · describe · (kick later)", route: navigation.Services}, + {id: resourceClusters, number: "2", shortcut: "c", title: "Clusters", blurb: "list · describe", soon: true}, + {id: resourceRepositories, number: "3", shortcut: "r", title: "Repositories", blurb: "list · get", soon: true}, + {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "trigger", soon: true}, + {id: resourceNotifications, number: "5", shortcut: "n", title: "Notifications", blurb: "sinks", soon: true}, + {id: resourceProviders, number: "6", shortcut: "v", title: "Providers", blurb: "list", soon: true}, + } +} + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionUp + keyActionDown + keyActionConfirm + keyActionBack +) + +var keyActionKeystrokes = map[keyAction]string{ + keyActionUp: "up", + keyActionDown: "down", + keyActionConfirm: "enter", + keyActionBack: "esc", +} + +func actionForKeystroke(keystroke string) keyAction { + for action, candidate := range keyActionKeystrokes { + if keystroke == candidate { + return action + } + } + return keyActionNone +} + +// Model is the CD / Deployments hub. +type Model struct { + theme theme.Theme + items []resource + cursor int + console string +} + +// New creates the deployments hub. consoleURL is shown in the connection panel. +func New(_ context.Context, t theme.Theme, consoleURL string) Model { + return Model{ + theme: t, + items: resources(), + console: consoleURL, + } +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + key, ok := msg.(tea.KeyPressMsg) + if !ok { + return m, nil + } + switch actionForKeystroke(key.Keystroke()) { + case keyActionUp: + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case keyActionDown: + if m.cursor < len(m.items)-1 { + m.cursor++ + } + return m, nil + case keyActionConfirm: + return m.openResource(m.items[m.cursor]) + case keyActionBack: + return m, navigation.Navigate(navigation.Welcome) + } + + text := key.Text + if text == "" && key.Code > 0 && key.Code < 128 { + text = string(rune(key.Code)) + } + for i, item := range m.items { + if text == item.number || text == item.shortcut { + m.cursor = i + return m.openResource(item) + } + } + return m, nil +} + +func (m Model) openResource(item resource) (Model, tea.Cmd) { + if item.soon || item.route == "" { + return m, nil + } + return m, navigation.Navigate(item.route) +} + +// SetConsoleURL refreshes the connection panel when context changes. +func (m *Model) SetConsoleURL(url string) { m.console = url } diff --git a/tui/screens/deployments/model_test.go b/tui/screens/deployments/model_test.go new file mode 100644 index 000000000..c5943f3b5 --- /dev/null +++ b/tui/screens/deployments/model_test.go @@ -0,0 +1,94 @@ +package deployments + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestDeploymentsGoldens(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + for _, width := range []int{80, 120} { + t.Run(strconv.Itoa(width), func(t *testing.T) { + height := 24 + if width == 120 { + height = 30 + } + got := normalizeView(model.View(width, height)) + golden := filepath.Join("testdata", "deployments-"+strconv.Itoa(width)+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + }) + } +} + +func TestUpdateGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + _ = os.MkdirAll("testdata", 0o755) + for _, width := range []int{80, 120} { + height := 24 + if width == 120 { + height = 30 + } + got := normalizeView(model.View(width, height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "deployments-"+strconv.Itoa(width)+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func TestServicesShortcutNavigates(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + _, cmd := model.Update(tea.KeyPressMsg{Code: 's', Text: "s"}) + if cmd == nil { + t.Fatal("expected navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Services}) { + t.Fatalf("msg = %#v", msg) + } +} + +func TestSoonResourceDoesNotNavigate(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "") + model.cursor = 1 + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd != nil { + t.Fatalf("unexpected cmd %#v", cmd()) + } +} + +func TestEscReturnsWelcome(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "") + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil { + t.Fatal("expected welcome navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Welcome}) { + t.Fatalf("msg = %#v", msg) + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/deployments/testdata/deployments-120.golden b/tui/screens/deployments/testdata/deployments-120.golden new file mode 100644 index 000000000..c8f415ecd --- /dev/null +++ b/tui/screens/deployments/testdata/deployments-120.golden @@ -0,0 +1,30 @@ + Plural CD / Deployments https://console.acme.io + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Resources ────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ › 1 s Services list · describe · (kick later) │ + │ 2 c Clusters list · describe [soon] │ + │ 3 r Repositories list · get [soon] │ + │ 4 p Pipelines trigger [soon] │ + │ 5 n Notifications sinks [soon] │ + │ 6 v Providers list [soon] │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ╭─ Connection ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Console https://console.acme.io │ + │ Tip plural cd … remains the automation API │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + + 1–6 / letter · enter open · esc welcome · ctrl+c quit diff --git a/tui/screens/deployments/testdata/deployments-80.golden b/tui/screens/deployments/testdata/deployments-80.golden new file mode 100644 index 000000000..fc893a289 --- /dev/null +++ b/tui/screens/deployments/testdata/deployments-80.golden @@ -0,0 +1,24 @@ + Plural CD / Deployments https://console.acme.io + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Resources ────────────────────────────────────────────────────────────╮ + │ › 1 s Services list · describe · (kick later) │ + │ 2 c Clusters list · describe [soon] │ + │ 3 r Repositories list · get [soon] │ + │ 4 p Pipelines trigger [soon] │ + │ 5 n Notifications sinks [soon] │ + │ 6 v Providers list [soon] │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + ╭─ Connection ─────────────────────────────────────────────────────────────╮ + │ Console https://console.acme.io │ + │ Tip plural cd … remains the automation API │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + + 1–6 / letter · enter open · esc welcome · ctrl+c quit diff --git a/tui/screens/deployments/view.go b/tui/screens/deployments/view.go new file mode 100644 index 000000000..9639b3da7 --- /dev/null +++ b/tui/screens/deployments/view.go @@ -0,0 +1,66 @@ +package deployments + +import ( + "fmt" + + "github.com/charmbracelet/x/ansi" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + status := m.theme.Muted.Render("no console") + if m.console != "" { + status = m.theme.Success.Render(ansi.Truncate(m.console, 40, "…")) + } + + body := page.Panel(m.theme, "Resources", m.resourceLines(contentWidth-4), contentWidth, 8, true) + "\n\n" + + page.Panel(m.theme, "Connection", m.connectionLines(), contentWidth, 4, false) + + help := "1–6 / letter · enter open · esc welcome · ctrl+c quit" + return page.Render(m.theme, width, height, "CD / Deployments", status, body, help) +} + +func (m Model) resourceLines(innerWidth int) []string { + lines := make([]string, 0, len(m.items)) + for i, item := range m.items { + cursor := " " + if i == m.cursor { + cursor = "› " + } + soon := "" + if item.soon { + soon = " " + m.theme.Muted.Render("[soon]") + } + left := fmt.Sprintf("%s %s %-14s %s", item.number, item.shortcut, item.title, item.blurb) + var row string + switch { + case i == m.cursor && !item.soon: + row = cursor + m.theme.Title.Render(left) + soon + case item.soon: + row = cursor + m.theme.Muted.Render(left) + soon + default: + row = cursor + m.theme.Body.Render(left) + } + lines = append(lines, ansi.Truncate(row, max(1, innerWidth), "…")) + } + return lines +} + +func (m Model) connectionLines() []string { + url := lo.CoalesceOrEmpty(m.console, "not connected") + display := ansi.Truncate(url, 56, "…") + if m.console == "" { + display = m.theme.Warning.Render(display) + } + return []string{ + "Console " + display, + m.theme.Muted.Render("Tip plural cd … remains the automation API"), + } +} diff --git a/tui/screens/services/model.go b/tui/screens/services/model.go index fa3b3e818..4d8d15f27 100644 --- a/tui/screens/services/model.go +++ b/tui/screens/services/model.go @@ -88,12 +88,12 @@ type Model struct { err error needsAuth bool - clusters []servicesbridge.Cluster - clusterCursor int - cluster servicesbridge.Cluster - clusterFilter string - serviceFilter string - filterInput textinput.Model + clusters []servicesbridge.Cluster + clusterCursor int + cluster servicesbridge.Cluster + clusterFilter string + serviceFilter string + filterInput textinput.Model filteringCluster bool page servicesbridge.Page @@ -284,7 +284,7 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { m.prevCursors = nil return m, nil default: - return m, navigation.Navigate(navigation.Welcome) + return m, navigation.Navigate(navigation.Deployments) } } if m.loading { diff --git a/tui/screens/services/model_test.go b/tui/screens/services/model_test.go index 09d2c2091..6a5207d7c 100644 --- a/tui/screens/services/model_test.go +++ b/tui/screens/services/model_test.go @@ -111,13 +111,13 @@ func TestNoConsoleNavigatesToAccess(t *testing.T) { } } -func TestBackFromClustersReturnsWelcome(t *testing.T) { +func TestBackFromClustersReturnsDeployments(t *testing.T) { model := loadClusters(t, New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII))) _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) if cmd == nil { - t.Fatal("expected welcome navigation") + t.Fatal("expected deployments navigation") } - if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Welcome}) { + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Deployments}) { t.Fatalf("msg = %#v", msg) } } diff --git a/tui/screens/welcome/groups.go b/tui/screens/welcome/groups.go new file mode 100644 index 000000000..0bfd6e2b6 --- /dev/null +++ b/tui/screens/welcome/groups.go @@ -0,0 +1,30 @@ +package welcome + +import "github.com/pluralsh/plural-cli/tui/navigation" + +type groupID uint8 + +const ( + groupDeployments groupID = iota + groupAccess + groupDiagnose + groupHelp +) + +type group struct { + id groupID + number string + shortcut string + title string + blurb string + route navigation.Route // empty for Help stub +} + +func welcomeGroups() []group { + return []group{ + {id: groupDeployments, number: "1", shortcut: "d", title: "CD / Deployments", blurb: "clusters · services · repos", route: navigation.Deployments}, + {id: groupAccess, number: "2", shortcut: "a", title: "Access", blurb: "login · profiles · Console", route: navigation.Access}, + {id: groupDiagnose, number: "3", shortcut: "g", title: "Diagnose", blurb: "local context · checks", route: navigation.Diagnostics}, + {id: groupHelp, number: "4", shortcut: "?", title: "Help", blurb: "shortcuts · about"}, + } +} diff --git a/tui/screens/welcome/model.go b/tui/screens/welcome/model.go index 6da587715..9565a7db0 100644 --- a/tui/screens/welcome/model.go +++ b/tui/screens/welcome/model.go @@ -7,7 +7,6 @@ import ( tea "charm.land/bubbletea/v2" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" - "github.com/pluralsh/plural-cli/tui/components/commandbar" pluralspinner "github.com/pluralsh/plural-cli/tui/components/spinner" "github.com/pluralsh/plural-cli/tui/navigation" "github.com/pluralsh/plural-cli/tui/theme" @@ -16,25 +15,50 @@ import ( type loadedMsg struct{ snapshot welcomebridge.Snapshot } type failedMsg struct{ err error } +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionUp + keyActionDown + keyActionConfirm +) + +var keyActionKeystrokes = map[keyAction]string{ + keyActionUp: "up", + keyActionDown: "down", + keyActionConfirm: "enter", +} + +func actionForKeystroke(keystroke string) keyAction { + for action, candidate := range keyActionKeystrokes { + if keystroke == candidate { + return action + } + } + return keyActionNone +} + type Model struct { ctx context.Context loader welcomebridge.Loader theme theme.Theme spinner spinner.Model - command commandbar.Model + groups []group + cursor int loading bool snapshot welcomebridge.Snapshot err error + helpOpen bool } func New(ctx context.Context, loader welcomebridge.Loader, t theme.Theme) Model { - commands := []string{"access", "console", "diagnostics", "help", "profiles", "services", "workspace"} return Model{ ctx: ctx, loader: loader, theme: t, spinner: pluralspinner.New(t), - command: commandbar.New(t, commands), + groups: welcomeGroups(), loading: loader != nil, } } @@ -72,21 +96,55 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd - case commandbar.SubmittedMsg: - switch msg.Command { - case "access", "console", "profiles": - return m, navigation.Navigate(navigation.Access) - case "diagnostics", "workspace": - return m, navigation.Navigate(navigation.Diagnostics) - case "services": - return m, navigation.Navigate(navigation.Services) + case tea.KeyPressMsg: + return m.updateKey(msg) + default: + return m, nil + } +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + if m.helpOpen { + m.helpOpen = false + if key.Keystroke() == "esc" { + return m, nil + } + } + + switch actionForKeystroke(key.Keystroke()) { + case keyActionUp: + if m.cursor > 0 { + m.cursor-- } return m, nil - default: - var cmd tea.Cmd - m.command, cmd = m.command.Update(msg) - return m, cmd + case keyActionDown: + if m.cursor < len(m.groups)-1 { + m.cursor++ + } + return m, nil + case keyActionConfirm: + return m.openGroup(m.groups[m.cursor]) + } + + text := key.Text + if text == "" && key.Code > 0 && key.Code < 128 { + text = string(rune(key.Code)) + } + for i, g := range m.groups { + if text == g.number || text == g.shortcut { + m.cursor = i + return m.openGroup(g) + } + } + return m, nil +} + +func (m Model) openGroup(g group) (Model, tea.Cmd) { + if g.route == "" { + m.helpOpen = true + return m, nil } + return m, navigation.Navigate(g.route) } func (m Model) Snapshot() welcomebridge.Snapshot { return m.snapshot } diff --git a/tui/screens/welcome/model_test.go b/tui/screens/welcome/model_test.go index 714e2e9ca..5389fb1e6 100644 --- a/tui/screens/welcome/model_test.go +++ b/tui/screens/welcome/model_test.go @@ -56,7 +56,52 @@ func TestReadOnlyWelcomeGoldens(t *testing.T) { } } -func TestWelcomeCommandPopupGolden(t *testing.T) { +func TestUpdateWelcomeGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + snapshot := bridge.Snapshot{ + Version: "v0.13.0", + App: bridge.AppProfile{ + Configured: true, Name: "personal", Email: "alex@acme.io", + Endpoint: "https://app.plural.sh", SavedProfiles: 2, + }, + Console: bridge.ConsoleConnection{Configured: true, URL: "https://console.acme.io"}, + Workspace: bridge.Workspace{ + Configured: true, Path: "/work/path/to/a/very/long/workspace", Name: "plrl-dev-aws", + Project: "acme", Provider: "aws", Region: "eu-west-1", Owner: "sebastian@plural.sh", + }, + KubeContext: "plural-platform-prod", + } + popupSnapshot := bridge.Snapshot{ + Version: "v0.13.0", + App: bridge.AppProfile{Configured: true, Name: "personal", Email: "alex@acme.io"}, + Console: bridge.ConsoleConnection{Configured: true, URL: "https://console.acme.io"}, + Workspace: bridge.Workspace{Configured: true, Path: "/work/plural", Name: "plrl-dev-aws", Provider: "aws", Region: "eu-west-1", Owner: "alex@acme.io"}, + } + _ = os.MkdirAll("testdata", 0o755) + for _, width := range []int{80, 120} { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(loadedMsg{snapshot: snapshot}) + height := 24 + if width == 120 { + height = 30 + } + got := normalizeView(model.View(width, height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "welcome-"+strconv.Itoa(width)+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(loadedMsg{snapshot: popupSnapshot}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + got := normalizeView(model.View(80, 24)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "welcome-popup-80.golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestWelcomeGroupPickerGolden(t *testing.T) { model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) model, _ = model.Update(loadedMsg{snapshot: bridge.Snapshot{ Version: "v0.13.0", @@ -74,7 +119,74 @@ func TestWelcomeCommandPopupGolden(t *testing.T) { t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) } if lines := strings.Split(got, "\n"); len(lines) != 24 { - t.Fatalf("popup view height = %d, want 24", len(lines)) + t.Fatalf("group picker view height = %d, want 24", len(lines)) + } +} + +func TestWelcomeOpensDeploymentsFromShortcut(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + if cmd == nil { + t.Fatal("selecting CD did not emit navigation") + } + if got := cmd().(navigation.NavigateMsg).Route; got != navigation.Deployments { + t.Fatalf("route = %q, want deployments", got) + } + if model.cursor != 0 { + t.Fatalf("cursor = %d, want 0", model.cursor) + } +} + +func TestWelcomeOpensAccessFromNumber(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + _, cmd := model.Update(tea.KeyPressMsg{Code: '2', Text: "2"}) + if cmd == nil { + t.Fatal("selecting Access did not emit navigation") + } + if got := cmd().(navigation.NavigateMsg).Route; got != navigation.Access { + t.Fatalf("route = %q, want access", got) + } +} + +func TestWelcomeArrowAndEnterOpensDiagnose(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("enter did not emit navigation") + } + if got := cmd().(navigation.NavigateMsg).Route; got != navigation.Diagnostics { + t.Fatalf("route = %q, want diagnostics", got) + } +} + +func TestWelcomeHelpIsStub(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, cmd := model.Update(tea.KeyPressMsg{Code: '?', Text: "?"}) + if cmd != nil { + t.Fatal("help stub should not navigate") + } + if !model.helpOpen { + t.Fatal("expected help panel open") + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.helpOpen { + t.Fatal("esc should close help") + } +} + +func TestGroupPickerIsAnchoredAtBottom(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + lines := strings.Split(normalizeView(model.View(80, 24)), "\n") + if len(lines) != 24 { + t.Fatalf("view height = %d, want 24", len(lines)) + } + if !strings.Contains(lines[len(lines)-9], "Choose an area") { + t.Fatalf("group picker is not anchored above help:\n%s", strings.Join(lines, "\n")) + } + if !strings.Contains(lines[len(lines)-1], "ctrl+c quit") { + t.Fatalf("keymap is not at the bottom: %q", lines[len(lines)-1]) } } @@ -182,46 +294,6 @@ func TestStatusAdaptsToTerminalColorCapability(t *testing.T) { } } -func TestWelcomeForwardsInputToCommandBar(t *testing.T) { - model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) - model, _ = model.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) - if got := model.command.CurrentSuggestion(); got != "diagnostics" { - t.Fatalf("suggestion = %q, want diagnostics", got) - } - model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) - if got := model.command.Value(); got != "diagnostics" { - t.Fatalf("completed input = %q, want diagnostics", got) - } - model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - if cmd == nil { - t.Fatal("selecting a command did not emit submission") - } - model, routeCmd := model.Update(cmd()) - if routeCmd == nil { - t.Fatal("submitted command did not route") - } - if got := routeCmd().(navigation.NavigateMsg).Route; got != navigation.Diagnostics { - t.Fatalf("route = %q, want diagnostics", got) - } - if got := model.command.Selected(); got != "diagnostics" { - t.Fatalf("selected command = %q, want diagnostics", got) - } -} - -func TestCommandInputIsAnchoredAtBottom(t *testing.T) { - model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) - lines := strings.Split(normalizeView(model.View(80, 24)), "\n") - if len(lines) != 24 { - t.Fatalf("view height = %d, want 24", len(lines)) - } - if !strings.Contains(lines[len(lines)-4], "Command") { - t.Fatalf("command bar is not anchored above help:\n%s", strings.Join(lines, "\n")) - } - if !strings.Contains(lines[len(lines)-1], "ctrl+c quit") { - t.Fatalf("keymap is not at the bottom: %q", lines[len(lines)-1]) - } -} - func TestWideLayoutStretchesRightPaneAndStaysLeftAligned(t *testing.T) { model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) model, _ = model.Update(loadedMsg{snapshot: bridge.Snapshot{ diff --git a/tui/screens/welcome/testdata/welcome-120.golden b/tui/screens/welcome/testdata/welcome-120.golden index 4a13a1660..8fcaacc42 100644 --- a/tui/screens/welcome/testdata/welcome-120.golden +++ b/tui/screens/welcome/testdata/welcome-120.golden @@ -19,12 +19,12 @@ - - - - - - ╭─ Command ────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │ › Search commands… │ + ╭─ Choose an area ─────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ › 1 d CD / Deployments clusters · services · repos │ + │ 2 a Access login · profiles · Console │ + │ 3 g Diagnose local context · checks │ + │ 4 ? Help shortcuts · about │ + │ │ + │ Setup · Agents · Develop — later │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - tab complete · ↑/↓ suggestions · enter select · esc clear · ctrl+c quit + 1–4 open · letter shortcut · ↑/↓ · enter · ctrl+c quit diff --git a/tui/screens/welcome/testdata/welcome-80.golden b/tui/screens/welcome/testdata/welcome-80.golden index 62d680d42..b08356e67 100644 --- a/tui/screens/welcome/testdata/welcome-80.golden +++ b/tui/screens/welcome/testdata/welcome-80.golden @@ -13,12 +13,12 @@ - - - - - - ╭─ Command ────────────────────────────────────────────────────────────────╮ - │ › Search commands… │ + ╭─ Choose an area ─────────────────────────────────────────────────────────╮ + │ › 1 d CD / Deployments clusters · services · repos │ + │ 2 a Access login · profiles · Console │ + │ 3 g Diagnose local context · checks │ + │ 4 ? Help shortcuts · about │ + │ │ + │ Setup · Agents · Develop — later │ ╰──────────────────────────────────────────────────────────────────────────╯ - tab complete · ↑/↓ suggestions · enter select · esc clear · ctrl+c quit + 1–4 open · letter shortcut · ↑/↓ · enter · ctrl+c quit diff --git a/tui/screens/welcome/testdata/welcome-popup-80.golden b/tui/screens/welcome/testdata/welcome-popup-80.golden index 870246618..6176a4afa 100644 --- a/tui/screens/welcome/testdata/welcome-popup-80.golden +++ b/tui/screens/welcome/testdata/welcome-popup-80.golden @@ -9,16 +9,16 @@ ╰─ v0.13.0 ────────────────────────────────────────────────────────────────╯ - ╭─ Available commands ───────────────╮ - │ › access │ - │ console │ - │ diagnostics │ - │ help │ - │ profiles │ - │ services │ - │ workspace │ - ╰────────────────────────────────────╯ - ╭─ Command ────────────────────────────────────────────────────────────────╮ - │ › Search commands… │ + + + + + ╭─ Choose an area ─────────────────────────────────────────────────────────╮ + │ 1 d CD / Deployments clusters · services · repos │ + │ › 2 a Access login · profiles · Console │ + │ 3 g Diagnose local context · checks │ + │ 4 ? Help shortcuts · about │ + │ │ + │ Setup · Agents · Develop — later │ ╰──────────────────────────────────────────────────────────────────────────╯ - ↑/↓ choose · enter open · esc close · type to filter · ctrl+c quit + 1–4 open · letter shortcut · ↑/↓ · enter · ctrl+c quit diff --git a/tui/screens/welcome/view.go b/tui/screens/welcome/view.go index 9d005b057..2b71e8d22 100644 --- a/tui/screens/welcome/view.go +++ b/tui/screens/welcome/view.go @@ -33,10 +33,64 @@ func (m Model) View(width, height int) string { contentWidth := width - 2*sideMargin version := lo.CoalesceOrEmpty(m.snapshot.Version, "dev") header := m.renderHero(contentWidth, version) - command := m.command.View(contentWidth) - gap := m.verticalGap(height, header, command) + groups := m.renderGroups(contentWidth) + gap := m.verticalGap(height, header, groups) - return m.indent(header+strings.Repeat("\n", gap)+command, sideMargin) + return m.indent(header+strings.Repeat("\n", gap)+groups, sideMargin) +} + +func (m Model) renderGroups(width int) string { + border := m.primaryBorder() + title := "Choose an area" + if m.helpOpen { + title = "Help" + } + topRule := max(1, width-5-lipgloss.Width(title)) + top := border.Render("╭─ " + title + " " + strings.Repeat("─", topRule) + "╮") + bottom := border.Render("╰" + strings.Repeat("─", width-2) + "╯") + + innerWidth := width - 4 + var body []string + if m.helpOpen { + body = []string{ + m.theme.Body.Render("1–4 / letter opens an area"), + m.theme.Muted.Render("↑/↓ move · enter confirm · esc close help"), + m.theme.Muted.Render("ctrl+c quit"), + "", + m.theme.Muted.Render("More docs will land in a later phase."), + } + } else { + for i, g := range m.groups { + prefix := " " + label := fmt.Sprintf("%s %s %-18s %s", g.number, g.shortcut, g.title, g.blurb) + if i == m.cursor { + prefix = "› " + label = m.theme.Title.Render(label) + } else { + label = m.theme.Body.Render(fmt.Sprintf("%s %s ", g.number, g.shortcut)) + + m.theme.Body.Render(fmt.Sprintf("%-18s ", g.title)) + + m.theme.Muted.Render(g.blurb) + } + line := prefix + label + line = ansi.Truncate(line, innerWidth, "…") + body = append(body, line) + } + body = append(body, "", m.theme.Muted.Render("Setup · Agents · Develop — later")) + } + + rows := make([]string, 0, len(body)+2) + rows = append(rows, top) + for _, line := range body { + padded := line + strings.Repeat(" ", max(0, innerWidth-lipgloss.Width(line))) + rows = append(rows, border.Render("│")+" "+padded+" "+border.Render("│")) + } + rows = append(rows, bottom) + + help := m.theme.Muted.Render(ansi.Truncate("1–4 open · letter shortcut · ↑/↓ · enter · ctrl+c quit", max(1, width-2), "…")) + if m.helpOpen { + help = m.theme.Muted.Render(ansi.Truncate("any key closes help · ctrl+c quit", max(1, width-2), "…")) + } + return strings.Join(rows, "\n") + "\n " + help } func (m Model) viewportSize(width, height int) (int, int) { From 160f204305054015bf3e97fe7b7d3d0b3f1b34ed Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Wed, 29 Jul 2026 13:29:40 +0200 Subject: [PATCH 04/16] implement CD commands --- pkg/bridge/services/actions.go | 280 ++++++++ pkg/bridge/services/errors.go | 10 +- pkg/bridge/services/services.go | 18 +- pkg/bridge/services/services_test.go | 33 +- tui/screens/deployments/model.go | 2 +- .../testdata/deployments-120.golden | 2 +- .../testdata/deployments-80.golden | 2 +- tui/screens/services/actions.go | 100 +++ tui/screens/services/model.go | 672 ++++++++++++++++-- tui/screens/services/model_test.go | 76 ++ .../testdata/services-detail-120.golden | 26 +- .../testdata/services-detail-80.golden | 26 +- .../testdata/services-list-120.golden | 4 +- .../services/testdata/services-list-80.golden | 4 +- tui/screens/services/view.go | 205 +++++- 15 files changed, 1310 insertions(+), 150 deletions(-) create mode 100644 pkg/bridge/services/actions.go create mode 100644 tui/screens/services/actions.go diff --git a/pkg/bridge/services/actions.go b/pkg/bridge/services/actions.go new file mode 100644 index 000000000..cd6204d9d --- /dev/null +++ b/pkg/bridge/services/actions.go @@ -0,0 +1,280 @@ +package services + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/utils" +) + +// CreateInput is the credential-free create payload. +type CreateInput struct { + ClusterID string + Name string + Namespace string + RepoID string + GitRef string + GitFolder string + Kustomize string + Version string + DryRun bool +} + +// UpdateInput is the credential-free update payload. +type UpdateInput struct { + ID string + GitRef string + GitFolder string + Kustomize string + Version string + DryRun *bool +} + +// CloneInput is the credential-free clone payload. +type CloneInput struct { + SourceID string + DestClusterID string + Name string + Namespace string +} + +// Loader is the narrow contract consumed by the Services screen. +type Loader interface { + ListClusters(ctx context.Context, query string) ([]Cluster, error) + List(ctx context.Context, clusterID string, after *string, query string) (Page, error) + Get(ctx context.Context, id string) (Detail, error) + Kick(ctx context.Context, id string) (Detail, error) + Delete(ctx context.Context, id string) error + Create(ctx context.Context, input CreateInput) (Detail, error) + Update(ctx context.Context, input UpdateInput) (Detail, error) + Clone(ctx context.Context, input CloneInput) (Detail, error) + DownloadTarball(ctx context.Context, id, dir string) (string, error) +} + +// API is the Console surface required by this package. +type API interface { + ListClusters() (*gqlclient.ListClusters, error) + ListClusterServices(clusterId, handle *string) ([]*gqlclient.ServiceDeploymentEdgeFragment, error) + GetClusterService(serviceId, serviceName, clusterName *string) (*gqlclient.ServiceDeploymentExtended, error) + KickClusterService(serviceId, serviceName, clusterName *string) (*gqlclient.ServiceDeploymentExtended, error) + DeleteClusterService(serviceId string) (*gqlclient.DeleteServiceDeployment, error) + CreateClusterService(clusterId, clusterName *string, attr gqlclient.ServiceDeploymentAttributes) (*gqlclient.ServiceDeploymentExtended, error) + UpdateClusterService(serviceId, serviceName, clusterName *string, attributes gqlclient.ServiceUpdateAttributes) (*gqlclient.ServiceDeploymentExtended, error) + CloneService(clusterId string, serviceId, serviceName, clusterName *string, attributes gqlclient.ServiceCloneAttributes) (*gqlclient.ServiceDeploymentFragment, error) + GetDeployToken(clusterId, clusterName *string) (string, error) +} + +func (s *Service) Kick(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + service, err := client.KickClusterService(&id, nil, nil) + if err != nil { + return Detail{}, err + } + if service == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService} + } + return detailFromExtended(service), nil +} + +func (s *Service) Delete(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } + id = strings.TrimSpace(id) + if id == "" { + return &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return err + } + _, err = client.DeleteClusterService(id) + return err +} + +func (s *Service) Create(ctx context.Context, input CreateInput) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + input.ClusterID = strings.TrimSpace(input.ClusterID) + input.Name = strings.TrimSpace(input.Name) + input.RepoID = strings.TrimSpace(input.RepoID) + input.GitRef = strings.TrimSpace(input.GitRef) + input.GitFolder = strings.TrimSpace(input.GitFolder) + if input.ClusterID == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingCluster} + } + if input.Name == "" || input.RepoID == "" || input.GitRef == "" || input.GitFolder == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingCreateFields} + } + if input.Namespace == "" { + input.Namespace = "default" + } + if input.Version == "" { + input.Version = "0.0.1" + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + attrs := gqlclient.ServiceDeploymentAttributes{ + Name: input.Name, + Namespace: input.Namespace, + Version: lo.ToPtr(input.Version), + RepositoryID: lo.ToPtr(input.RepoID), + Git: &gqlclient.GitRefAttributes{Ref: input.GitRef, Folder: input.GitFolder}, + DryRun: lo.ToPtr(input.DryRun), + } + if input.Kustomize != "" { + attrs.Kustomize = &gqlclient.KustomizeAttributes{Path: input.Kustomize} + } + service, err := client.CreateClusterService(&input.ClusterID, nil, attrs) + if err != nil { + return Detail{}, err + } + if service == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService} + } + return detailFromExtended(service), nil +} + +func (s *Service) Update(ctx context.Context, input UpdateInput) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + input.ID = strings.TrimSpace(input.ID) + if input.ID == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + attrs := gqlclient.ServiceUpdateAttributes{} + if input.GitRef != "" || input.GitFolder != "" { + attrs.Git = &gqlclient.GitRefAttributes{Ref: input.GitRef, Folder: input.GitFolder} + } + if input.Version != "" { + attrs.Version = lo.ToPtr(input.Version) + } + if input.DryRun != nil { + attrs.DryRun = input.DryRun + } + if input.Kustomize != "" { + attrs.Kustomize = &gqlclient.KustomizeAttributes{Path: input.Kustomize} + } + service, err := client.UpdateClusterService(&input.ID, nil, nil, attrs) + if err != nil { + return Detail{}, err + } + if service == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService} + } + return detailFromExtended(service), nil +} + +func (s *Service) Clone(ctx context.Context, input CloneInput) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + input.SourceID = strings.TrimSpace(input.SourceID) + input.DestClusterID = strings.TrimSpace(input.DestClusterID) + input.Name = strings.TrimSpace(input.Name) + if input.SourceID == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + if input.DestClusterID == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingCluster} + } + if input.Name == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingCreateFields} + } + if input.Namespace == "" { + input.Namespace = "default" + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + attrs := gqlclient.ServiceCloneAttributes{Name: input.Name, Namespace: lo.ToPtr(input.Namespace)} + frag, err := client.CloneService(input.DestClusterID, &input.SourceID, nil, nil, attrs) + if err != nil { + return Detail{}, err + } + if frag == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService} + } + return s.Get(ctx, frag.ID) +} + +func (s *Service) DownloadTarball(ctx context.Context, id, dir string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + id = strings.TrimSpace(id) + if id == "" { + return "", &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return "", err + } + service, err := client.GetClusterService(&id, nil, nil) + if err != nil { + return "", err + } + if service == nil { + return "", &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService} + } + if service.Tarball == nil || strings.TrimSpace(*service.Tarball) == "" { + return "", &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingTarball} + } + dir = strings.TrimSpace(dir) + if dir == "" { + dir = filepath.Join(".", service.Name+"-tarball") + } + if err := utils.EnsureEmptyDir(dir); err != nil { + return "", err + } + if service.Cluster == nil { + return "", &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingCluster} + } + token, err := client.GetDeployToken(&service.Cluster.ID, nil) + if err != nil { + return "", err + } + resp, err := utils.ReadRemoteFileWithRetries(*service.Tarball, token, 3) + if err != nil { + return "", err + } + defer func(c io.Closer) { _ = c.Close() }(resp) + if err := utils.Untar(dir, resp); err != nil { + return "", err + } + abs, err := filepath.Abs(dir) + if err != nil { + return dir, nil + } + if _, err := os.Stat(abs); err != nil { + return "", fmt.Errorf("tarball directory missing after unpack: %w", err) + } + return abs, nil +} diff --git a/pkg/bridge/services/errors.go b/pkg/bridge/services/errors.go index f748f17e6..3df66e14c 100644 --- a/pkg/bridge/services/errors.go +++ b/pkg/bridge/services/errors.go @@ -3,8 +3,10 @@ package services import "errors" var ( - errNoConsole = errors.New("connect a Console profile before browsing Console resources") - errMissingID = errors.New("service id is required") - errMissingCluster = errors.New("cluster id is required") - errMissingService = errors.New("service was not found") + errNoConsole = errors.New("connect a Console profile before browsing Console resources") + errMissingID = errors.New("service id is required") + errMissingCluster = errors.New("cluster id is required") + errMissingService = errors.New("service was not found") + errMissingCreateFields = errors.New("name, repository id, git ref, and git folder are required") + errMissingTarball = errors.New("service does not have a tarball") ) diff --git a/pkg/bridge/services/services.go b/pkg/bridge/services/services.go index af64bf5a1..cdb8e3e1c 100644 --- a/pkg/bridge/services/services.go +++ b/pkg/bridge/services/services.go @@ -1,4 +1,4 @@ -// Package services exposes read-only Console service list/get use cases to +// Package services exposes Console service list/get/mutation use cases to // presentation layers without importing TUI code. package services @@ -40,6 +40,7 @@ type ServiceError struct { // Detail is the credential-free detail payload for a service deployment. type Detail struct { Summary + ClusterID string ClusterName string ClusterHandle string RevisionSHA string @@ -57,25 +58,11 @@ type Page struct { TotalShown int } -// Loader is the narrow contract consumed by the Services screen. -type Loader interface { - ListClusters(ctx context.Context, query string) ([]Cluster, error) - List(ctx context.Context, clusterID string, after *string, query string) (Page, error) - Get(ctx context.Context, id string) (Detail, error) -} - // ConsoleResolver supplies the active Console URL and token. type ConsoleResolver interface { ActiveConsole(ctx context.Context) (url, token string, err error) } -// API is the Console surface required by this package. -type API interface { - ListClusters() (*gqlclient.ListClusters, error) - ListClusterServices(clusterId, handle *string) ([]*gqlclient.ServiceDeploymentEdgeFragment, error) - GetClusterService(serviceId, serviceName, clusterName *string) (*gqlclient.ServiceDeploymentExtended, error) -} - // ClientFactory builds a Console API for an authenticated endpoint. type ClientFactory func(token, url string) (API, error) @@ -254,6 +241,7 @@ func detailFromExtended(service *gqlclient.ServiceDeploymentExtended) Detail { detail.GitFolder = service.Git.Folder } if service.Cluster != nil { + detail.ClusterID = service.Cluster.ID detail.ClusterName = service.Cluster.Name if service.Cluster.Handle != nil { detail.ClusterHandle = *service.Cluster.Handle diff --git a/pkg/bridge/services/services_test.go b/pkg/bridge/services/services_test.go index 29dc6bef6..ae63cf637 100644 --- a/pkg/bridge/services/services_test.go +++ b/pkg/bridge/services/services_test.go @@ -19,11 +19,11 @@ func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) { } type fakeAPI struct { - clusters *gqlclient.ListClusters - edges []*gqlclient.ServiceDeploymentEdgeFragment - listErr error - detail *gqlclient.ServiceDeploymentExtended - getErr error + clusters *gqlclient.ListClusters + edges []*gqlclient.ServiceDeploymentEdgeFragment + listErr error + detail *gqlclient.ServiceDeploymentExtended + getErr error clusterID string } @@ -39,6 +39,25 @@ func (f *fakeAPI) ListClusterServices(clusterId, _ *string) ([]*gqlclient.Servic func (f *fakeAPI) GetClusterService(*string, *string, *string) (*gqlclient.ServiceDeploymentExtended, error) { return f.detail, f.getErr } +func (f *fakeAPI) KickClusterService(*string, *string, *string) (*gqlclient.ServiceDeploymentExtended, error) { + return f.detail, f.getErr +} +func (f *fakeAPI) DeleteClusterService(string) (*gqlclient.DeleteServiceDeployment, error) { + return &gqlclient.DeleteServiceDeployment{}, f.getErr +} +func (f *fakeAPI) CreateClusterService(*string, *string, gqlclient.ServiceDeploymentAttributes) (*gqlclient.ServiceDeploymentExtended, error) { + return f.detail, f.getErr +} +func (f *fakeAPI) UpdateClusterService(*string, *string, *string, gqlclient.ServiceUpdateAttributes) (*gqlclient.ServiceDeploymentExtended, error) { + return f.detail, f.getErr +} +func (f *fakeAPI) CloneService(string, *string, *string, *string, gqlclient.ServiceCloneAttributes) (*gqlclient.ServiceDeploymentFragment, error) { + if f.detail == nil { + return nil, f.getErr + } + return &gqlclient.ServiceDeploymentFragment{ID: f.detail.ID, Name: f.detail.Name, Namespace: f.detail.Namespace}, f.getErr +} +func (f *fakeAPI) GetDeployToken(*string, *string) (string, error) { return "token", f.getErr } func TestListClustersAndScopedServices(t *testing.T) { handle := "prod-eu" @@ -87,8 +106,8 @@ func TestGetMapsDetail(t *testing.T) { sha := "abc123" api := &fakeAPI{detail: &gqlclient.ServiceDeploymentExtended{ ID: "svc-1", Name: "api", Namespace: "default", Status: gqlclient.ServiceDeploymentStatusFailed, - Git: &gqlclient.GitRefFragment{Ref: "main", Folder: "services/api"}, - Cluster: &gqlclient.BaseClusterFragment{Name: "prod", Handle: &handle}, + Git: &gqlclient.GitRefFragment{Ref: "main", Folder: "services/api"}, + Cluster: &gqlclient.BaseClusterFragment{Name: "prod", Handle: &handle}, Revision: &gqlclient.RevisionFragment{ID: "rev-1", Sha: &sha, Git: &gqlclient.RevisionFragment_Git{Ref: "main"}}, Components: []*gqlclient.ServiceDeploymentExtended_Components{ {Synced: true}, diff --git a/tui/screens/deployments/model.go b/tui/screens/deployments/model.go index 07f882a44..a77178f26 100644 --- a/tui/screens/deployments/model.go +++ b/tui/screens/deployments/model.go @@ -32,7 +32,7 @@ type resource struct { func resources() []resource { return []resource{ - {id: resourceServices, number: "1", shortcut: "s", title: "Services", blurb: "list · describe · (kick later)", route: navigation.Services}, + {id: resourceServices, number: "1", shortcut: "s", title: "Services", blurb: "browse · kick · create · …", route: navigation.Services}, {id: resourceClusters, number: "2", shortcut: "c", title: "Clusters", blurb: "list · describe", soon: true}, {id: resourceRepositories, number: "3", shortcut: "r", title: "Repositories", blurb: "list · get", soon: true}, {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "trigger", soon: true}, diff --git a/tui/screens/deployments/testdata/deployments-120.golden b/tui/screens/deployments/testdata/deployments-120.golden index c8f415ecd..a1d1c156c 100644 --- a/tui/screens/deployments/testdata/deployments-120.golden +++ b/tui/screens/deployments/testdata/deployments-120.golden @@ -2,7 +2,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╭─ › Resources ────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │ › 1 s Services list · describe · (kick later) │ + │ › 1 s Services browse · kick · create · … │ │ 2 c Clusters list · describe [soon] │ │ 3 r Repositories list · get [soon] │ │ 4 p Pipelines trigger [soon] │ diff --git a/tui/screens/deployments/testdata/deployments-80.golden b/tui/screens/deployments/testdata/deployments-80.golden index fc893a289..0fbe23392 100644 --- a/tui/screens/deployments/testdata/deployments-80.golden +++ b/tui/screens/deployments/testdata/deployments-80.golden @@ -2,7 +2,7 @@ ──────────────────────────────────────────────────────────────────────────── ╭─ › Resources ────────────────────────────────────────────────────────────╮ - │ › 1 s Services list · describe · (kick later) │ + │ › 1 s Services browse · kick · create · … │ │ 2 c Clusters list · describe [soon] │ │ 3 r Repositories list · get [soon] │ │ 4 p Pipelines trigger [soon] │ diff --git a/tui/screens/services/actions.go b/tui/screens/services/actions.go new file mode 100644 index 000000000..092d50b0a --- /dev/null +++ b/tui/screens/services/actions.go @@ -0,0 +1,100 @@ +package services + +import ( + "fmt" + "strings" + + servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" +) + +type actionKind uint8 + +const ( + actionKick actionKind = iota + actionEdit + actionClone + actionTarball + actionWorkbench + actionDelete + actionCreate +) + +type detailAction struct { + kind actionKind + shortcut string + title string + blurb string + danger bool +} + +func detailActions() []detailAction { + return []detailAction{ + {kind: actionKick, shortcut: "k", title: "Kick", blurb: "force sync now"}, + {kind: actionEdit, shortcut: "e", title: "Edit", blurb: "git ref · folder · config · version"}, + {kind: actionClone, shortcut: "c", title: "Clone", blurb: "onto another cluster"}, + {kind: actionTarball, shortcut: "t", title: "Tarball", blurb: "download locally"}, + {kind: actionWorkbench, shortcut: "m", title: "Template…", blurb: "liquid / tpl / lua workbench"}, + {kind: actionDelete, shortcut: "d", title: "Delete", blurb: "remove service", danger: true}, + } +} + +type pendingOp struct { + kind actionKind + title string + cli string + lines []string + danger bool + create *servicesbridge.CreateInput + update *servicesbridge.UpdateInput + clone *servicesbridge.CloneInput + tarball string + deleteID string + kickID string +} + +func (m Model) kickPlan() pendingOp { + d := m.detail + cluster := clusterLabel(servicesbridge.Cluster{Handle: d.ClusterHandle, Name: d.ClusterName, ID: d.ClusterID}) + cli := "plural cd services kick " + d.ID + if d.ClusterHandle != "" { + cli = fmt.Sprintf("plural cd services kick @%s/%s", d.ClusterHandle, d.Name) + } + return pendingOp{ + kind: actionKick, + title: "Force sync · " + d.Name, + cli: cli, + kickID: d.ID, + lines: []string{ + "Action Kick / force sync", + "Cluster " + cluster, + "Service " + d.Name + " · " + d.Namespace, + "Revision " + loCoalesce(d.RevisionSHA, "—"), + "Status " + loCoalesce(d.Status, "—"), + }, + } +} + +func (m Model) deletePlan() pendingOp { + d := m.detail + return pendingOp{ + kind: actionDelete, + title: "Delete · " + d.Name, + cli: "plural cd services delete " + d.ID, + danger: true, + deleteID: d.ID, + lines: []string{ + "Action Delete service", + "Service " + d.Name + " · " + clusterLabel(servicesbridge.Cluster{Handle: d.ClusterHandle, Name: d.ClusterName}), + "Note Cluster workloads are not uninstalled automatically.", + }, + } +} + +func loCoalesce(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} diff --git a/tui/screens/services/model.go b/tui/screens/services/model.go index 4d8d15f27..45e3c5a9a 100644 --- a/tui/screens/services/model.go +++ b/tui/screens/services/model.go @@ -1,8 +1,10 @@ -// Package services implements the Phase 2 read-only Console services browser. +// Package services implements Console service browsing and contextual actions. package services import ( "context" + "fmt" + "path/filepath" "strings" "charm.land/bubbles/v2/textinput" @@ -21,6 +23,15 @@ const ( modeList modeDetail modeFilter + modeReview + modeOperating + modeResult + modeDeleteConfirm + modeTarball + modeCreate + modeEdit + modeClone + modeWorkbench ) type keyAction uint8 @@ -36,18 +47,22 @@ const ( keyActionNextPage keyActionPrevPage keyActionConnectConsole + keyActionCreate + keyActionBackground ) var keyActionKeystrokes = map[keyAction][]string{ keyActionBack: {"esc"}, - keyActionMoveUp: {"up", "k"}, - keyActionMoveDown: {"down", "j"}, + keyActionMoveUp: {"up"}, + keyActionMoveDown: {"down"}, keyActionConfirm: {"enter"}, keyActionRefresh: {"r"}, keyActionFilter: {"/"}, - keyActionNextPage: {"n", "right"}, - keyActionPrevPage: {"p", "left"}, + keyActionNextPage: {"right", "]"}, + keyActionPrevPage: {"left", "p", "["}, keyActionConnectConsole: {"c"}, + keyActionCreate: {"n"}, + keyActionBackground: {"b"}, } func actionForKeystroke(keystroke string) keyAction { @@ -77,8 +92,15 @@ type detailMsg struct { err error request uint64 } +type opDoneMsg struct { + err error + detail servicesbridge.Detail + path string + request uint64 + kind actionKind +} -// Model owns only Services-screen interaction state. +// Model owns Services-screen interaction state. type Model struct { ctx context.Context loader servicesbridge.Loader @@ -102,26 +124,49 @@ type Model struct { prevCursors []string request uint64 - detail servicesbridge.Detail - detailID string - listCursor int - listAfter *string - listFilter string - listPrev []string + detail servicesbridge.Detail + detailID string + listCursor int + listAfter *string + listFilter string + listPrev []string + actionCursor int + + pending pendingOp + opLog []string + result string + + formInput textinput.Model + formFields []formField + formIndex int + formValues map[string]string + formDryRun bool + wbTemplate bool + confirmName string +} + +type formField struct { + label string + key string } func New(ctx context.Context, loader servicesbridge.Loader, t theme.Theme) Model { input := textinput.New() input.Prompt = "› " input.Placeholder = "filter" - input.CharLimit = 128 + input.CharLimit = 256 styles := textinput.DefaultDarkStyles() styles.Focused.Text = t.Body styles.Focused.Prompt = t.Title styles.Focused.Placeholder = t.Muted styles.Blurred = styles.Focused input.SetStyles(styles) - return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, mode: modeClusters} + form := input + form.Placeholder = "" + return Model{ + ctx: ctx, loader: loader, theme: t, loading: loader != nil, + filterInput: input, formInput: form, mode: modeClusters, wbTemplate: true, + } } func (m Model) Init() tea.Cmd { @@ -167,6 +212,43 @@ func (m *Model) beginDetail(id string) tea.Cmd { } } +func (m *Model) beginPending() tea.Cmd { + m.mode = modeOperating + m.loading = true + m.err = nil + m.opLog = []string{"starting…"} + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + op := m.pending + detailID := m.detailID + return func() tea.Msg { + switch op.kind { + case actionKick: + detail, err := loader.Kick(ctx, op.kickID) + return opDoneMsg{err: err, detail: detail, request: request, kind: op.kind} + case actionDelete: + err := loader.Delete(ctx, op.deleteID) + return opDoneMsg{err: err, request: request, kind: op.kind} + case actionTarball: + path, err := loader.DownloadTarball(ctx, detailID, op.tarball) + return opDoneMsg{err: err, path: path, request: request, kind: op.kind} + case actionEdit: + detail, err := loader.Update(ctx, *op.update) + return opDoneMsg{err: err, detail: detail, request: request, kind: op.kind} + case actionClone: + detail, err := loader.Clone(ctx, *op.clone) + return opDoneMsg{err: err, detail: detail, request: request, kind: op.kind} + case actionCreate: + detail, err := loader.Create(ctx, *op.create) + return opDoneMsg{err: err, detail: detail, request: request, kind: op.kind} + default: + return opDoneMsg{err: fmt.Errorf("unsupported action"), request: request, kind: op.kind} + } + } +} + func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case initMsg: @@ -220,15 +302,51 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) if msg.err == nil { m.detail = msg.detail + m.actionCursor = 0 m.mode = modeDetail } return m, nil + case opDoneMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.mode = modeResult + if msg.err != nil { + m.result = "failed" + m.opLog = append(m.opLog, "✗ "+msg.err.Error()) + return m, nil + } + m.result = "ok" + switch msg.kind { + case actionKick, actionEdit, actionCreate: + m.detail = msg.detail + m.detailID = msg.detail.ID + m.opLog = append(m.opLog, "✓ completed") + case actionClone: + m.detail = msg.detail + m.detailID = msg.detail.ID + m.opLog = append(m.opLog, "✓ cloned "+msg.detail.Name) + case actionDelete: + m.opLog = append(m.opLog, "✓ deleted") + case actionTarball: + m.opLog = append(m.opLog, "✓ wrote "+msg.path) + m.result = msg.path + default: + m.opLog = append(m.opLog, "✓ completed") + } + return m, nil case tea.KeyPressMsg: return m.updateKey(msg) } - if m.mode == modeFilter { + switch m.mode { + case modeFilter, modeDeleteConfirm, modeTarball, modeCreate, modeEdit, modeClone, modeWorkbench: var cmd tea.Cmd - m.filterInput, cmd = m.filterInput.Update(msg) + m.formInput, cmd = m.formInput.Update(msg) + if m.mode == modeFilter { + m.filterInput = m.formInput + } return m, cmd } return m, nil @@ -236,45 +354,34 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { action := actionForKeystroke(key.Keystroke()) - if m.mode == modeFilter { - switch action { - case keyActionBack: - m.mode = modeList - if m.filteringCluster { - m.mode = modeClusters - } - m.filterInput.Blur() - return m, nil - case keyActionConfirm: - value := strings.TrimSpace(m.filterInput.Value()) - m.filterInput.Blur() - if m.filteringCluster { - m.clusterFilter = value - m.mode = modeClusters - m.clusterCursor = 0 - return m, m.beginClusters() - } - m.serviceFilter = value - m.mode = modeList - m.after = nil - m.prevCursors = nil - m.cursor = 0 - return m, m.beginList(nil) - } - var cmd tea.Cmd - m.filterInput, cmd = m.filterInput.Update(key) - return m, cmd + text := key.Text + if text == "" && key.Code > 0 && key.Code < 128 { + text = string(rune(key.Code)) + } + + switch m.mode { + case modeFilter: + return m.updateFilter(action, key) + case modeReview: + return m.updateReview(action) + case modeOperating: + return m, nil + case modeResult: + return m.updateResult(action) + case modeDeleteConfirm: + return m.updateDeleteConfirm(action, key) + case modeTarball: + return m.updateTarball(action, key) + case modeCreate, modeEdit, modeClone: + return m.updateForm(action, key) + case modeWorkbench: + return m.updateWorkbench(action, key, text) + case modeDetail: + return m.updateDetail(action, text) } + if action == keyActionBack { switch m.mode { - case modeDetail: - m.mode = modeList - m.err = nil - m.cursor = m.listCursor - m.after = m.listAfter - m.serviceFilter = m.listFilter - m.prevCursors = append([]string(nil), m.listPrev...) - return m, nil case modeList: m.mode = modeClusters m.page = servicesbridge.Page{} @@ -290,19 +397,456 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { if m.loading { return m, nil } - if m.needsAuth && action == keyActionConnectConsole { + if m.needsAuth && (action == keyActionConnectConsole || text == "c") { return m, navigation.Navigate(navigation.Access) } - if m.mode == modeDetail { - if action == keyActionRefresh && m.detailID != "" { - return m, m.beginDetail(m.detailID) + if m.mode == modeClusters { + return m.updateClusters(action) + } + return m.updateList(action, text) +} + +func (m Model) updateFilter(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.mode = modeList + if m.filteringCluster { + m.mode = modeClusters } + m.filterInput.Blur() return m, nil + case keyActionConfirm: + value := strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + if m.filteringCluster { + m.clusterFilter = value + m.mode = modeClusters + m.clusterCursor = 0 + return m, m.beginClusters() + } + m.serviceFilter = value + m.mode = modeList + m.after = nil + m.prevCursors = nil + m.cursor = 0 + return m, m.beginList(nil) } - if m.mode == modeClusters { - return m.updateClusters(action) + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd +} + +func (m Model) updateDetail(action keyAction, text string) (Model, tea.Cmd) { + if action == keyActionBack { + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.after = m.listAfter + m.serviceFilter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) + return m, nil + } + if m.loading { + return m, nil + } + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + actions := detailActions() + switch action { + case keyActionMoveUp: + m.actionCursor = clampCursor(m.actionCursor-1, len(actions)) + return m, nil + case keyActionMoveDown: + m.actionCursor = clampCursor(m.actionCursor+1, len(actions)) + return m, nil + case keyActionConfirm: + return m.openAction(actions[m.actionCursor]) + } + for i, a := range actions { + if text == a.shortcut { + m.actionCursor = i + return m.openAction(a) + } + } + return m, nil +} + +func (m Model) openAction(a detailAction) (Model, tea.Cmd) { + switch a.kind { + case actionKick: + m.pending = m.kickPlan() + m.mode = modeReview + return m, nil + case actionDelete: + m.mode = modeDeleteConfirm + m.confirmName = "" + m.formInput.SetValue("") + m.formInput.Placeholder = m.detail.Name + m.formInput.Focus() + return m, nil + case actionTarball: + m.mode = modeTarball + dir := filepath.Join(".", m.detail.Name+"-tarball") + m.formInput.SetValue(dir) + m.formInput.Placeholder = dir + m.formInput.Focus() + return m, nil + case actionEdit: + return m.beginEditForm(), nil + case actionClone: + return m.beginCloneForm(), nil + case actionWorkbench: + m.mode = modeWorkbench + m.wbTemplate = true + m.formInput.SetValue("") + m.formInput.Placeholder = "./values.yaml.liquid" + m.formInput.Focus() + return m, nil } - return m.updateList(action) + return m, nil +} + +func (m Model) updateReview(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.mode = modeDetail + return m, nil + case keyActionConfirm: + return m, m.beginPending() + } + return m, nil +} + +func (m Model) updateResult(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionBack: + if m.pending.kind == actionDelete && m.result == "ok" { + m.mode = modeList + return m, m.beginList(m.after) + } + if m.pending.create != nil && m.result == "ok" { + m.mode = modeDetail + return m, nil + } + m.mode = modeDetail + return m, nil + case keyActionConfirm: + if m.result == "ok" { + if m.pending.kind == actionDelete { + m.mode = modeList + return m, m.beginList(m.after) + } + m.mode = modeDetail + if m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + return m, nil + } + m.mode = modeReview + return m, nil + case keyActionRefresh: + if m.result != "ok" { + m.mode = modeReview + return m, nil + } + } + return m, nil +} + +func (m Model) updateDeleteConfirm(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.formInput.Blur() + m.mode = modeDetail + return m, nil + case keyActionConfirm: + if strings.TrimSpace(m.formInput.Value()) != m.detail.Name { + m.err = fmt.Errorf("name does not match %q", m.detail.Name) + return m, nil + } + m.formInput.Blur() + m.err = nil + m.pending = m.deletePlan() + m.mode = modeReview + return m, nil + } + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd +} + +func (m Model) updateTarball(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.formInput.Blur() + m.mode = modeDetail + return m, nil + case keyActionConfirm: + dir := strings.TrimSpace(m.formInput.Value()) + if dir == "" { + dir = filepath.Join(".", m.detail.Name+"-tarball") + } + m.formInput.Blur() + m.pending = pendingOp{ + kind: actionTarball, + title: "Download tarball · " + m.detail.Name, + cli: "plural cd services tarball " + m.detail.ID + " --dir " + dir, + tarball: dir, + lines: []string{ + "Action Download tarball", + "Service " + m.detail.Name, + "Directory " + dir, + }, + } + m.mode = modeReview + return m, nil + } + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd +} + +func (m Model) beginCreateForm() Model { + m.mode = modeCreate + m.formFields = []formField{ + {label: "Name", key: "name"}, + {label: "Namespace", key: "namespace"}, + {label: "Repo ID", key: "repo"}, + {label: "Git ref", key: "ref"}, + {label: "Git folder", key: "folder"}, + {label: "Kustomize", key: "kustomize"}, + {label: "Version", key: "version"}, + } + m.formIndex = 0 + m.formDryRun = false + m.formValues = map[string]string{"namespace": "default", "version": "0.0.1", "ref": "main"} + m.formInput.SetValue("") + m.formInput.Placeholder = "service name" + m.formInput.Focus() + m.err = nil + return m +} + +func (m Model) beginEditForm() Model { + m.mode = modeEdit + m.formFields = []formField{ + {label: "Git ref", key: "ref"}, + {label: "Git folder", key: "folder"}, + {label: "Kustomize", key: "kustomize"}, + {label: "Version", key: "version"}, + } + m.formIndex = 0 + m.formDryRun = false + m.formValues = map[string]string{ + "ref": m.detail.GitRef, + "folder": m.detail.GitFolder, + "version": "0.0.1", + } + m.formInput.SetValue(m.formValues["ref"]) + m.formInput.Placeholder = "git ref" + m.formInput.Focus() + m.err = nil + return m +} + +func (m Model) beginCloneForm() Model { + m.mode = modeClone + m.formFields = []formField{ + {label: "Dest cluster ID", key: "cluster"}, + {label: "Name", key: "name"}, + {label: "Namespace", key: "namespace"}, + } + m.formIndex = 0 + m.formValues = map[string]string{ + "name": m.detail.Name + "-clone", + "namespace": m.detail.Namespace, + "cluster": m.detail.ClusterID, + } + m.formInput.SetValue(m.formValues["cluster"]) + m.formInput.Placeholder = "destination cluster id" + m.formInput.Focus() + m.err = nil + return m +} + +func (m Model) updateForm(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.formInput.Blur() + if m.mode == modeCreate { + m.mode = modeList + } else { + m.mode = modeDetail + } + return m, nil + case keyActionConfirm: + m.saveFormField() + if m.formIndex < len(m.formFields)-1 { + m.formIndex++ + m.loadFormField() + return m, nil + } + return m.submitForm() + case keyActionMoveDown: + m.saveFormField() + if m.formIndex < len(m.formFields)-1 { + m.formIndex++ + m.loadFormField() + } + return m, nil + case keyActionMoveUp: + m.saveFormField() + if m.formIndex > 0 { + m.formIndex-- + m.loadFormField() + } + return m, nil + } + if key.Text == "d" && key.Mod == tea.ModCtrl { + // ignore + } + if key.Keystroke() == "ctrl+d" { + m.formDryRun = !m.formDryRun + return m, nil + } + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd +} + +func (m *Model) saveFormField() { + if m.formValues == nil { + m.formValues = map[string]string{} + } + if m.formIndex >= 0 && m.formIndex < len(m.formFields) { + m.formValues[m.formFields[m.formIndex].key] = strings.TrimSpace(m.formInput.Value()) + } +} + +func (m *Model) loadFormField() { + if m.formIndex < 0 || m.formIndex >= len(m.formFields) { + return + } + field := m.formFields[m.formIndex] + m.formInput.SetValue(m.formValues[field.key]) + m.formInput.Placeholder = field.label + m.formInput.Focus() +} + +func (m Model) submitForm() (Model, tea.Cmd) { + m.formInput.Blur() + switch m.mode { + case modeCreate: + input := servicesbridge.CreateInput{ + ClusterID: m.cluster.ID, + Name: m.formValues["name"], + Namespace: m.formValues["namespace"], + RepoID: m.formValues["repo"], + GitRef: m.formValues["ref"], + GitFolder: m.formValues["folder"], + Kustomize: m.formValues["kustomize"], + Version: m.formValues["version"], + DryRun: m.formDryRun, + } + m.pending = pendingOp{ + kind: actionCreate, + title: "Create service · " + input.Name, + cli: fmt.Sprintf("plural cd services create %s --name %s --repo-id %s --git-ref %s --git-folder %s", clusterLabel(m.cluster), input.Name, input.RepoID, input.GitRef, input.GitFolder), + create: &input, + lines: []string{ + "Action Create service", + "Cluster " + clusterLabel(m.cluster), + "Name " + input.Name, + "Namespace " + input.Namespace, + "Repo " + input.RepoID, + "Git " + input.GitRef + " / " + input.GitFolder, + fmt.Sprintf("Dry-run %v (Console attribute)", input.DryRun), + }, + } + m.mode = modeReview + return m, nil + case modeEdit: + dry := m.formDryRun + input := servicesbridge.UpdateInput{ + ID: m.detail.ID, + GitRef: m.formValues["ref"], + GitFolder: m.formValues["folder"], + Kustomize: m.formValues["kustomize"], + Version: m.formValues["version"], + DryRun: &dry, + } + m.pending = pendingOp{ + kind: actionEdit, + title: "Update · " + m.detail.Name, + cli: "plural cd services update " + m.detail.ID, + update: &input, + lines: []string{ + "Action Update service", + "Service " + m.detail.Name, + "Git " + input.GitRef + " / " + input.GitFolder, + "Version " + input.Version, + fmt.Sprintf("Dry-run %v", dry), + }, + } + m.mode = modeReview + return m, nil + case modeClone: + input := servicesbridge.CloneInput{ + SourceID: m.detail.ID, + DestClusterID: m.formValues["cluster"], + Name: m.formValues["name"], + Namespace: m.formValues["namespace"], + } + m.pending = pendingOp{ + kind: actionClone, + title: "Clone · " + m.detail.Name, + cli: "plural cd services clone " + input.DestClusterID + " " + m.detail.ID + " --name " + input.Name, + clone: &input, + lines: []string{ + "Action Clone service", + "Source " + m.detail.Name, + "Dest " + input.DestClusterID, + "Name " + input.Name, + "Namespace " + input.Namespace, + }, + } + m.mode = modeReview + return m, nil + } + return m, nil +} + +func (m Model) updateWorkbench(action keyAction, key tea.KeyPressMsg, text string) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.formInput.Blur() + m.mode = modeDetail + return m, nil + case keyActionConfirm: + m.mode = modeResult + m.result = "ok" + m.pending = pendingOp{kind: actionWorkbench, title: "Workbench · " + m.detail.Name} + file := strings.TrimSpace(m.formInput.Value()) + mode := "template" + if !m.wbTemplate { + mode = "lua" + } + m.opLog = []string{ + "Dry-run workbench — rendering is CLI-backed for now.", + "", + fmt.Sprintf(" plural cd services %s --file %q --service %s/%s", mode, file, clusterLabel(servicesbridge.Cluster{Handle: m.detail.ClusterHandle, Name: m.detail.ClusterName}), m.detail.Name), + } + m.formInput.Blur() + return m, nil + } + if text == "tab" || key.Keystroke() == "tab" { + m.wbTemplate = !m.wbTemplate + return m, nil + } + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd } func (m Model) updateClusters(action keyAction) (Model, tea.Cmd) { @@ -329,11 +873,18 @@ func (m Model) updateClusters(action keyAction) (Model, tea.Cmd) { m.filterInput.Placeholder = "filter clusters" m.filterInput.SetValue(m.clusterFilter) m.filterInput.Focus() + m.formInput = m.filterInput } return m, nil } -func (m Model) updateList(action keyAction) (Model, tea.Cmd) { +func (m Model) updateList(action keyAction, text string) (Model, tea.Cmd) { + if action == keyActionCreate || text == "n" { + if m.cluster.ID == "" { + return m, nil + } + return m.beginCreateForm(), nil + } switch action { case keyActionMoveUp: m.cursor = clampCursor(m.cursor-1, len(m.page.Items)) @@ -357,6 +908,7 @@ func (m Model) updateList(action keyAction) (Model, tea.Cmd) { m.filterInput.Placeholder = "filter services" m.filterInput.SetValue(m.serviceFilter) m.filterInput.Focus() + m.formInput = m.filterInput case keyActionNextPage: if !m.page.HasNext || m.page.EndCursor == "" { return m, nil diff --git a/tui/screens/services/model_test.go b/tui/screens/services/model_test.go index 6a5207d7c..efda7f815 100644 --- a/tui/screens/services/model_test.go +++ b/tui/screens/services/model_test.go @@ -21,6 +21,8 @@ type fakeLoader struct { detail servicesbridge.Detail err error listedID string + kicked string + deleted string } func (f *fakeLoader) ListClusters(context.Context, string) ([]servicesbridge.Cluster, error) { @@ -33,6 +35,26 @@ func (f *fakeLoader) List(_ context.Context, clusterID string, _ *string, _ stri func (f *fakeLoader) Get(context.Context, string) (servicesbridge.Detail, error) { return f.detail, f.err } +func (f *fakeLoader) Kick(_ context.Context, id string) (servicesbridge.Detail, error) { + f.kicked = id + return f.detail, f.err +} +func (f *fakeLoader) Delete(_ context.Context, id string) error { + f.deleted = id + return f.err +} +func (f *fakeLoader) Create(context.Context, servicesbridge.CreateInput) (servicesbridge.Detail, error) { + return f.detail, f.err +} +func (f *fakeLoader) Update(context.Context, servicesbridge.UpdateInput) (servicesbridge.Detail, error) { + return f.detail, f.err +} +func (f *fakeLoader) Clone(context.Context, servicesbridge.CloneInput) (servicesbridge.Detail, error) { + return f.detail, f.err +} +func (f *fakeLoader) DownloadTarball(context.Context, string, string) (string, error) { + return "/tmp/tarball", f.err +} func loadClusters(t *testing.T, model Model) Model { t.Helper() @@ -111,6 +133,60 @@ func TestNoConsoleNavigatesToAccess(t *testing.T) { } } +func TestKickFromDetail(t *testing.T) { + loader := &fakeLoader{ + clusters: []servicesbridge.Cluster{{ID: "c1", Name: "production", Handle: "prod-eu"}}, + page: servicesbridge.Page{Items: []servicesbridge.Summary{{ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY"}}}, + detail: servicesbridge.Detail{Summary: servicesbridge.Summary{ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY"}, ClusterHandle: "prod-eu"}, + } + model := loadClusters(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail { + t.Fatalf("mode = %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'k', Text: "k"}) + if model.mode != modeReview || model.pending.kind != actionKick { + t.Fatalf("review = mode=%d kind=%d", model.mode, model.pending.kind) + } + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("expected kick command") + } + model, _ = model.Update(cmd()) + if model.mode != modeResult || loader.kicked != "1" || model.result != "ok" { + t.Fatalf("result mode=%d kicked=%q result=%q", model.mode, loader.kicked, model.result) + } +} + +func TestDeleteRequiresTypedName(t *testing.T) { + loader := &fakeLoader{ + clusters: []servicesbridge.Cluster{{ID: "c1", Handle: "prod-eu"}}, + page: servicesbridge.Page{Items: []servicesbridge.Summary{{ID: "1", Name: "api"}}}, + detail: servicesbridge.Detail{Summary: servicesbridge.Summary{ID: "1", Name: "api"}}, + } + model := loadClusters(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + model, _ = model.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + if model.mode != modeDeleteConfirm { + t.Fatalf("mode = %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.err == nil { + t.Fatal("expected name mismatch error") + } + model.formInput.SetValue("api") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeReview || model.pending.kind != actionDelete { + t.Fatalf("expected delete review, mode=%d kind=%d", model.mode, model.pending.kind) + } +} + func TestBackFromClustersReturnsDeployments(t *testing.T) { model := loadClusters(t, New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII))) _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) diff --git a/tui/screens/services/testdata/services-detail-120.golden b/tui/screens/services/testdata/services-detail-120.golden index fe9d6e42d..4df4d543e 100644 --- a/tui/screens/services/testdata/services-detail-120.golden +++ b/tui/screens/services/testdata/services-detail-120.golden @@ -1,21 +1,24 @@ - Plural Services HEALTHY + Plural Services · api HEALTHY ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - ╭─ › api ──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + ╭─ Summary ────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Status HEALTHY │ │ Namespace default │ - │ Cluster prod-eu · production │ + │ Cluster @prod-eu · production │ │ Revision main · 91ca21f0 │ │ Git main / services/api │ │ Components 13 / 14 synced │ - │ │ - │ Errors │ - │ ✗ Deployment/api exceeded rollout deadline │ - │ │ - │ │ - │ │ + │ 1 errors │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭─ › Actions ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ › k Kick force sync now │ + │ e Edit git ref · folder · config · version │ + │ c Clone onto another cluster │ + │ t Tarball download locally │ + │ m Template… liquid / tpl / lua workbench │ + │ d Delete remove service [destructive] │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -24,7 +27,4 @@ - - - - r refresh · esc back · ctrl+c quit + ↑/↓ actions · enter · k e c t m d · r refresh · esc list diff --git a/tui/screens/services/testdata/services-detail-80.golden b/tui/screens/services/testdata/services-detail-80.golden index 3b6813cbd..7fbcd5ffa 100644 --- a/tui/screens/services/testdata/services-detail-80.golden +++ b/tui/screens/services/testdata/services-detail-80.golden @@ -1,24 +1,24 @@ - Plural Services HEALTHY + Plural Services · api HEALTHY ──────────────────────────────────────────────────────────────────────────── - ╭─ › api ──────────────────────────────────────────────────────────────────╮ + ╭─ Summary ────────────────────────────────────────────────────────────────╮ │ Status HEALTHY │ │ Namespace default │ - │ Cluster prod-eu · production │ + │ Cluster @prod-eu · production │ │ Revision main · 91ca21f0 │ │ Git main / services/api │ │ Components 13 / 14 synced │ - │ │ - │ Errors │ - │ ✗ Deployment/api exceeded rollout deadline │ - │ │ - │ │ - │ │ + │ 1 errors │ ╰──────────────────────────────────────────────────────────────────────────╯ + ╭─ › Actions ──────────────────────────────────────────────────────────────╮ + │ › k Kick force sync now │ + │ e Edit git ref · folder · config · version │ + │ c Clone onto another cluster │ + │ t Tarball download locally │ + │ m Template… liquid / tpl / lua workbench │ + │ d Delete remove service [destructive] │ + ╰──────────────────────────────────────────────────────────────────────────╯ - - - - r refresh · esc back · ctrl+c quit + ↑/↓ · enter · letters · r · esc diff --git a/tui/screens/services/testdata/services-list-120.golden b/tui/screens/services/testdata/services-list-120.golden index a4eb8fbd8..48cab48b5 100644 --- a/tui/screens/services/testdata/services-list-120.golden +++ b/tui/screens/services/testdata/services-list-120.golden @@ -7,7 +7,7 @@ │ worker jobs FAILED main services/worker │ │ canary default SYNCED release services/canary │ │ │ - │ page · n next │ + │ page · ] next │ │ │ │ │ │ │ @@ -27,4 +27,4 @@ - ↑/↓ select · enter open · / filter · n/p page · r refresh · esc clusters + ↑/↓ · enter detail · n create · / filter · ]/[ page · r refresh · esc diff --git a/tui/screens/services/testdata/services-list-80.golden b/tui/screens/services/testdata/services-list-80.golden index ac83618ff..4692ffe30 100644 --- a/tui/screens/services/testdata/services-list-80.golden +++ b/tui/screens/services/testdata/services-list-80.golden @@ -7,7 +7,7 @@ │ worker jobs FAILED main services/wor… │ │ canary default SYNCED release services/… │ │ │ - │ page · n next │ + │ page · ] next │ │ │ │ │ │ │ @@ -21,4 +21,4 @@ - ↑/↓ · enter · / filter · n/p page · esc clusters + ↑/↓ · enter · n create · / · esc diff --git a/tui/screens/services/view.go b/tui/screens/services/view.go index d27ce4e89..11ed73646 100644 --- a/tui/screens/services/view.go +++ b/tui/screens/services/view.go @@ -18,20 +18,58 @@ func (m Model) View(width, height int) string { contentWidth := page.ContentWidth(width) status := m.headerStatus() body, help := m.bodyAndHelp(contentWidth) - return page.Render(m.theme, width, height, "Services", status, body, help) + title := "Services" + switch m.mode { + case modeReview, modeOperating, modeResult: + title = m.pending.title + if title == "" { + title = "Services" + } + case modeDeleteConfirm: + title = "Delete · " + m.detail.Name + case modeTarball: + title = "Download tarball · " + m.detail.Name + case modeCreate: + title = "Create service · " + clusterLabel(m.cluster) + case modeEdit: + title = "Edit · " + m.detail.Name + case modeClone: + title = "Clone · " + m.detail.Name + case modeWorkbench: + title = "Workbench · " + m.detail.Name + case modeDetail: + title = "Services · " + m.detail.Name + } + return page.Render(m.theme, width, height, title, status, body, help) } func (m Model) headerStatus() string { - if m.loading { - return m.theme.Warning.Render("◌ loading") + if m.loading || m.mode == modeOperating { + return m.theme.Warning.Render("◌ working") } if m.needsAuth { return m.theme.Warning.Render("○ connect Console") } - if m.err != nil { - return m.theme.Danger.Render("✗ load failed") + if m.err != nil && m.mode != modeResult { + return m.theme.Danger.Render("✗ attention") } switch m.mode { + case modeReview: + if m.pending.danger { + return m.theme.Danger.Render("destructive") + } + return m.theme.Warning.Render("review") + case modeResult: + if m.result == "ok" || (m.result != "failed" && m.result != "") { + if m.pending.kind == actionTarball && m.result != "ok" { + return m.theme.Success.Render("saved") + } + if m.result == "failed" { + return m.theme.Danger.Render("failed") + } + return m.theme.Success.Render("done") + } + return m.theme.Danger.Render("failed") case modeDetail: return m.statusBadge(m.detail.Status) case modeList: @@ -44,8 +82,10 @@ func (m Model) headerStatus() string { return m.theme.Muted.Render(fmt.Sprintf("%d matching clusters", len(m.clusters))) } return m.theme.Success.Render(fmt.Sprintf("%d clusters", len(m.clusters))) + case modeWorkbench: + return m.theme.Muted.Render("dry-run only") default: - return m.theme.Muted.Render("select cluster") + return m.theme.Muted.Render("services") } } @@ -69,21 +109,134 @@ func (m Model) bodyAndHelp(width int) (string, string) { } return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" } - if m.mode == modeDetail { - return page.Panel(m.theme, m.detail.Name, m.detailLines(), width, 14, true), "r refresh · esc back · ctrl+c quit" - } - if m.mode == modeClusters { + switch m.mode { + case modeReview: + lines := append([]string{}, m.pending.lines...) + lines = append(lines, "", m.theme.Muted.Render("Equivalent CLI"), " "+m.pending.cli) + return page.Panel(m.theme, "Plan (immutable)", lines, width, 12, true), "enter confirm · esc back" + case modeOperating: + lines := []string{m.theme.Warning.Render("● Running…"), ""} + lines = append(lines, m.opLog...) + return page.Panel(m.theme, "Operation", lines, width, 10, true), "ctrl+c quit" + case modeResult: + head := m.theme.Success.Render("✓ Success") + help := "enter detail · esc back" + if m.result == "failed" { + head = m.theme.Danger.Render("✗ Failed") + help = "enter retry review · esc detail" + } else if m.pending.kind == actionDelete { + help = "enter list · esc list" + } else if m.pending.kind == actionTarball { + head = m.theme.Success.Render("✓ Wrote " + m.result) + } else if m.pending.kind == actionWorkbench { + head = m.theme.Muted.Render("CLI equivalent") + help = "esc detail" + } + lines := []string{head, ""} + lines = append(lines, m.opLog...) + return page.Panel(m.theme, "Result", lines, width, 12, true), help + case modeDeleteConfirm: + lines := []string{ + m.theme.Danger.Render("This permanently deletes the Console service record."), + m.theme.Muted.Render("Cluster workloads are not automatically uninstalled."), + "", + "Service " + m.detail.Name + " · " + clusterLabel(m.cluster), + "Type the service name to confirm:", + "", + m.formInput.View(), + } + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + return page.Panel(m.theme, "Confirm deletion", lines, width, 11, true), "enter continue · esc cancel" + case modeTarball: + lines := []string{ + "Directory " + m.formInput.View(), + "", + m.theme.Muted.Render("Fetches deploy token + tarball and unpacks into that directory."), + } + return page.Panel(m.theme, "Destination", lines, width, 7, true), "enter review · esc cancel" + case modeCreate, modeEdit, modeClone: + return m.formView(width) + case modeWorkbench: + mode := "› Template (.liquid / .tpl) Lua engine" + if !m.wbTemplate { + mode = " Template (.liquid / .tpl) › Lua engine" + } + lines := []string{ + mode, + "", + "File " + m.formInput.View(), + "Context service " + m.detail.Name + " " + clusterLabel(m.cluster), + "", + m.theme.Muted.Render("Enter shows the CLI equivalent. Full in-TUI render lands next."), + } + return page.Panel(m.theme, "Workbench", lines, width, 10, true), "tab mode · enter · esc detail" + case modeDetail: + summary := page.Panel(m.theme, "Summary", m.detailLines(), width, 9, false) + actions := page.Panel(m.theme, "Actions", m.actionLines(width), width, 8, true) + help := "↑/↓ actions · enter · k e c t m d · r refresh · esc list" + if width < 100 { + help = "↑/↓ · enter · letters · r · esc" + } + return summary + "\n\n" + actions, help + case modeClusters: help := "↑/↓ select · enter open cluster · / filter · r refresh · esc back" if width < 100 { help = "↑/↓ · enter · / filter · esc back" } return page.Panel(m.theme, m.clusterTitle(), m.clusterLines(width), width, 14, true), help + default: + help := "↑/↓ · enter detail · n create · / filter · ]/[ page · r refresh · esc" + if width < 100 { + help = "↑/↓ · enter · n create · / · esc" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help + } +} + +func (m Model) formView(width int) (string, string) { + lines := make([]string, 0, len(m.formFields)+3) + for i, field := range m.formFields { + value := m.formValues[field.key] + cursor := " " + if i == m.formIndex { + cursor = "› " + value = m.formInput.View() + } + lines = append(lines, cursor+pad(field.label, 12)+" "+value) } - help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc clusters" - if width < 100 { - help = "↑/↓ · enter · / filter · n/p page · esc clusters" + lines = append(lines, "", fmt.Sprintf("Dry-run attribute %v (ctrl+d toggle)", m.formDryRun)) + step := fmt.Sprintf("field %d/%d", m.formIndex+1, len(m.formFields)) + help := "↑/↓ fields · enter next/review · esc cancel · " + step + return page.Panel(m.theme, "Form", lines, width, 12, true), help +} + +func (m Model) actionLines(width int) []string { + lines := make([]string, 0, len(detailActions())) + for i, a := range detailActions() { + cursor := " " + if i == m.actionCursor { + cursor = "› " + } + label := fmt.Sprintf("%s %-10s %s", a.shortcut, a.title, a.blurb) + if a.danger { + label += " [destructive]" + if i == m.actionCursor { + lines = append(lines, cursor+m.theme.Danger.Render(label)) + } else { + lines = append(lines, cursor+m.theme.Muted.Render(label)) + } + continue + } + if i == m.actionCursor { + lines = append(lines, cursor+m.theme.Title.Render(label)) + } else { + lines = append(lines, cursor+m.theme.Body.Render(label)) + } + _ = width } - return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help + return lines } func (m Model) clusterTitle() string { @@ -138,7 +291,7 @@ func (m Model) listLines(width int) []string { return []string{m.theme.Danger.Render("✗ Unable to load services"), m.theme.Danger.Render("Error " + m.err.Error()), m.theme.Muted.Render("Press r to retry.")} } if len(m.page.Items) == 0 { - return []string{m.theme.Warning.Render("○ No services found"), m.theme.Muted.Render(" Adjust the filter or choose another cluster.")} + return []string{m.theme.Warning.Render("○ No services found"), m.theme.Muted.Render(" Press n to create, or adjust the filter.")} } nameWidth := max(12, min(28, width/3)) lines := []string{m.theme.Muted.Render(" " + pad("NAME", nameWidth) + " " + pad("NAMESPACE", 14) + " " + pad("STATUS", 10) + " GIT")} @@ -157,10 +310,10 @@ func (m Model) listLines(width int) []string { if m.page.HasNext || len(m.prevCursors) > 0 { pager := "page" if len(m.prevCursors) > 0 { - pager += " · p prev" + pager += " · [ prev" } if m.page.HasNext { - pager += " · n next" + pager += " · ] next" } lines = append(lines, "", m.theme.Muted.Render(pager)) } @@ -176,9 +329,9 @@ func (m Model) detailLines() []string { } cluster := m.detail.ClusterName if m.detail.ClusterHandle != "" { - cluster = m.detail.ClusterHandle + cluster = "@" + m.detail.ClusterHandle if m.detail.ClusterName != "" && m.detail.ClusterName != m.detail.ClusterHandle { - cluster = m.detail.ClusterHandle + " · " + m.detail.ClusterName + cluster += " · " + m.detail.ClusterName } } if cluster == "" { @@ -205,19 +358,9 @@ func (m Model) detailLines() []string { m.labelValue("Revision", revision), m.labelValue("Git", git), m.labelValue("Components", fmt.Sprintf("%d / %d synced", m.detail.Synced, m.detail.Components)), - "", - } - if len(m.detail.Errors) == 0 { - lines = append(lines, m.theme.Success.Render("✓ No service errors")) - return lines } - lines = append(lines, m.theme.Danger.Render("Errors")) - for _, item := range m.detail.Errors { - source := item.Source - if source == "" { - source = "sync" - } - lines = append(lines, m.theme.Danger.Render("✗ "+source)+" "+item.Message) + if len(m.detail.Errors) > 0 { + lines = append(lines, m.theme.Danger.Render(fmt.Sprintf("%d errors", len(m.detail.Errors)))) } return lines } From c9239198223a965f12b73d46a479f5cb60efb73a Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Wed, 29 Jul 2026 15:19:49 +0200 Subject: [PATCH 05/16] fix get tarball --- pkg/bridge/services/actions.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/bridge/services/actions.go b/pkg/bridge/services/actions.go index cd6204d9d..8874a3c01 100644 --- a/pkg/bridge/services/actions.go +++ b/pkg/bridge/services/actions.go @@ -3,12 +3,12 @@ package services import ( "context" "fmt" - "io" "os" "path/filepath" "strings" gqlclient "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/polly/fs" "github.com/samber/lo" "github.com/pluralsh/plural-cli/pkg/bridge" @@ -265,8 +265,8 @@ func (s *Service) DownloadTarball(ctx context.Context, id, dir string) (string, if err != nil { return "", err } - defer func(c io.Closer) { _ = c.Close() }(resp) - if err := utils.Untar(dir, resp); err != nil { + defer resp.Close() + if err := fs.Untar(dir, resp); err != nil { return "", err } abs, err := filepath.Abs(dir) From 504876eae6465b2af08f80dd4549efa6b3290f8c Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Wed, 29 Jul 2026 15:49:52 +0200 Subject: [PATCH 06/16] improve service clone --- tui/screens/services/model.go | 102 ++++++++++++++++++++++++----- tui/screens/services/model_test.go | 53 +++++++++++++++ tui/screens/services/view.go | 33 +++++++++- 3 files changed, 167 insertions(+), 21 deletions(-) diff --git a/tui/screens/services/model.go b/tui/screens/services/model.go index 45e3c5a9a..5ecf859e8 100644 --- a/tui/screens/services/model.go +++ b/tui/screens/services/model.go @@ -31,6 +31,7 @@ const ( modeCreate modeEdit modeClone + modeCloneCluster modeWorkbench ) @@ -143,6 +144,9 @@ type Model struct { formDryRun bool wbTemplate bool confirmName string + + pickingCloneDest bool + cloneDest servicesbridge.Cluster } type formField struct { @@ -262,6 +266,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.cursor = 0 m.err = nil m.needsAuth = false + m.pickingCloneDest = false + m.cloneDest = servicesbridge.Cluster{} if m.loader == nil { m.loading = false return m, nil @@ -277,7 +283,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if msg.err == nil { m.clusters = msg.clusters m.clusterCursor = clampCursor(m.clusterCursor, len(m.clusters)) - m.mode = modeClusters + if m.pickingCloneDest { + m.mode = modeCloneCluster + } else { + m.mode = modeClusters + } } return m, nil case listedMsg: @@ -374,6 +384,8 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { return m.updateTarball(action, key) case modeCreate, modeEdit, modeClone: return m.updateForm(action, key) + case modeCloneCluster: + return m.updateCloneCluster(action) case modeWorkbench: return m.updateWorkbench(action, key, text) case modeDetail: @@ -409,19 +421,26 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { func (m Model) updateFilter(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { switch action { case keyActionBack: - m.mode = modeList - if m.filteringCluster { + m.filterInput.Blur() + if m.pickingCloneDest { + m.mode = modeCloneCluster + } else if m.filteringCluster { m.mode = modeClusters + } else { + m.mode = modeList } - m.filterInput.Blur() return m, nil case keyActionConfirm: value := strings.TrimSpace(m.filterInput.Value()) m.filterInput.Blur() - if m.filteringCluster { + if m.pickingCloneDest || m.filteringCluster { m.clusterFilter = value - m.mode = modeClusters m.clusterCursor = 0 + if m.pickingCloneDest { + m.mode = modeCloneCluster + } else { + m.mode = modeClusters + } return m, m.beginClusters() } m.serviceFilter = value @@ -495,7 +514,7 @@ func (m Model) openAction(a detailAction) (Model, tea.Cmd) { case actionEdit: return m.beginEditForm(), nil case actionClone: - return m.beginCloneForm(), nil + return m.beginClone() case actionWorkbench: m.mode = modeWorkbench m.wbTemplate = true @@ -650,21 +669,63 @@ func (m Model) beginEditForm() Model { return m } +func (m Model) beginClone() (Model, tea.Cmd) { + m.pickingCloneDest = true + m.cloneDest = servicesbridge.Cluster{} + m.clusterFilter = "" + m.clusterCursor = 0 + m.err = nil + m.mode = modeCloneCluster + m.loading = true + return m, m.beginClusters() +} + +func (m Model) updateCloneCluster(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.pickingCloneDest = false + m.clusterFilter = "" + m.mode = modeDetail + m.err = nil + return m, nil + case keyActionMoveUp: + m.clusterCursor = clampCursor(m.clusterCursor-1, len(m.clusters)) + case keyActionMoveDown: + m.clusterCursor = clampCursor(m.clusterCursor+1, len(m.clusters)) + case keyActionConfirm: + if len(m.clusters) == 0 { + return m, nil + } + m.cloneDest = m.clusters[m.clusterCursor] + m.pickingCloneDest = false + m.clusterFilter = "" + return m.beginCloneForm(), nil + case keyActionRefresh: + return m, m.beginClusters() + case keyActionFilter: + m.mode = modeFilter + m.filteringCluster = true + m.filterInput.Placeholder = "filter destination clusters" + m.filterInput.SetValue(m.clusterFilter) + m.filterInput.Focus() + m.formInput = m.filterInput + } + return m, nil +} + func (m Model) beginCloneForm() Model { m.mode = modeClone m.formFields = []formField{ - {label: "Dest cluster ID", key: "cluster"}, {label: "Name", key: "name"}, {label: "Namespace", key: "namespace"}, } m.formIndex = 0 m.formValues = map[string]string{ "name": m.detail.Name + "-clone", - "namespace": m.detail.Namespace, - "cluster": m.detail.ClusterID, + "namespace": loCoalesce(m.detail.Namespace, "default"), } - m.formInput.SetValue(m.formValues["cluster"]) - m.formInput.Placeholder = "destination cluster id" + m.formInput.SetValue(m.formValues["name"]) + m.formInput.Placeholder = "cloned service name" m.formInput.Focus() m.err = nil return m @@ -674,9 +735,13 @@ func (m Model) updateForm(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd switch action { case keyActionBack: m.formInput.Blur() - if m.mode == modeCreate { + switch m.mode { + case modeCreate: m.mode = modeList - } else { + case modeClone: + m.pickingCloneDest = true + m.mode = modeCloneCluster + default: m.mode = modeDetail } return m, nil @@ -794,19 +859,20 @@ func (m Model) submitForm() (Model, tea.Cmd) { case modeClone: input := servicesbridge.CloneInput{ SourceID: m.detail.ID, - DestClusterID: m.formValues["cluster"], + DestClusterID: m.cloneDest.ID, Name: m.formValues["name"], Namespace: m.formValues["namespace"], } + dest := clusterLabel(m.cloneDest) m.pending = pendingOp{ kind: actionClone, title: "Clone · " + m.detail.Name, - cli: "plural cd services clone " + input.DestClusterID + " " + m.detail.ID + " --name " + input.Name, + cli: fmt.Sprintf("plural cd services clone %s %s --name %s --namespace %s", dest, m.detail.ID, input.Name, input.Namespace), clone: &input, lines: []string{ "Action Clone service", - "Source " + m.detail.Name, - "Dest " + input.DestClusterID, + "Source " + m.detail.Name + " · " + clusterLabel(m.cluster), + "Dest " + dest, "Name " + input.Name, "Namespace " + input.Namespace, }, diff --git a/tui/screens/services/model_test.go b/tui/screens/services/model_test.go index efda7f815..75240f688 100644 --- a/tui/screens/services/model_test.go +++ b/tui/screens/services/model_test.go @@ -8,6 +8,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" "github.com/pluralsh/plural-cli/pkg/bridge" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" @@ -187,6 +188,58 @@ func TestDeleteRequiresTypedName(t *testing.T) { } } +func TestClonePicksDestinationCluster(t *testing.T) { + loader := &fakeLoader{ + clusters: []servicesbridge.Cluster{ + {ID: "c1", Name: "production", Handle: "prod-eu"}, + {ID: "c2", Name: "staging", Handle: "staging"}, + }, + page: servicesbridge.Page{Items: []servicesbridge.Summary{{ID: "1", Name: "api", Namespace: "default"}}}, + detail: servicesbridge.Detail{ + Summary: servicesbridge.Summary{ID: "1", Name: "api", Namespace: "default"}, + ClusterID: "c1", ClusterHandle: "prod-eu", ClusterName: "production", + }, + } + model := loadClusters(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + + model, cmd = model.Update(tea.KeyPressMsg{Code: 'c', Text: "c"}) + if cmd == nil { + t.Fatal("expected cluster reload for clone") + } + model, _ = model.Update(cmd()) + if model.mode != modeCloneCluster || !model.pickingCloneDest { + t.Fatalf("clone cluster mode = %d picking=%v", model.mode, model.pickingCloneDest) + } + if !strings.Contains(model.View(80, 24), "Choose destination cluster") { + t.Fatalf("missing destination picker:\n%s", model.View(80, 24)) + } + if !strings.Contains(ansi.Strip(model.View(80, 24)), "(source)") { + t.Fatalf("source cluster not marked:\n%s", ansi.Strip(model.View(80, 24))) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeClone || model.cloneDest.ID != "c2" { + t.Fatalf("clone form dest = %#v mode=%d", model.cloneDest, model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // accept name + model.formInput.SetValue("default") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // accept namespace → review + if model.mode != modeReview || model.pending.clone == nil || model.pending.clone.DestClusterID != "c2" { + t.Fatalf("review = mode=%d pending=%#v", model.mode, model.pending) + } + if model.pending.clone.Name != "api-clone" { + t.Fatalf("clone name = %q", model.pending.clone.Name) + } + if !strings.Contains(strings.Join(model.pending.lines, "\n"), "@staging") { + t.Fatalf("review missing dest label: %#v", model.pending.lines) + } +} + func TestBackFromClustersReturnsDeployments(t *testing.T) { model := loadClusters(t, New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII))) _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) diff --git a/tui/screens/services/view.go b/tui/screens/services/view.go index 11ed73646..ae1f978e5 100644 --- a/tui/screens/services/view.go +++ b/tui/screens/services/view.go @@ -35,6 +35,8 @@ func (m Model) View(width, height int) string { title = "Edit · " + m.detail.Name case modeClone: title = "Clone · " + m.detail.Name + case modeCloneCluster: + title = "Clone · choose destination" case modeWorkbench: title = "Workbench · " + m.detail.Name case modeDetail: @@ -158,6 +160,12 @@ func (m Model) bodyAndHelp(width int) (string, string) { return page.Panel(m.theme, "Destination", lines, width, 7, true), "enter review · esc cancel" case modeCreate, modeEdit, modeClone: return m.formView(width) + case modeCloneCluster: + help := "↑/↓ select · enter use cluster · / filter · r refresh · esc detail" + if width < 100 { + help = "↑/↓ · enter · / filter · esc detail" + } + return page.Panel(m.theme, m.cloneClusterTitle(), m.clusterLines(width), width, 14, true), help case modeWorkbench: mode := "› Template (.liquid / .tpl) Lua engine" if !m.wbTemplate { @@ -196,7 +204,14 @@ func (m Model) bodyAndHelp(width int) (string, string) { } func (m Model) formView(width int) (string, string) { - lines := make([]string, 0, len(m.formFields)+3) + lines := make([]string, 0, len(m.formFields)+4) + if m.mode == modeClone { + lines = append(lines, + m.theme.Muted.Render("Destination "+clusterLabel(m.cloneDest)), + m.theme.Muted.Render("Source "+m.detail.Name+" · "+clusterLabel(m.cluster)), + "", + ) + } for i, field := range m.formFields { value := m.formValues[field.key] cursor := " " @@ -206,12 +221,21 @@ func (m Model) formView(width int) (string, string) { } lines = append(lines, cursor+pad(field.label, 12)+" "+value) } - lines = append(lines, "", fmt.Sprintf("Dry-run attribute %v (ctrl+d toggle)", m.formDryRun)) + if m.mode != modeClone { + lines = append(lines, "", fmt.Sprintf("Dry-run attribute %v (ctrl+d toggle)", m.formDryRun)) + } step := fmt.Sprintf("field %d/%d", m.formIndex+1, len(m.formFields)) - help := "↑/↓ fields · enter next/review · esc cancel · " + step + help := "↑/↓ fields · enter next/review · esc back · " + step return page.Panel(m.theme, "Form", lines, width, 12, true), help } +func (m Model) cloneClusterTitle() string { + if m.clusterFilter != "" { + return "Destination clusters · filter “" + m.clusterFilter + "”" + } + return "Choose destination cluster" +} + func (m Model) actionLines(width int) []string { lines := make([]string, 0, len(detailActions())) for i, a := range detailActions() { @@ -278,6 +302,9 @@ func (m Model) clusterLines(width int) []string { handle = "@" + handle } row := cursor + pad(handle, handleWidth) + " " + pad(cluster.Name, 24) + " " + cluster.ID + if m.mode == modeCloneCluster && cluster.ID != "" && cluster.ID == m.detail.ClusterID { + row += " " + m.theme.Muted.Render("(source)") + } lines = append(lines, ansi.Truncate(row, width-2, "…")) } return lines From 786b8ae97d23f38a3fc76506d4e084cf656a0f5c Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Thu, 30 Jul 2026 12:02:43 +0200 Subject: [PATCH 07/16] add clusters --- cmd/command/tui/tui.go | 3 + pkg/bridge/clusters/clusters.go | 208 +++++++++++++ pkg/bridge/clusters/clusters_test.go | 84 ++++++ tui/app/model.go | 9 + tui/app/model_test.go | 5 + tui/app/view.go | 2 + tui/navigation/navigation.go | 1 + tui/screens/clusters/model.go | 274 ++++++++++++++++++ tui/screens/clusters/model_test.go | 204 +++++++++++++ .../testdata/clusters-detail-120.golden | 30 ++ .../testdata/clusters-detail-80.golden | 24 ++ .../testdata/clusters-list-120.golden | 30 ++ .../clusters/testdata/clusters-list-80.golden | 24 ++ tui/screens/clusters/view.go | 182 ++++++++++++ tui/screens/deployments/model.go | 2 +- tui/screens/deployments/model_test.go | 13 +- .../testdata/deployments-120.golden | 2 +- .../testdata/deployments-80.golden | 2 +- 18 files changed, 1095 insertions(+), 4 deletions(-) create mode 100644 pkg/bridge/clusters/clusters.go create mode 100644 pkg/bridge/clusters/clusters_test.go create mode 100644 tui/screens/clusters/model.go create mode 100644 tui/screens/clusters/model_test.go create mode 100644 tui/screens/clusters/testdata/clusters-detail-120.golden create mode 100644 tui/screens/clusters/testdata/clusters-detail-80.golden create mode 100644 tui/screens/clusters/testdata/clusters-list-120.golden create mode 100644 tui/screens/clusters/testdata/clusters-list-80.golden create mode 100644 tui/screens/clusters/view.go diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go index e9cffa658..eb10eb010 100644 --- a/cmd/command/tui/tui.go +++ b/cmd/command/tui/tui.go @@ -8,6 +8,7 @@ import ( "github.com/pluralsh/plural-cli/pkg/bridge" accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" "github.com/pluralsh/plural-cli/pkg/common" @@ -21,10 +22,12 @@ func Command() cli.Command { auth := bridge.NewAuthService(bridge.PluralAuthFactory{}, 0) access := accessbridge.NewLocalManager("", auth, nil) services := servicesbridge.NewService(access) + clusters := clustersbridge.NewService(access) return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{ Welcome: welcome, Access: access, Services: services, + Clusters: clusters, }) }) } diff --git a/pkg/bridge/clusters/clusters.go b/pkg/bridge/clusters/clusters.go new file mode 100644 index 000000000..3a42e73b1 --- /dev/null +++ b/pkg/bridge/clusters/clusters.go @@ -0,0 +1,208 @@ +// Package clusters exposes read-only Console cluster list/get use cases to +// presentation layers without importing TUI code. +package clusters + +import ( + "context" + "errors" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +var ( + errNoConsole = errors.New("connect a Console profile before browsing Console resources") + errMissingID = errors.New("cluster id is required") + errMissingCluster = errors.New("cluster was not found") +) + +// Summary is a credential-free list row for a Console cluster. +type Summary struct { + ID string + Name string + Handle string + Version string + Distro string +} + +// Tag is a credential-free cluster tag. +type Tag struct { + Name string + Value string +} + +// Detail is the credential-free detail payload for a Console cluster. +type Detail struct { + Summary + Self bool + PingedAt string + Protect bool + DeletedAt string + Project string + Provider string + Tags []Tag + NodePools int +} + +// Loader is the narrow contract consumed by the Clusters screen. +type Loader interface { + List(ctx context.Context, query string) ([]Summary, error) + Get(ctx context.Context, id string) (Detail, error) +} + +// ConsoleResolver supplies the active Console URL and token. +type ConsoleResolver interface { + ActiveConsole(ctx context.Context) (url, token string, err error) +} + +// API is the Console surface required by this package. +type API interface { + ListClusters() (*gqlclient.ListClusters, error) + GetCluster(clusterId, clusterName *string) (*gqlclient.ClusterFragment, error) +} + +// ClientFactory builds a Console API for an authenticated endpoint. +type ClientFactory func(token, url string) (API, error) + +// Service implements Loader against Console GraphQL. +type Service struct { + resolve ConsoleResolver + newClient ClientFactory +} + +// NewService wires production Console credentials and client construction. +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + } +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s.resolve == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + url, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return nil, err + } + factory := s.newClient + if factory == nil { + factory = func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + } + } + return factory(token, url) +} + +func (s *Service) List(ctx context.Context, query string) ([]Summary, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + client, err := s.client(ctx) + if err != nil { + return nil, err + } + result, err := client.ListClusters() + if err != nil { + return nil, err + } + if result == nil || result.Clusters == nil { + return nil, nil + } + items := make([]Summary, 0, len(result.Clusters.Edges)) + for _, edge := range result.Clusters.Edges { + if edge == nil || edge.Node == nil { + continue + } + summary := summaryFromFragment(edge.Node) + if !matchesQuery(summary, query) { + continue + } + items = append(items, summary) + } + return items, nil +} + +func (s *Service) Get(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + cluster, err := client.GetCluster(&id, nil) + if err != nil { + return Detail{}, err + } + if cluster == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingCluster} + } + return detailFromFragment(cluster), nil +} + +func summaryFromFragment(node *gqlclient.ClusterFragment) Summary { + summary := Summary{ID: node.ID, Name: node.Name} + if node.Handle != nil { + summary.Handle = *node.Handle + } + if node.CurrentVersion != nil { + summary.Version = *node.CurrentVersion + } + if node.Distro != nil { + summary.Distro = string(*node.Distro) + } + return summary +} + +func detailFromFragment(cluster *gqlclient.ClusterFragment) Detail { + detail := Detail{Summary: summaryFromFragment(cluster)} + if cluster.Self != nil { + detail.Self = *cluster.Self + } + if cluster.PingedAt != nil { + detail.PingedAt = *cluster.PingedAt + } + if cluster.Protect != nil { + detail.Protect = *cluster.Protect + } + if cluster.DeletedAt != nil { + detail.DeletedAt = *cluster.DeletedAt + } + if cluster.Project != nil { + detail.Project = cluster.Project.Name + } + if cluster.Provider != nil { + detail.Provider = cluster.Provider.Name + if cluster.Provider.Cloud != "" { + detail.Provider = strings.TrimSpace(detail.Provider + " · " + cluster.Provider.Cloud) + } + } + for _, tag := range cluster.Tags { + if tag == nil { + continue + } + detail.Tags = append(detail.Tags, Tag{Name: tag.Name, Value: tag.Value}) + } + detail.NodePools = len(cluster.NodePools) + return detail +} + +func matchesQuery(summary Summary, query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{summary.Name, summary.Handle, summary.ID, summary.Version, summary.Distro}, " ")) + return strings.Contains(haystack, query) +} diff --git a/pkg/bridge/clusters/clusters_test.go b/pkg/bridge/clusters/clusters_test.go new file mode 100644 index 000000000..b6226e7b0 --- /dev/null +++ b/pkg/bridge/clusters/clusters_test.go @@ -0,0 +1,84 @@ +package clusters + +import ( + "context" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeResolver struct { + url, token string + err error +} + +func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) { + return f.url, f.token, f.err +} + +type fakeAPI struct { + clusters *gqlclient.ListClusters + listErr error + detail *gqlclient.ClusterFragment + getErr error +} + +func (f *fakeAPI) ListClusters() (*gqlclient.ListClusters, error) { return f.clusters, f.listErr } +func (f *fakeAPI) GetCluster(*string, *string) (*gqlclient.ClusterFragment, error) { + return f.detail, f.getErr +} + +func TestListAndGet(t *testing.T) { + handle := "prod-eu" + version := "1.30.2" + distro := gqlclient.ClusterDistroEks + pinged := "2026-07-29T10:00:00Z" + api := &fakeAPI{ + clusters: &gqlclient.ListClusters{Clusters: &gqlclient.ListClusters_Clusters{Edges: []*gqlclient.ClusterEdgeFragment{ + {Node: &gqlclient.ClusterFragment{ + ID: "c1", Name: "production", Handle: &handle, + CurrentVersion: &version, Distro: &distro, + }}, + {Node: &gqlclient.ClusterFragment{ID: "c2", Name: "staging"}}, + }}}, + detail: &gqlclient.ClusterFragment{ + ID: "c1", Name: "production", Handle: &handle, + CurrentVersion: &version, Distro: &distro, + Self: lo.ToPtr(true), PingedAt: &pinged, Protect: lo.ToPtr(false), + Project: &gqlclient.TinyProjectFragment{Name: "acme"}, + Tags: []*gqlclient.ClusterTags{{Name: "env", Value: "prod"}}, + NodePools: []*gqlclient.NodePoolFragment{{}, {}}, + }, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + } + + items, err := service.List(t.Context(), "prod") + if err != nil || len(items) != 1 || items[0].Handle != "prod-eu" || items[0].Version != "1.30.2" { + t.Fatalf("List() = %#v, %v", items, err) + } + + detail, err := service.Get(t.Context(), "c1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if !detail.Self || detail.Project != "acme" || detail.NodePools != 2 || len(detail.Tags) != 1 { + t.Fatalf("detail = %#v", detail) + } +} + +func TestGetRequiresID(t *testing.T) { + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return &fakeAPI{}, nil }, + } + _, err := service.Get(t.Context(), "") + if !bridge.IsCode(err, bridge.ErrorInvalid) { + t.Fatalf("Get() error = %v", err) + } +} diff --git a/tui/app/model.go b/tui/app/model.go index 770d6d5bf..d10aab56e 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -8,10 +8,12 @@ import ( tea "charm.land/bubbletea/v2" accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" "github.com/pluralsh/plural-cli/tui/navigation" accessscreen "github.com/pluralsh/plural-cli/tui/screens/access" + clustersscreen "github.com/pluralsh/plural-cli/tui/screens/clusters" deploymentsscreen "github.com/pluralsh/plural-cli/tui/screens/deployments" diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" @@ -24,6 +26,7 @@ type Dependencies struct { Welcome welcomebridge.Loader Access accessbridge.Manager Services servicesbridge.Loader + Clusters clustersbridge.Loader } // Model is the root TUI model. It owns global input and delegates screen state @@ -40,6 +43,7 @@ type Model struct { diagnostics diagnosticsscreen.Model deployments deploymentsscreen.Model services servicesscreen.Model + clusters clustersscreen.Model route navigation.Route } @@ -52,6 +56,7 @@ func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t), deployments: deploymentsscreen.New(ctx, t, ""), services: servicesscreen.New(ctx, dependencies.Services, t), + clusters: clustersscreen.New(ctx, dependencies.Clusters, t), route: navigation.Welcome, quit: key.NewBinding( key.WithKeys("ctrl+c"), @@ -76,6 +81,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.deployments.Init() case navigation.Services: return m, m.services.Init() + case navigation.Clusters: + return m, m.clusters.Init() default: return m, m.welcome.Init() } @@ -103,6 +110,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.deployments, cmd = m.deployments.Update(msg) case navigation.Services: m.services, cmd = m.services.Update(msg) + case navigation.Clusters: + m.clusters, cmd = m.clusters.Update(msg) default: m.welcome, cmd = m.welcome.Update(msg) } diff --git a/tui/app/model_test.go b/tui/app/model_test.go index 0926d4f09..212e5f9f7 100644 --- a/tui/app/model_test.go +++ b/tui/app/model_test.go @@ -55,6 +55,11 @@ func TestModelRoutesScreensWithoutRebuildingShell(t *testing.T) { if routed.route != navigation.Services || !strings.Contains(routed.View().Content, "Services") { t.Fatalf("services route/view = %q\n%s", routed.route, routed.View().Content) } + updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Clusters}) + routed = updated.(Model) + if routed.route != navigation.Clusters || !strings.Contains(routed.View().Content, "Clusters") { + t.Fatalf("clusters route/view = %q\n%s", routed.route, routed.View().Content) + } updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Welcome}) if got := updated.(Model).route; got != navigation.Welcome { t.Fatalf("route = %q", got) diff --git a/tui/app/view.go b/tui/app/view.go index 0ac2fcbd2..a0c5f6324 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -19,6 +19,8 @@ func (m Model) View() tea.View { content = m.deployments.View(m.width, m.height) case navigation.Services: content = m.services.View(m.width, m.height) + case navigation.Clusters: + content = m.clusters.View(m.width, m.height) } view := tea.NewView(content) view.AltScreen = true diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index 4cea2e030..e12b2dcb0 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -14,6 +14,7 @@ const ( Diagnostics Route = "diagnostics" Deployments Route = "deployments" Services Route = "services" + Clusters Route = "clusters" ) // NavigateMsg requests a top-level route change. diff --git a/tui/screens/clusters/model.go b/tui/screens/clusters/model.go new file mode 100644 index 000000000..aff81ec0b --- /dev/null +++ b/tui/screens/clusters/model.go @@ -0,0 +1,274 @@ +// Package clusters implements the read-only Console clusters browser. +package clusters + +import ( + "context" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeList mode = iota + modeDetail + modeFilter +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionFilter + keyActionConnectConsole +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionRefresh: {"r"}, + keyActionFilter: {"/"}, + keyActionConnectConsole: {"c"}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + return keyActionNone +} + +type initMsg struct{} +type listedMsg struct { + items []clustersbridge.Summary + err error + request uint64 +} +type detailMsg struct { + detail clustersbridge.Detail + err error + request uint64 +} + +// Model owns Clusters-screen interaction state. +type Model struct { + ctx context.Context + loader clustersbridge.Loader + theme theme.Theme + mode mode + loading bool + err error + needsAuth bool + request uint64 + + items []clustersbridge.Summary + cursor int + filter string + filterInput textinput.Model + + detail clustersbridge.Detail + detailID string + listCursor int + listFilter string +} + +func New(ctx context.Context, loader clustersbridge.Loader, t theme.Theme) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "filter clusters" + input.CharLimit = 128 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + input.SetStyles(styles) + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, mode: modeList} +} + +func (m Model) Init() tea.Cmd { + return func() tea.Msg { return initMsg{} } +} + +func (m *Model) beginList() tea.Cmd { + m.loading = true + m.request++ + request := m.request + query := m.filter + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + items, err := loader.List(ctx, query) + return listedMsg{items: items, err: err, request: request} + } +} + +func (m *Model) beginDetail(id string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + detail, err := loader.Get(ctx, id) + return detailMsg{detail: detail, err: err, request: request} + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.mode = modeList + m.items = nil + m.cursor = 0 + m.err = nil + m.needsAuth = false + if m.loader == nil { + m.loading = false + return m, nil + } + return m, m.beginList() + case listedMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.items = msg.items + m.cursor = clampCursor(m.cursor, len(m.items)) + m.mode = modeList + } + return m, nil + case detailMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.detail = msg.detail + m.mode = modeDetail + } + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeFilter { + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + if m.mode == modeFilter { + switch action { + case keyActionBack: + m.mode = modeList + m.filterInput.Blur() + return m, nil + case keyActionConfirm: + m.filter = strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + m.mode = modeList + m.cursor = 0 + return m, m.beginList() + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd + } + if action == keyActionBack { + if m.mode == modeDetail { + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.filter = m.listFilter + return m, nil + } + return m, navigation.Navigate(navigation.Deployments) + } + if m.loading { + return m, nil + } + if m.needsAuth && action == keyActionConnectConsole { + return m, navigation.Navigate(navigation.Access) + } + if m.mode == modeDetail { + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + return m, nil + } + return m.updateList(action) +} + +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionMoveUp: + m.cursor = clampCursor(m.cursor-1, len(m.items)) + case keyActionMoveDown: + m.cursor = clampCursor(m.cursor+1, len(m.items)) + case keyActionConfirm: + if len(m.items) == 0 { + return m, nil + } + m.listCursor = m.cursor + m.listFilter = m.filter + m.detailID = m.items[m.cursor].ID + return m, m.beginDetail(m.detailID) + case keyActionRefresh: + return m, m.beginList() + case keyActionFilter: + m.mode = modeFilter + m.filterInput.SetValue(m.filter) + m.filterInput.Focus() + } + return m, nil +} + +func clampCursor(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} + +func clusterLabel(item clustersbridge.Summary) string { + if item.Handle != "" { + return "@" + item.Handle + } + if item.Name != "" { + return item.Name + } + return item.ID +} diff --git a/tui/screens/clusters/model_test.go b/tui/screens/clusters/model_test.go new file mode 100644 index 000000000..b0268a01e --- /dev/null +++ b/tui/screens/clusters/model_test.go @@ -0,0 +1,204 @@ +package clusters + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/pkg/bridge" + clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + items []clustersbridge.Summary + detail clustersbridge.Detail + err error +} + +func (f *fakeLoader) List(context.Context, string) ([]clustersbridge.Summary, error) { + return f.items, f.err +} +func (f *fakeLoader) Get(context.Context, string) (clustersbridge.Detail, error) { + return f.detail, f.err +} + +func loadList(t *testing.T, model Model) Model { + t.Helper() + cmd := model.Init() + model, cmd = model.Update(cmd()) + if cmd == nil { + t.Fatal("expected list command") + } + model, _ = model.Update(cmd()) + return model +} + +func TestOpenClusterDetailAndBack(t *testing.T) { + loader := &fakeLoader{ + items: []clustersbridge.Summary{ + {ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, + {ID: "c2", Name: "staging", Handle: "staging", Version: "1.29.0", Distro: "EKS"}, + }, + detail: clustersbridge.Detail{ + Summary: clustersbridge.Summary{ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, + Self: true, + Project: "acme", + PingedAt: "2026-07-29T10:00:00Z", + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if model.mode != modeList || len(model.items) != 2 { + t.Fatalf("list state = mode=%d count=%d", model.mode, len(model.items)) + } + if !strings.Contains(model.View(80, 24), "@prod-eu") { + t.Fatalf("list missing handle:\n%s", model.View(80, 24)) + } + + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail || model.detail.Handle != "prod-eu" { + t.Fatalf("detail = %#v mode=%d", model.detail, model.mode) + } + if !strings.Contains(model.View(80, 24), "production") { + t.Fatalf("detail view missing name:\n%s", model.View(80, 24)) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeList { + t.Fatalf("mode after detail esc = %d", model.mode) + } + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Deployments}) { + t.Fatalf("expected deployments navigation") + } +} + +func TestNoConsoleNavigatesToAccess(t *testing.T) { + loader := &fakeLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !model.needsAuth { + t.Fatal("expected needsAuth") + } + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c'}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Access}) { + t.Fatalf("expected access navigation") + } +} + +func TestClustersGoldens(t *testing.T) { + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.items = []clustersbridge.Summary{ + {ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, + {ID: "c2", Name: "staging", Handle: "staging", Version: "1.29.0", Distro: "EKS"}, + {ID: "c3", Name: "edge", Version: "1.28.1", Distro: "K3S"}, + } + + detail := list + detail.mode = modeDetail + detail.detail = clustersbridge.Detail{ + Summary: clustersbridge.Summary{ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, + Self: true, + PingedAt: "2026-07-29T10:00:00Z", + Protect: false, + Project: "acme", + Provider: "aws · EKS", + NodePools: 2, + Tags: []clustersbridge.Tag{{Name: "env", Value: "prod"}}, + } + + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeView(tc.model.View(tc.width, tc.height)) + golden := filepath.Join("testdata", "clusters-"+tc.name+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + lines := strings.Split(got, "\n") + if len(lines) != tc.height { + t.Fatalf("height = %d, want %d", len(lines), tc.height) + } + for _, line := range lines { + if w := lipgloss.Width(line); w > tc.width { + t.Fatalf("line width %d > %d: %q", w, tc.width, line) + } + } + }) + } +} + +func TestWriteClustersGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.items = []clustersbridge.Summary{ + {ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, + {ID: "c2", Name: "staging", Handle: "staging", Version: "1.29.0", Distro: "EKS"}, + {ID: "c3", Name: "edge", Version: "1.28.1", Distro: "K3S"}, + } + detail := list + detail.mode = modeDetail + detail.detail = clustersbridge.Detail{ + Summary: clustersbridge.Summary{ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, + Self: true, + PingedAt: "2026-07-29T10:00:00Z", + Protect: false, + Project: "acme", + Provider: "aws · EKS", + NodePools: 2, + Tags: []clustersbridge.Tag{{Name: "env", Value: "prod"}}, + } + _ = os.MkdirAll("testdata", 0o755) + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + got := normalizeView(tc.model.View(tc.width, tc.height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "clusters-"+tc.name+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/clusters/testdata/clusters-detail-120.golden b/tui/screens/clusters/testdata/clusters-detail-120.golden new file mode 100644 index 000000000..4cda1e764 --- /dev/null +++ b/tui/screens/clusters/testdata/clusters-detail-120.golden @@ -0,0 +1,30 @@ + Plural Clusters · production self + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Handle @prod-eu │ + │ Name production │ + │ Version 1.30.2 │ + │ Distro EKS │ + │ Project acme │ + │ Provider aws · EKS │ + │ Pinged 2026-07-29T10:00:00Z │ + │ Self true │ + │ Protect false │ + │ Node pools 2 │ + │ ID c1 │ + │ │ + │ Tags │ + │ env=prod │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/clusters/testdata/clusters-detail-80.golden b/tui/screens/clusters/testdata/clusters-detail-80.golden new file mode 100644 index 000000000..e1fd12860 --- /dev/null +++ b/tui/screens/clusters/testdata/clusters-detail-80.golden @@ -0,0 +1,24 @@ + Plural Clusters · production self + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────╮ + │ Handle @prod-eu │ + │ Name production │ + │ Version 1.30.2 │ + │ Distro EKS │ + │ Project acme │ + │ Provider aws · EKS │ + │ Pinged 2026-07-29T10:00:00Z │ + │ Self true │ + │ Protect false │ + │ Node pools 2 │ + │ ID c1 │ + │ │ + │ Tags │ + │ env=prod │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/clusters/testdata/clusters-list-120.golden b/tui/screens/clusters/testdata/clusters-list-120.golden new file mode 100644 index 000000000..51be6fea1 --- /dev/null +++ b/tui/screens/clusters/testdata/clusters-list-120.golden @@ -0,0 +1,30 @@ + Plural Clusters 3 clusters + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Clusters ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ HANDLE NAME VERSION DISTRO │ + │ › @prod-eu production 1.30.2 EKS │ + │ @staging staging 1.29.0 EKS │ + │ — edge 1.28.1 K3S │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · enter open · / filter · r refresh · esc back diff --git a/tui/screens/clusters/testdata/clusters-list-80.golden b/tui/screens/clusters/testdata/clusters-list-80.golden new file mode 100644 index 000000000..f5a5b9cf7 --- /dev/null +++ b/tui/screens/clusters/testdata/clusters-list-80.golden @@ -0,0 +1,24 @@ + Plural Clusters 3 clusters + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Clusters ─────────────────────────────────────────────────────────────╮ + │ HANDLE NAME VERSION DISTRO │ + │ › @prod-eu production 1.30.2 EKS │ + │ @staging staging 1.29.0 EKS │ + │ — edge 1.28.1 K3S │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / filter · esc back diff --git a/tui/screens/clusters/view.go b/tui/screens/clusters/view.go new file mode 100644 index 000000000..5f7674164 --- /dev/null +++ b/tui/screens/clusters/view.go @@ -0,0 +1,182 @@ +package clusters + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + title := "Clusters" + if m.mode == modeDetail && m.detail.Name != "" { + title = "Clusters · " + m.detail.Name + } + body, help := m.bodyAndHelp(contentWidth) + return page.Render(m.theme, width, height, title, m.headerStatus(), body, help) +} + +func (m Model) headerStatus() string { + if m.loading { + return m.theme.Warning.Render("◌ loading") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + if m.err != nil { + return m.theme.Danger.Render("✗ load failed") + } + switch m.mode { + case modeDetail: + if m.detail.DeletedAt != "" { + return m.theme.Danger.Render("terminating") + } + if m.detail.Self { + return m.theme.Success.Render("self") + } + return m.theme.Success.Render(loCoalesce(m.detail.Distro, "ready")) + case modeList: + if m.filter != "" { + return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.items))) + } + return m.theme.Success.Render(fmt.Sprintf("%d clusters", len(m.items))) + default: + return m.theme.Muted.Render("clusters") + } +} + +func (m Model) bodyAndHelp(width int) (string, string) { + if m.mode == modeFilter { + lines := []string{ + m.theme.Muted.Render("Filter by handle, name, id, version, or distro."), + "", + m.filterInput.View(), + } + return page.Panel(m.theme, "Filter clusters", lines, width, 6, true), "enter apply · esc cancel" + } + if m.needsAuth { + lines := []string{ + m.theme.Warning.Render("○ Console is not connected"), + m.theme.Muted.Render(" Connect a Console profile to browse clusters."), + "", + m.theme.Body.Render("Press c to open Access."), + } + return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" + } + if m.mode == modeDetail { + help := "r refresh · esc list · ctrl+c quit" + return page.Panel(m.theme, "Summary", m.detailLines(), width, 16, true), help + } + help := "↑/↓ select · enter open · / filter · r refresh · esc back" + if width < 100 { + help = "↑/↓ · enter · / filter · esc back" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help +} + +func (m Model) listTitle() string { + if m.filter != "" { + return "Clusters · filter “" + m.filter + "”" + } + return "Clusters" +} + +func (m Model) listLines(width int) []string { + if m.loading && len(m.items) == 0 { + return []string{m.theme.Warning.Render("◌ Loading clusters…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load clusters"), m.theme.Danger.Render("Error " + m.err.Error()), m.theme.Muted.Render("Press r to retry.")} + } + if len(m.items) == 0 { + return []string{m.theme.Warning.Render("○ No clusters found"), m.theme.Muted.Render(" Adjust the filter or connect another Console.")} + } + handleWidth := max(12, min(20, width/4)) + nameWidth := max(12, min(24, width/3)) + lines := []string{m.theme.Muted.Render(" " + pad("HANDLE", handleWidth) + " " + pad("NAME", nameWidth) + " " + pad("VERSION", 10) + " DISTRO")} + for i, item := range m.items { + cursor := " " + if i == m.cursor { + cursor = "› " + } + handle := item.Handle + if handle == "" { + handle = "—" + } else { + handle = "@" + handle + } + version := loCoalesce(item.Version, "—") + distro := loCoalesce(item.Distro, "—") + row := cursor + pad(handle, handleWidth) + " " + pad(item.Name, nameWidth) + " " + pad(version, 10) + " " + distro + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + return lines +} + +func (m Model) detailLines() []string { + if m.loading { + return []string{m.theme.Warning.Render("◌ Loading cluster detail…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load cluster"), m.theme.Danger.Render(m.err.Error())} + } + handle := m.detail.Handle + if handle != "" { + handle = "@" + handle + } else { + handle = "—" + } + lines := []string{ + m.labelValue("Handle", handle), + m.labelValue("Name", m.detail.Name), + m.labelValue("Version", loCoalesce(m.detail.Version, "—")), + m.labelValue("Distro", loCoalesce(m.detail.Distro, "—")), + m.labelValue("Project", loCoalesce(m.detail.Project, "—")), + m.labelValue("Provider", loCoalesce(m.detail.Provider, "—")), + m.labelValue("Pinged", loCoalesce(m.detail.PingedAt, "—")), + m.labelValue("Self", fmt.Sprintf("%v", m.detail.Self)), + m.labelValue("Protect", fmt.Sprintf("%v", m.detail.Protect)), + m.labelValue("Node pools", fmt.Sprintf("%d", m.detail.NodePools)), + m.labelValue("ID", m.detail.ID), + } + if m.detail.DeletedAt != "" { + lines = append(lines, m.theme.Danger.Render("Deleted "+m.detail.DeletedAt)) + } + if len(m.detail.Tags) > 0 { + lines = append(lines, "", m.theme.Muted.Render("Tags")) + for _, tag := range m.detail.Tags { + lines = append(lines, " "+tag.Name+"="+tag.Value) + } + } + return lines +} + +func (m Model) labelValue(label, value string) string { + label = label + strings.Repeat(" ", max(1, 12-len(label))) + return label + " " + value +} + +func pad(value string, width int) string { + value = ansi.Truncate(value, width, "…") + if lipgloss.Width(value) >= width { + return value + } + return value + strings.Repeat(" ", width-lipgloss.Width(value)) +} + +func loCoalesce(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} diff --git a/tui/screens/deployments/model.go b/tui/screens/deployments/model.go index a77178f26..a7c3c520e 100644 --- a/tui/screens/deployments/model.go +++ b/tui/screens/deployments/model.go @@ -33,7 +33,7 @@ type resource struct { func resources() []resource { return []resource{ {id: resourceServices, number: "1", shortcut: "s", title: "Services", blurb: "browse · kick · create · …", route: navigation.Services}, - {id: resourceClusters, number: "2", shortcut: "c", title: "Clusters", blurb: "list · describe", soon: true}, + {id: resourceClusters, number: "2", shortcut: "c", title: "Clusters", blurb: "list · describe", route: navigation.Clusters}, {id: resourceRepositories, number: "3", shortcut: "r", title: "Repositories", blurb: "list · get", soon: true}, {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "trigger", soon: true}, {id: resourceNotifications, number: "5", shortcut: "n", title: "Notifications", blurb: "sinks", soon: true}, diff --git a/tui/screens/deployments/model_test.go b/tui/screens/deployments/model_test.go index c5943f3b5..4b0ff56e6 100644 --- a/tui/screens/deployments/model_test.go +++ b/tui/screens/deployments/model_test.go @@ -65,9 +65,20 @@ func TestServicesShortcutNavigates(t *testing.T) { } } +func TestClustersNavigates(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c', Text: "c"}) + if cmd == nil { + t.Fatal("expected navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Clusters}) { + t.Fatalf("msg = %#v", msg) + } +} + func TestSoonResourceDoesNotNavigate(t *testing.T) { model := New(t.Context(), theme.New(colorprofile.ASCII), "") - model.cursor = 1 + model.cursor = 2 // repositories [soon] _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if cmd != nil { t.Fatalf("unexpected cmd %#v", cmd()) diff --git a/tui/screens/deployments/testdata/deployments-120.golden b/tui/screens/deployments/testdata/deployments-120.golden index a1d1c156c..37618cc26 100644 --- a/tui/screens/deployments/testdata/deployments-120.golden +++ b/tui/screens/deployments/testdata/deployments-120.golden @@ -3,7 +3,7 @@ ╭─ › Resources ────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ › 1 s Services browse · kick · create · … │ - │ 2 c Clusters list · describe [soon] │ + │ 2 c Clusters list · describe │ │ 3 r Repositories list · get [soon] │ │ 4 p Pipelines trigger [soon] │ │ 5 n Notifications sinks [soon] │ diff --git a/tui/screens/deployments/testdata/deployments-80.golden b/tui/screens/deployments/testdata/deployments-80.golden index 0fbe23392..1539b8906 100644 --- a/tui/screens/deployments/testdata/deployments-80.golden +++ b/tui/screens/deployments/testdata/deployments-80.golden @@ -3,7 +3,7 @@ ╭─ › Resources ────────────────────────────────────────────────────────────╮ │ › 1 s Services browse · kick · create · … │ - │ 2 c Clusters list · describe [soon] │ + │ 2 c Clusters list · describe │ │ 3 r Repositories list · get [soon] │ │ 4 p Pipelines trigger [soon] │ │ 5 n Notifications sinks [soon] │ From bf6085920b19bf71dce2c52d232701decb29f2cb Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Thu, 30 Jul 2026 12:53:16 +0200 Subject: [PATCH 08/16] add repositories --- cmd/command/tui/tui.go | 11 +- pkg/bridge/clusters/clusters.go | 53 ++- pkg/bridge/clusters/clusters_test.go | 31 +- pkg/bridge/repositories/repositories.go | 211 ++++++++++++ pkg/bridge/repositories/repositories_test.go | 114 +++++++ pkg/bridge/services/services.go | 2 +- pkg/bridge/services/services_test.go | 27 ++ tui/app/model.go | 47 +-- tui/app/model_test.go | 5 + tui/app/view.go | 2 + tui/navigation/navigation.go | 5 +- tui/screens/clusters/model.go | 72 ++++- tui/screens/clusters/model_test.go | 53 ++- .../testdata/clusters-list-120.golden | 4 +- .../clusters/testdata/clusters-list-80.golden | 4 +- tui/screens/clusters/view.go | 51 ++- tui/screens/deployments/model.go | 2 +- tui/screens/deployments/model_test.go | 13 +- .../testdata/deployments-120.golden | 2 +- .../testdata/deployments-80.golden | 2 +- tui/screens/repositories/model.go | 306 ++++++++++++++++++ tui/screens/repositories/model_test.go | 232 +++++++++++++ .../testdata/repositories-detail-120.golden | 30 ++ .../testdata/repositories-detail-80.golden | 24 ++ .../testdata/repositories-list-120.golden | 30 ++ .../testdata/repositories-list-80.golden | 24 ++ tui/screens/repositories/view.go | 209 ++++++++++++ tui/screens/services/model.go | 28 +- tui/screens/services/model_test.go | 78 +++++ .../testdata/services-clusters-120.golden | 2 +- .../testdata/services-list-120.golden | 4 +- .../services/testdata/services-list-80.golden | 4 +- tui/screens/services/view.go | 48 ++- 33 files changed, 1626 insertions(+), 104 deletions(-) create mode 100644 pkg/bridge/repositories/repositories.go create mode 100644 pkg/bridge/repositories/repositories_test.go create mode 100644 tui/screens/repositories/model.go create mode 100644 tui/screens/repositories/model_test.go create mode 100644 tui/screens/repositories/testdata/repositories-detail-120.golden create mode 100644 tui/screens/repositories/testdata/repositories-detail-80.golden create mode 100644 tui/screens/repositories/testdata/repositories-list-120.golden create mode 100644 tui/screens/repositories/testdata/repositories-list-80.golden create mode 100644 tui/screens/repositories/view.go diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go index eb10eb010..982b54d98 100644 --- a/cmd/command/tui/tui.go +++ b/cmd/command/tui/tui.go @@ -9,6 +9,7 @@ import ( "github.com/pluralsh/plural-cli/pkg/bridge" accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" "github.com/pluralsh/plural-cli/pkg/common" @@ -23,11 +24,13 @@ func Command() cli.Command { access := accessbridge.NewLocalManager("", auth, nil) services := servicesbridge.NewService(access) clusters := clustersbridge.NewService(access) + repositories := repositoriesbridge.NewService(access) return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{ - Welcome: welcome, - Access: access, - Services: services, - Clusters: clusters, + Welcome: welcome, + Access: access, + Services: services, + Clusters: clusters, + Repositories: repositories, }) }) } diff --git a/pkg/bridge/clusters/clusters.go b/pkg/bridge/clusters/clusters.go index 3a42e73b1..1ccfdb4b2 100644 --- a/pkg/bridge/clusters/clusters.go +++ b/pkg/bridge/clusters/clusters.go @@ -13,6 +13,8 @@ import ( "github.com/pluralsh/plural-cli/pkg/console" ) +const defaultPageSize int64 = 10 + var ( errNoConsole = errors.New("connect a Console profile before browsing Console resources") errMissingID = errors.New("cluster id is required") @@ -47,9 +49,17 @@ type Detail struct { NodePools int } +// Page is one cursor page of cluster summaries. +type Page struct { + Items []Summary + EndCursor string + HasNext bool + TotalShown int +} + // Loader is the narrow contract consumed by the Clusters screen. type Loader interface { - List(ctx context.Context, query string) ([]Summary, error) + List(ctx context.Context, after *string, query string) (Page, error) Get(ctx context.Context, id string) (Detail, error) } @@ -71,6 +81,7 @@ type ClientFactory func(token, url string) (API, error) type Service struct { resolve ConsoleResolver newClient ClientFactory + pageSize int64 } // NewService wires production Console credentials and client construction. @@ -80,6 +91,7 @@ func NewService(resolve ConsoleResolver) *Service { newClient: func(token, url string) (API, error) { return console.NewConsoleClient(token, url) }, + pageSize: defaultPageSize, } } @@ -100,20 +112,20 @@ func (s *Service) client(ctx context.Context) (API, error) { return factory(token, url) } -func (s *Service) List(ctx context.Context, query string) ([]Summary, error) { +func (s *Service) List(ctx context.Context, after *string, query string) (Page, error) { if err := ctx.Err(); err != nil { - return nil, err + return Page{}, err } client, err := s.client(ctx) if err != nil { - return nil, err + return Page{}, err } result, err := client.ListClusters() if err != nil { - return nil, err + return Page{}, err } if result == nil || result.Clusters == nil { - return nil, nil + return Page{}, nil } items := make([]Summary, 0, len(result.Clusters.Edges)) for _, edge := range result.Clusters.Edges { @@ -126,7 +138,7 @@ func (s *Service) List(ctx context.Context, query string) ([]Summary, error) { } items = append(items, summary) } - return items, nil + return pageItems(items, after, s.pageSize), nil } func (s *Service) Get(ctx context.Context, id string) (Detail, error) { @@ -151,6 +163,33 @@ func (s *Service) Get(ctx context.Context, id string) (Detail, error) { return detailFromFragment(cluster), nil } +func pageItems(items []Summary, after *string, pageSize int64) Page { + if pageSize <= 0 { + pageSize = defaultPageSize + } + start := 0 + if after != nil && *after != "" { + for i, item := range items { + if item.ID == *after { + start = i + 1 + break + } + } + } + if start > len(items) { + start = len(items) + } + end := start + int(pageSize) + if end > len(items) { + end = len(items) + } + page := Page{Items: items[start:end], TotalShown: end - start, HasNext: end < len(items)} + if len(page.Items) > 0 { + page.EndCursor = page.Items[len(page.Items)-1].ID + } + return page +} + func summaryFromFragment(node *gqlclient.ClusterFragment) Summary { summary := Summary{ID: node.ID, Name: node.Name} if node.Handle != nil { diff --git a/pkg/bridge/clusters/clusters_test.go b/pkg/bridge/clusters/clusters_test.go index b6226e7b0..39d7f9aa8 100644 --- a/pkg/bridge/clusters/clusters_test.go +++ b/pkg/bridge/clusters/clusters_test.go @@ -56,11 +56,12 @@ func TestListAndGet(t *testing.T) { service := &Service{ resolve: fakeResolver{url: "https://console.example.com", token: "token"}, newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 50, } - items, err := service.List(t.Context(), "prod") - if err != nil || len(items) != 1 || items[0].Handle != "prod-eu" || items[0].Version != "1.30.2" { - t.Fatalf("List() = %#v, %v", items, err) + page, err := service.List(t.Context(), nil, "prod") + if err != nil || len(page.Items) != 1 || page.Items[0].Handle != "prod-eu" || page.Items[0].Version != "1.30.2" { + t.Fatalf("List() = %#v, %v", page, err) } detail, err := service.Get(t.Context(), "c1") @@ -72,6 +73,30 @@ func TestListAndGet(t *testing.T) { } } +func TestListPages(t *testing.T) { + api := &fakeAPI{ + clusters: &gqlclient.ListClusters{Clusters: &gqlclient.ListClusters_Clusters{Edges: []*gqlclient.ClusterEdgeFragment{ + {Node: &gqlclient.ClusterFragment{ID: "c1", Name: "a"}}, + {Node: &gqlclient.ClusterFragment{ID: "c2", Name: "b"}}, + {Node: &gqlclient.ClusterFragment{ID: "c3", Name: "c"}}, + }}}, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 2, + } + first, err := service.List(t.Context(), nil, "") + if err != nil || len(first.Items) != 2 || !first.HasNext || first.EndCursor != "c2" { + t.Fatalf("first = %#v, %v", first, err) + } + after := first.EndCursor + second, err := service.List(t.Context(), &after, "") + if err != nil || len(second.Items) != 1 || second.HasNext || second.Items[0].ID != "c3" { + t.Fatalf("second = %#v, %v", second, err) + } +} + func TestGetRequiresID(t *testing.T) { service := &Service{ resolve: fakeResolver{url: "https://console.example.com", token: "token"}, diff --git a/pkg/bridge/repositories/repositories.go b/pkg/bridge/repositories/repositories.go new file mode 100644 index 000000000..ff53ad503 --- /dev/null +++ b/pkg/bridge/repositories/repositories.go @@ -0,0 +1,211 @@ +// Package repositories exposes read-only Console git repository list/get use +// cases to presentation layers without importing TUI code. +package repositories + +import ( + "context" + "errors" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +const defaultPageSize int64 = 10 + +var ( + errNoConsole = errors.New("connect a Console profile before browsing Console resources") + errMissingID = errors.New("repository id is required") + errMissingRepository = errors.New("repository was not found") +) + +// Summary is a credential-free list row for a Console git repository. +type Summary struct { + ID string + URL string + Health string + Error string + AuthMethod string +} + +// Detail is the credential-free detail payload for a Console git repository. +type Detail struct { + Summary + Decrypt bool +} + +// Page is one cursor page of repository summaries. +type Page struct { + Items []Summary + EndCursor string + HasNext bool + TotalShown int +} + +// Loader is the narrow contract consumed by the Repositories screen. +type Loader interface { + List(ctx context.Context, after *string, query string) (Page, error) + Get(ctx context.Context, id string) (Detail, error) +} + +// ConsoleResolver supplies the active Console URL and token. +type ConsoleResolver interface { + ActiveConsole(ctx context.Context) (url, token string, err error) +} + +// API is the Console surface required by this package. +type API interface { + ListRepositories() (*gqlclient.ListGitRepositories, error) + GetRepository(id string) (*gqlclient.GetGitRepository, error) +} + +// ClientFactory builds a Console API for an authenticated endpoint. +type ClientFactory func(token, url string) (API, error) + +// Service implements Loader against Console GraphQL. +type Service struct { + resolve ConsoleResolver + newClient ClientFactory + pageSize int64 +} + +// NewService wires production Console credentials and client construction. +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + pageSize: defaultPageSize, + } +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s.resolve == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + url, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return nil, err + } + factory := s.newClient + if factory == nil { + factory = func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + } + } + return factory(token, url) +} + +func (s *Service) List(ctx context.Context, after *string, query string) (Page, error) { + if err := ctx.Err(); err != nil { + return Page{}, err + } + client, err := s.client(ctx) + if err != nil { + return Page{}, err + } + result, err := client.ListRepositories() + if err != nil { + return Page{}, err + } + if result == nil || result.GitRepositories == nil { + return Page{}, nil + } + items := make([]Summary, 0, len(result.GitRepositories.Edges)) + for _, edge := range result.GitRepositories.Edges { + if edge == nil || edge.Node == nil { + continue + } + summary := summaryFromFragment(edge.Node) + if !matchesQuery(summary, query) { + continue + } + items = append(items, summary) + } + return pageItems(items, after, s.pageSize), nil +} + +func (s *Service) Get(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + result, err := client.GetRepository(id) + if err != nil { + return Detail{}, err + } + if result == nil || result.GitRepository == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingRepository} + } + return detailFromFragment(result.GitRepository), nil +} + +func pageItems(items []Summary, after *string, pageSize int64) Page { + if pageSize <= 0 { + pageSize = defaultPageSize + } + start := 0 + if after != nil && *after != "" { + for i, item := range items { + if item.ID == *after { + start = i + 1 + break + } + } + } + if start > len(items) { + start = len(items) + } + end := start + int(pageSize) + if end > len(items) { + end = len(items) + } + page := Page{Items: items[start:end], TotalShown: end - start, HasNext: end < len(items)} + if len(page.Items) > 0 { + page.EndCursor = page.Items[len(page.Items)-1].ID + } + return page +} + +func summaryFromFragment(node *gqlclient.GitRepositoryFragment) Summary { + summary := Summary{ID: node.ID, URL: node.URL, Health: "UNKNOWN"} + if node.Health != nil { + summary.Health = string(*node.Health) + } + if node.Error != nil { + summary.Error = *node.Error + } + if node.AuthMethod != nil { + summary.AuthMethod = string(*node.AuthMethod) + } + return summary +} + +func detailFromFragment(node *gqlclient.GitRepositoryFragment) Detail { + detail := Detail{Summary: summaryFromFragment(node)} + if node.Decrypt != nil { + detail.Decrypt = *node.Decrypt + } + return detail +} + +func matchesQuery(summary Summary, query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{ + summary.URL, summary.ID, summary.Health, summary.Error, summary.AuthMethod, + }, " ")) + return strings.Contains(haystack, query) +} diff --git a/pkg/bridge/repositories/repositories_test.go b/pkg/bridge/repositories/repositories_test.go new file mode 100644 index 000000000..3224c5550 --- /dev/null +++ b/pkg/bridge/repositories/repositories_test.go @@ -0,0 +1,114 @@ +package repositories + +import ( + "context" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeResolver struct { + url, token string + err error +} + +func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) { + return f.url, f.token, f.err +} + +type fakeAPI struct { + repos *gqlclient.ListGitRepositories + listErr error + detail *gqlclient.GetGitRepository + getErr error +} + +func (f *fakeAPI) ListRepositories() (*gqlclient.ListGitRepositories, error) { + return f.repos, f.listErr +} +func (f *fakeAPI) GetRepository(string) (*gqlclient.GetGitRepository, error) { + return f.detail, f.getErr +} + +func TestListAndGet(t *testing.T) { + health := gqlclient.GitHealthPullable + auth := gqlclient.AuthMethodSSH + errMsg := "auth failed" + failed := gqlclient.GitHealthFailed + api := &fakeAPI{ + repos: &gqlclient.ListGitRepositories{GitRepositories: &gqlclient.ListGitRepositories_GitRepositories{ + Edges: []*gqlclient.GitRepositoryEdgeFragment{ + {Node: &gqlclient.GitRepositoryFragment{ + ID: "r1", URL: "git@github.com:acme/infra.git", Health: &health, AuthMethod: &auth, + }}, + {Node: &gqlclient.GitRepositoryFragment{ + ID: "r2", URL: "https://github.com/acme/apps.git", Health: &failed, Error: &errMsg, + }}, + }, + }}, + detail: &gqlclient.GetGitRepository{GitRepository: &gqlclient.GitRepositoryFragment{ + ID: "r1", URL: "git@github.com:acme/infra.git", Health: &health, AuthMethod: &auth, + Decrypt: lo.ToPtr(true), + }}, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 50, + } + + page, err := service.List(t.Context(), nil, "infra") + if err != nil || len(page.Items) != 1 || page.Items[0].ID != "r1" || page.Items[0].Health != "PULLABLE" || page.Items[0].AuthMethod != "SSH" { + t.Fatalf("List() = %#v, %v", page, err) + } + + detail, err := service.Get(t.Context(), "r1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if !detail.Decrypt || detail.URL != "git@github.com:acme/infra.git" || detail.Health != "PULLABLE" { + t.Fatalf("detail = %#v", detail) + } +} + +func TestListPages(t *testing.T) { + health := gqlclient.GitHealthPullable + edges := make([]*gqlclient.GitRepositoryEdgeFragment, 0, 3) + for _, id := range []string{"r1", "r2", "r3"} { + edges = append(edges, &gqlclient.GitRepositoryEdgeFragment{ + Node: &gqlclient.GitRepositoryFragment{ID: id, URL: "git@github.com:acme/" + id + ".git", Health: &health}, + }) + } + api := &fakeAPI{ + repos: &gqlclient.ListGitRepositories{GitRepositories: &gqlclient.ListGitRepositories_GitRepositories{Edges: edges}}, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 2, + } + + first, err := service.List(t.Context(), nil, "") + if err != nil || len(first.Items) != 2 || !first.HasNext || first.EndCursor != "r2" { + t.Fatalf("first page = %#v, %v", first, err) + } + after := first.EndCursor + second, err := service.List(t.Context(), &after, "") + if err != nil || len(second.Items) != 1 || second.HasNext || second.Items[0].ID != "r3" { + t.Fatalf("second page = %#v, %v", second, err) + } +} + +func TestGetRequiresID(t *testing.T) { + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return &fakeAPI{}, nil }, + } + _, err := service.Get(t.Context(), "") + if !bridge.IsCode(err, bridge.ErrorInvalid) { + t.Fatalf("Get() error = %v", err) + } +} diff --git a/pkg/bridge/services/services.go b/pkg/bridge/services/services.go index cdb8e3e1c..b2c76560b 100644 --- a/pkg/bridge/services/services.go +++ b/pkg/bridge/services/services.go @@ -12,7 +12,7 @@ import ( "github.com/pluralsh/plural-cli/pkg/console" ) -const defaultPageSize int64 = 50 +const defaultPageSize int64 = 10 // Cluster is a credential-free Console cluster summary. type Cluster struct { diff --git a/pkg/bridge/services/services_test.go b/pkg/bridge/services/services_test.go index ae63cf637..6b4494d19 100644 --- a/pkg/bridge/services/services_test.go +++ b/pkg/bridge/services/services_test.go @@ -101,6 +101,33 @@ func TestListRequiresCluster(t *testing.T) { } } +func TestListPages(t *testing.T) { + edges := make([]*gqlclient.ServiceDeploymentEdgeFragment, 0, 12) + for i := 0; i < 12; i++ { + id := string(rune('a' + i)) + edges = append(edges, &gqlclient.ServiceDeploymentEdgeFragment{ + Node: &gqlclient.ServiceDeploymentBaseFragment{ + ID: id, Name: "svc-" + id, Namespace: "default", Status: gqlclient.ServiceDeploymentStatusHealthy, + }, + }) + } + api := &fakeAPI{edges: edges} + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 10, + } + first, err := service.List(t.Context(), "c1", nil, "") + if err != nil || len(first.Items) != 10 || !first.HasNext || first.EndCursor != "j" { + t.Fatalf("first = %#v, %v", first, err) + } + after := first.EndCursor + second, err := service.List(t.Context(), "c1", &after, "") + if err != nil || len(second.Items) != 2 || second.HasNext || second.Items[0].ID != "k" { + t.Fatalf("second = %#v, %v", second, err) + } +} + func TestGetMapsDetail(t *testing.T) { handle := "prod-eu" sha := "abc123" diff --git a/tui/app/model.go b/tui/app/model.go index d10aab56e..d591564ff 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -9,6 +9,7 @@ import ( accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" "github.com/pluralsh/plural-cli/tui/navigation" @@ -16,6 +17,7 @@ import ( clustersscreen "github.com/pluralsh/plural-cli/tui/screens/clusters" deploymentsscreen "github.com/pluralsh/plural-cli/tui/screens/deployments" diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" + repositoriesscreen "github.com/pluralsh/plural-cli/tui/screens/repositories" servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" welcomescreen "github.com/pluralsh/plural-cli/tui/screens/welcome" "github.com/pluralsh/plural-cli/tui/theme" @@ -23,10 +25,11 @@ import ( // Dependencies contains the services required by TUI screens. type Dependencies struct { - Welcome welcomebridge.Loader - Access accessbridge.Manager - Services servicesbridge.Loader - Clusters clustersbridge.Loader + Welcome welcomebridge.Loader + Access accessbridge.Manager + Services servicesbridge.Loader + Clusters clustersbridge.Loader + Repositories repositoriesbridge.Loader } // Model is the root TUI model. It owns global input and delegates screen state @@ -38,26 +41,28 @@ type Model struct { theme theme.Theme quit key.Binding - welcome welcomescreen.Model - access accessscreen.Model - diagnostics diagnosticsscreen.Model - deployments deploymentsscreen.Model - services servicesscreen.Model - clusters clustersscreen.Model - route navigation.Route + welcome welcomescreen.Model + access accessscreen.Model + diagnostics diagnosticsscreen.Model + deployments deploymentsscreen.Model + services servicesscreen.Model + clusters clustersscreen.Model + repositories repositoriesscreen.Model + route navigation.Route } // New composes the root model with caller-provided dependencies. func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { return Model{ - theme: t, - welcome: welcomescreen.New(ctx, dependencies.Welcome, t), - access: accessscreen.New(ctx, dependencies.Access, t), - diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t), - deployments: deploymentsscreen.New(ctx, t, ""), - services: servicesscreen.New(ctx, dependencies.Services, t), - clusters: clustersscreen.New(ctx, dependencies.Clusters, t), - route: navigation.Welcome, + theme: t, + welcome: welcomescreen.New(ctx, dependencies.Welcome, t), + access: accessscreen.New(ctx, dependencies.Access, t), + diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t), + deployments: deploymentsscreen.New(ctx, t, ""), + services: servicesscreen.New(ctx, dependencies.Services, t), + clusters: clustersscreen.New(ctx, dependencies.Clusters, t), + repositories: repositoriesscreen.New(ctx, dependencies.Repositories, t), + route: navigation.Welcome, quit: key.NewBinding( key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit"), @@ -83,6 +88,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.services.Init() case navigation.Clusters: return m, m.clusters.Init() + case navigation.Repositories: + return m, m.repositories.Init() default: return m, m.welcome.Init() } @@ -112,6 +119,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.services, cmd = m.services.Update(msg) case navigation.Clusters: m.clusters, cmd = m.clusters.Update(msg) + case navigation.Repositories: + m.repositories, cmd = m.repositories.Update(msg) default: m.welcome, cmd = m.welcome.Update(msg) } diff --git a/tui/app/model_test.go b/tui/app/model_test.go index 212e5f9f7..1f5dd892d 100644 --- a/tui/app/model_test.go +++ b/tui/app/model_test.go @@ -60,6 +60,11 @@ func TestModelRoutesScreensWithoutRebuildingShell(t *testing.T) { if routed.route != navigation.Clusters || !strings.Contains(routed.View().Content, "Clusters") { t.Fatalf("clusters route/view = %q\n%s", routed.route, routed.View().Content) } + updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Repositories}) + routed = updated.(Model) + if routed.route != navigation.Repositories || !strings.Contains(routed.View().Content, "Repositories") { + t.Fatalf("repositories route/view = %q\n%s", routed.route, routed.View().Content) + } updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Welcome}) if got := updated.(Model).route; got != navigation.Welcome { t.Fatalf("route = %q", got) diff --git a/tui/app/view.go b/tui/app/view.go index a0c5f6324..d9d1c4c0f 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -21,6 +21,8 @@ func (m Model) View() tea.View { content = m.services.View(m.width, m.height) case navigation.Clusters: content = m.clusters.View(m.width, m.height) + case navigation.Repositories: + content = m.repositories.View(m.width, m.height) } view := tea.NewView(content) view.AltScreen = true diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index e12b2dcb0..8b4ddda5b 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -13,8 +13,9 @@ const ( Access Route = "access" Diagnostics Route = "diagnostics" Deployments Route = "deployments" - Services Route = "services" - Clusters Route = "clusters" + Services Route = "services" + Clusters Route = "clusters" + Repositories Route = "repositories" ) // NavigateMsg requests a top-level route change. diff --git a/tui/screens/clusters/model.go b/tui/screens/clusters/model.go index aff81ec0b..5fa072bf6 100644 --- a/tui/screens/clusters/model.go +++ b/tui/screens/clusters/model.go @@ -33,6 +33,8 @@ const ( keyActionRefresh keyActionFilter keyActionConnectConsole + keyActionNextPage + keyActionPrevPage ) var keyActionKeystrokes = map[keyAction][]string{ @@ -43,6 +45,8 @@ var keyActionKeystrokes = map[keyAction][]string{ keyActionRefresh: {"r"}, keyActionFilter: {"/"}, keyActionConnectConsole: {"c"}, + keyActionNextPage: {"n", "right", "]"}, + keyActionPrevPage: {"p", "left", "["}, } func actionForKeystroke(keystroke string) keyAction { @@ -58,7 +62,7 @@ func actionForKeystroke(keystroke string) keyAction { type initMsg struct{} type listedMsg struct { - items []clustersbridge.Summary + page clustersbridge.Page err error request uint64 } @@ -79,15 +83,19 @@ type Model struct { needsAuth bool request uint64 - items []clustersbridge.Summary + page clustersbridge.Page cursor int filter string filterInput textinput.Model + after *string + prevCursors []string detail clustersbridge.Detail detailID string listCursor int + listAfter *string listFilter string + listPrev []string } func New(ctx context.Context, loader clustersbridge.Loader, t theme.Theme) Model { @@ -108,7 +116,7 @@ func (m Model) Init() tea.Cmd { return func() tea.Msg { return initMsg{} } } -func (m *Model) beginList() tea.Cmd { +func (m *Model) beginList(after *string) tea.Cmd { m.loading = true m.request++ request := m.request @@ -116,8 +124,8 @@ func (m *Model) beginList() tea.Cmd { loader := m.loader ctx := m.ctx return func() tea.Msg { - items, err := loader.List(ctx, query) - return listedMsg{items: items, err: err, request: request} + page, err := loader.List(ctx, after, query) + return listedMsg{page: page, err: err, request: request} } } @@ -137,15 +145,17 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case initMsg: m.mode = modeList - m.items = nil + m.page = clustersbridge.Page{} m.cursor = 0 + m.after = nil + m.prevCursors = nil m.err = nil m.needsAuth = false if m.loader == nil { m.loading = false return m, nil } - return m, m.beginList() + return m, m.beginList(nil) case listedMsg: if msg.request != m.request { return m, nil @@ -154,8 +164,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.err = msg.err m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) if msg.err == nil { - m.items = msg.items - m.cursor = clampCursor(m.cursor, len(m.items)) + m.page = msg.page + m.cursor = clampCursor(m.cursor, len(m.page.Items)) m.mode = modeList } return m, nil @@ -195,7 +205,9 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { m.filterInput.Blur() m.mode = modeList m.cursor = 0 - return m, m.beginList() + m.after = nil + m.prevCursors = nil + return m, m.beginList(nil) } var cmd tea.Cmd m.filterInput, cmd = m.filterInput.Update(key) @@ -206,7 +218,9 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { m.mode = modeList m.err = nil m.cursor = m.listCursor + m.after = m.listAfter m.filter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) return m, nil } return m, navigation.Navigate(navigation.Deployments) @@ -229,23 +243,51 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { func (m Model) updateList(action keyAction) (Model, tea.Cmd) { switch action { case keyActionMoveUp: - m.cursor = clampCursor(m.cursor-1, len(m.items)) + m.cursor = clampCursor(m.cursor-1, len(m.page.Items)) case keyActionMoveDown: - m.cursor = clampCursor(m.cursor+1, len(m.items)) + m.cursor = clampCursor(m.cursor+1, len(m.page.Items)) case keyActionConfirm: - if len(m.items) == 0 { + if len(m.page.Items) == 0 { return m, nil } m.listCursor = m.cursor + m.listAfter = m.after m.listFilter = m.filter - m.detailID = m.items[m.cursor].ID + m.listPrev = append([]string(nil), m.prevCursors...) + m.detailID = m.page.Items[m.cursor].ID return m, m.beginDetail(m.detailID) case keyActionRefresh: - return m, m.beginList() + return m, m.beginList(m.after) case keyActionFilter: m.mode = modeFilter m.filterInput.SetValue(m.filter) m.filterInput.Focus() + case keyActionNextPage: + if !m.page.HasNext || m.page.EndCursor == "" { + return m, nil + } + if m.after != nil { + m.prevCursors = append(m.prevCursors, *m.after) + } else { + m.prevCursors = append(m.prevCursors, "") + } + cursor := m.page.EndCursor + m.after = &cursor + m.cursor = 0 + return m, m.beginList(m.after) + case keyActionPrevPage: + if len(m.prevCursors) == 0 { + return m, nil + } + previous := m.prevCursors[len(m.prevCursors)-1] + m.prevCursors = m.prevCursors[:len(m.prevCursors)-1] + if previous == "" { + m.after = nil + } else { + m.after = &previous + } + m.cursor = 0 + return m, m.beginList(m.after) } return m, nil } diff --git a/tui/screens/clusters/model_test.go b/tui/screens/clusters/model_test.go index b0268a01e..32bc082a9 100644 --- a/tui/screens/clusters/model_test.go +++ b/tui/screens/clusters/model_test.go @@ -20,13 +20,13 @@ import ( ) type fakeLoader struct { - items []clustersbridge.Summary + page clustersbridge.Page detail clustersbridge.Detail err error } -func (f *fakeLoader) List(context.Context, string) ([]clustersbridge.Summary, error) { - return f.items, f.err +func (f *fakeLoader) List(context.Context, *string, string) (clustersbridge.Page, error) { + return f.page, f.err } func (f *fakeLoader) Get(context.Context, string) (clustersbridge.Detail, error) { return f.detail, f.err @@ -45,10 +45,10 @@ func loadList(t *testing.T, model Model) Model { func TestOpenClusterDetailAndBack(t *testing.T) { loader := &fakeLoader{ - items: []clustersbridge.Summary{ + page: clustersbridge.Page{Items: []clustersbridge.Summary{ {ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, {ID: "c2", Name: "staging", Handle: "staging", Version: "1.29.0", Distro: "EKS"}, - }, + }}, detail: clustersbridge.Detail{ Summary: clustersbridge.Summary{ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, Self: true, @@ -57,8 +57,8 @@ func TestOpenClusterDetailAndBack(t *testing.T) { }, } model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) - if model.mode != modeList || len(model.items) != 2 { - t.Fatalf("list state = mode=%d count=%d", model.mode, len(model.items)) + if model.mode != modeList || len(model.page.Items) != 2 { + t.Fatalf("list state = mode=%d count=%d", model.mode, len(model.page.Items)) } if !strings.Contains(model.View(80, 24), "@prod-eu") { t.Fatalf("list missing handle:\n%s", model.View(80, 24)) @@ -83,6 +83,37 @@ func TestOpenClusterDetailAndBack(t *testing.T) { } } +func TestNextPrevPage(t *testing.T) { + loader := &fakeLoader{ + page: clustersbridge.Page{ + Items: []clustersbridge.Summary{{ID: "c1", Name: "a", Handle: "a"}}, + EndCursor: "c1", + HasNext: true, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !strings.Contains(model.View(80, 24), "n next") { + t.Fatalf("missing next pager:\n%s", model.View(80, 24)) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: 'n'}) + if cmd == nil { + t.Fatal("expected next-page list command") + } + loader.page = clustersbridge.Page{Items: []clustersbridge.Summary{{ID: "c2", Name: "b", Handle: "b"}}} + model, _ = model.Update(cmd()) + if model.after == nil || *model.after != "c1" || len(model.prevCursors) != 1 { + t.Fatalf("after page turn after=%v prev=%v", model.after, model.prevCursors) + } + model, cmd = model.Update(tea.KeyPressMsg{Code: 'p'}) + if cmd == nil { + t.Fatal("expected prev-page list command") + } + model, _ = model.Update(cmd()) + if model.after != nil || len(model.prevCursors) != 0 { + t.Fatalf("after prev after=%v prev=%v", model.after, model.prevCursors) + } +} + func TestNoConsoleNavigatesToAccess(t *testing.T) { loader := &fakeLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) @@ -99,11 +130,11 @@ func TestClustersGoldens(t *testing.T) { list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) list.loading = false list.mode = modeList - list.items = []clustersbridge.Summary{ + list.page = clustersbridge.Page{Items: []clustersbridge.Summary{ {ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, {ID: "c2", Name: "staging", Handle: "staging", Version: "1.29.0", Distro: "EKS"}, {ID: "c3", Name: "edge", Version: "1.28.1", Distro: "K3S"}, - } + }, HasNext: true, EndCursor: "c3"} detail := list detail.mode = modeDetail @@ -159,11 +190,11 @@ func TestWriteClustersGoldens(t *testing.T) { list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) list.loading = false list.mode = modeList - list.items = []clustersbridge.Summary{ + list.page = clustersbridge.Page{Items: []clustersbridge.Summary{ {ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, {ID: "c2", Name: "staging", Handle: "staging", Version: "1.29.0", Distro: "EKS"}, {ID: "c3", Name: "edge", Version: "1.28.1", Distro: "K3S"}, - } + }, HasNext: true, EndCursor: "c3"} detail := list detail.mode = modeDetail detail.detail = clustersbridge.Detail{ diff --git a/tui/screens/clusters/testdata/clusters-list-120.golden b/tui/screens/clusters/testdata/clusters-list-120.golden index 51be6fea1..be037dd59 100644 --- a/tui/screens/clusters/testdata/clusters-list-120.golden +++ b/tui/screens/clusters/testdata/clusters-list-120.golden @@ -7,7 +7,7 @@ │ @staging staging 1.29.0 EKS │ │ — edge 1.28.1 K3S │ │ │ - │ │ + │ page · n next │ │ │ │ │ │ │ @@ -27,4 +27,4 @@ - ↑/↓ select · enter open · / filter · r refresh · esc back + ↑/↓ select · enter open · / filter · n/p page · r refresh · esc back diff --git a/tui/screens/clusters/testdata/clusters-list-80.golden b/tui/screens/clusters/testdata/clusters-list-80.golden index f5a5b9cf7..080e76006 100644 --- a/tui/screens/clusters/testdata/clusters-list-80.golden +++ b/tui/screens/clusters/testdata/clusters-list-80.golden @@ -7,7 +7,7 @@ │ @staging staging 1.29.0 EKS │ │ — edge 1.28.1 K3S │ │ │ - │ │ + │ page · n next │ │ │ │ │ │ │ @@ -21,4 +21,4 @@ - ↑/↓ · enter · / filter · esc back + ↑/↓ · enter · / · n/p page · esc back diff --git a/tui/screens/clusters/view.go b/tui/screens/clusters/view.go index 5f7674164..cb20a6c6e 100644 --- a/tui/screens/clusters/view.go +++ b/tui/screens/clusters/view.go @@ -45,9 +45,9 @@ func (m Model) headerStatus() string { return m.theme.Success.Render(loCoalesce(m.detail.Distro, "ready")) case modeList: if m.filter != "" { - return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.items))) + return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.page.Items))) } - return m.theme.Success.Render(fmt.Sprintf("%d clusters", len(m.items))) + return m.theme.Success.Render(fmt.Sprintf("%d clusters", len(m.page.Items))) default: return m.theme.Muted.Render("clusters") } @@ -75,9 +75,9 @@ func (m Model) bodyAndHelp(width int) (string, string) { help := "r refresh · esc list · ctrl+c quit" return page.Panel(m.theme, "Summary", m.detailLines(), width, 16, true), help } - help := "↑/↓ select · enter open · / filter · r refresh · esc back" + help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc back" if width < 100 { - help = "↑/↓ · enter · / filter · esc back" + help = "↑/↓ · enter · / · n/p page · esc back" } return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help } @@ -90,19 +90,21 @@ func (m Model) listTitle() string { } func (m Model) listLines(width int) []string { - if m.loading && len(m.items) == 0 { + if m.loading && len(m.page.Items) == 0 { return []string{m.theme.Warning.Render("◌ Loading clusters…")} } if m.err != nil { return []string{m.theme.Danger.Render("✗ Unable to load clusters"), m.theme.Danger.Render("Error " + m.err.Error()), m.theme.Muted.Render("Press r to retry.")} } - if len(m.items) == 0 { + if len(m.page.Items) == 0 { return []string{m.theme.Warning.Render("○ No clusters found"), m.theme.Muted.Render(" Adjust the filter or connect another Console.")} } handleWidth := max(12, min(20, width/4)) nameWidth := max(12, min(24, width/3)) lines := []string{m.theme.Muted.Render(" " + pad("HANDLE", handleWidth) + " " + pad("NAME", nameWidth) + " " + pad("VERSION", 10) + " DISTRO")} - for i, item := range m.items { + start, end := visibleWindow(m.cursor, len(m.page.Items), 8) + for i := start; i < end; i++ { + item := m.page.Items[i] cursor := " " if i == m.cursor { cursor = "› " @@ -118,9 +120,44 @@ func (m Model) listLines(width int) []string { row := cursor + pad(handle, handleWidth) + " " + pad(item.Name, nameWidth) + " " + pad(version, 10) + " " + distro lines = append(lines, ansi.Truncate(row, width-2, "…")) } + if start > 0 || end < len(m.page.Items) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" … %d–%d of %d", start+1, end, len(m.page.Items)))) + } + if m.page.HasNext || len(m.prevCursors) > 0 { + pager := "page" + if len(m.prevCursors) > 0 { + pager += " · p prev" + } + if m.page.HasNext { + pager += " · n next" + } + lines = append(lines, "", m.theme.Muted.Render(pager)) + } return lines } +func visibleWindow(cursor, count, size int) (start, end int) { + if count <= 0 { + return 0, 0 + } + if size <= 0 { + size = count + } + if count <= size { + return 0, count + } + start = cursor - size/2 + if start < 0 { + start = 0 + } + end = start + size + if end > count { + end = count + start = end - size + } + return start, end +} + func (m Model) detailLines() []string { if m.loading { return []string{m.theme.Warning.Render("◌ Loading cluster detail…")} diff --git a/tui/screens/deployments/model.go b/tui/screens/deployments/model.go index a7c3c520e..9442db561 100644 --- a/tui/screens/deployments/model.go +++ b/tui/screens/deployments/model.go @@ -34,7 +34,7 @@ func resources() []resource { return []resource{ {id: resourceServices, number: "1", shortcut: "s", title: "Services", blurb: "browse · kick · create · …", route: navigation.Services}, {id: resourceClusters, number: "2", shortcut: "c", title: "Clusters", blurb: "list · describe", route: navigation.Clusters}, - {id: resourceRepositories, number: "3", shortcut: "r", title: "Repositories", blurb: "list · get", soon: true}, + {id: resourceRepositories, number: "3", shortcut: "r", title: "Repositories", blurb: "list · describe", route: navigation.Repositories}, {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "trigger", soon: true}, {id: resourceNotifications, number: "5", shortcut: "n", title: "Notifications", blurb: "sinks", soon: true}, {id: resourceProviders, number: "6", shortcut: "v", title: "Providers", blurb: "list", soon: true}, diff --git a/tui/screens/deployments/model_test.go b/tui/screens/deployments/model_test.go index 4b0ff56e6..39cbd4529 100644 --- a/tui/screens/deployments/model_test.go +++ b/tui/screens/deployments/model_test.go @@ -76,9 +76,20 @@ func TestClustersNavigates(t *testing.T) { } } +func TestRepositoriesNavigates(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + _, cmd := model.Update(tea.KeyPressMsg{Code: 'r', Text: "r"}) + if cmd == nil { + t.Fatal("expected navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Repositories}) { + t.Fatalf("msg = %#v", msg) + } +} + func TestSoonResourceDoesNotNavigate(t *testing.T) { model := New(t.Context(), theme.New(colorprofile.ASCII), "") - model.cursor = 2 // repositories [soon] + model.cursor = 3 // pipelines [soon] _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if cmd != nil { t.Fatalf("unexpected cmd %#v", cmd()) diff --git a/tui/screens/deployments/testdata/deployments-120.golden b/tui/screens/deployments/testdata/deployments-120.golden index 37618cc26..a5b6a7f64 100644 --- a/tui/screens/deployments/testdata/deployments-120.golden +++ b/tui/screens/deployments/testdata/deployments-120.golden @@ -4,7 +4,7 @@ ╭─ › Resources ────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ › 1 s Services browse · kick · create · … │ │ 2 c Clusters list · describe │ - │ 3 r Repositories list · get [soon] │ + │ 3 r Repositories list · describe │ │ 4 p Pipelines trigger [soon] │ │ 5 n Notifications sinks [soon] │ │ 6 v Providers list [soon] │ diff --git a/tui/screens/deployments/testdata/deployments-80.golden b/tui/screens/deployments/testdata/deployments-80.golden index 1539b8906..4230cd22d 100644 --- a/tui/screens/deployments/testdata/deployments-80.golden +++ b/tui/screens/deployments/testdata/deployments-80.golden @@ -4,7 +4,7 @@ ╭─ › Resources ────────────────────────────────────────────────────────────╮ │ › 1 s Services browse · kick · create · … │ │ 2 c Clusters list · describe │ - │ 3 r Repositories list · get [soon] │ + │ 3 r Repositories list · describe │ │ 4 p Pipelines trigger [soon] │ │ 5 n Notifications sinks [soon] │ │ 6 v Providers list [soon] │ diff --git a/tui/screens/repositories/model.go b/tui/screens/repositories/model.go new file mode 100644 index 000000000..d7e31813b --- /dev/null +++ b/tui/screens/repositories/model.go @@ -0,0 +1,306 @@ +// Package repositories implements the read-only Console git repositories browser. +package repositories + +import ( + "context" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeList mode = iota + modeDetail + modeFilter +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionFilter + keyActionConnectConsole + keyActionNextPage + keyActionPrevPage +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionRefresh: {"r"}, + keyActionFilter: {"/"}, + keyActionConnectConsole: {"c"}, + keyActionNextPage: {"n", "right", "]"}, + keyActionPrevPage: {"p", "left", "["}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + return keyActionNone +} + +type initMsg struct{} +type listedMsg struct { + page repositoriesbridge.Page + err error + request uint64 +} +type detailMsg struct { + detail repositoriesbridge.Detail + err error + request uint64 +} + +// Model owns Repositories-screen interaction state. +type Model struct { + ctx context.Context + loader repositoriesbridge.Loader + theme theme.Theme + mode mode + loading bool + err error + needsAuth bool + request uint64 + + page repositoriesbridge.Page + cursor int + filter string + filterInput textinput.Model + after *string + prevCursors []string + + detail repositoriesbridge.Detail + detailID string + listCursor int + listAfter *string + listFilter string + listPrev []string +} + +func New(ctx context.Context, loader repositoriesbridge.Loader, t theme.Theme) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "filter repositories" + input.CharLimit = 128 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + input.SetStyles(styles) + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, mode: modeList} +} + +func (m Model) Init() tea.Cmd { + return func() tea.Msg { return initMsg{} } +} + +func (m *Model) beginList(after *string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + query := m.filter + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + page, err := loader.List(ctx, after, query) + return listedMsg{page: page, err: err, request: request} + } +} + +func (m *Model) beginDetail(id string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + detail, err := loader.Get(ctx, id) + return detailMsg{detail: detail, err: err, request: request} + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.mode = modeList + m.page = repositoriesbridge.Page{} + m.cursor = 0 + m.after = nil + m.prevCursors = nil + m.err = nil + m.needsAuth = false + if m.loader == nil { + m.loading = false + return m, nil + } + return m, m.beginList(nil) + case listedMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.page = msg.page + m.cursor = clampCursor(m.cursor, len(m.page.Items)) + m.mode = modeList + } + return m, nil + case detailMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.detail = msg.detail + m.mode = modeDetail + } + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeFilter { + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + if m.mode == modeFilter { + switch action { + case keyActionBack: + m.mode = modeList + m.filterInput.Blur() + return m, nil + case keyActionConfirm: + m.filter = strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + m.mode = modeList + m.cursor = 0 + m.after = nil + m.prevCursors = nil + return m, m.beginList(nil) + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd + } + if action == keyActionBack { + if m.mode == modeDetail { + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.after = m.listAfter + m.filter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) + return m, nil + } + return m, navigation.Navigate(navigation.Deployments) + } + if m.loading { + return m, nil + } + if m.needsAuth && action == keyActionConnectConsole { + return m, navigation.Navigate(navigation.Access) + } + if m.mode == modeDetail { + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + return m, nil + } + return m.updateList(action) +} + +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionMoveUp: + m.cursor = clampCursor(m.cursor-1, len(m.page.Items)) + case keyActionMoveDown: + m.cursor = clampCursor(m.cursor+1, len(m.page.Items)) + case keyActionConfirm: + if len(m.page.Items) == 0 { + return m, nil + } + m.listCursor = m.cursor + m.listAfter = m.after + m.listFilter = m.filter + m.listPrev = append([]string(nil), m.prevCursors...) + m.detailID = m.page.Items[m.cursor].ID + return m, m.beginDetail(m.detailID) + case keyActionRefresh: + return m, m.beginList(m.after) + case keyActionFilter: + m.mode = modeFilter + m.filterInput.SetValue(m.filter) + m.filterInput.Focus() + case keyActionNextPage: + if !m.page.HasNext || m.page.EndCursor == "" { + return m, nil + } + if m.after != nil { + m.prevCursors = append(m.prevCursors, *m.after) + } else { + m.prevCursors = append(m.prevCursors, "") + } + cursor := m.page.EndCursor + m.after = &cursor + m.cursor = 0 + return m, m.beginList(m.after) + case keyActionPrevPage: + if len(m.prevCursors) == 0 { + return m, nil + } + previous := m.prevCursors[len(m.prevCursors)-1] + m.prevCursors = m.prevCursors[:len(m.prevCursors)-1] + if previous == "" { + m.after = nil + } else { + m.after = &previous + } + m.cursor = 0 + return m, m.beginList(m.after) + } + return m, nil +} + +func clampCursor(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} diff --git a/tui/screens/repositories/model_test.go b/tui/screens/repositories/model_test.go new file mode 100644 index 000000000..6cee2ec04 --- /dev/null +++ b/tui/screens/repositories/model_test.go @@ -0,0 +1,232 @@ +package repositories + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/pkg/bridge" + repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + page repositoriesbridge.Page + detail repositoriesbridge.Detail + err error +} + +func (f *fakeLoader) List(context.Context, *string, string) (repositoriesbridge.Page, error) { + return f.page, f.err +} +func (f *fakeLoader) Get(context.Context, string) (repositoriesbridge.Detail, error) { + return f.detail, f.err +} + +func loadList(t *testing.T, model Model) Model { + t.Helper() + cmd := model.Init() + model, cmd = model.Update(cmd()) + if cmd == nil { + t.Fatal("expected list command") + } + model, _ = model.Update(cmd()) + return model +} + +func TestOpenRepositoryDetailAndBack(t *testing.T) { + loader := &fakeLoader{ + page: repositoriesbridge.Page{Items: []repositoriesbridge.Summary{ + {ID: "r1", URL: "git@github.com:acme/infra.git", Health: "PULLABLE", AuthMethod: "SSH"}, + {ID: "r2", URL: "https://github.com/acme/apps.git", Health: "FAILED", Error: "auth failed"}, + }}, + detail: repositoriesbridge.Detail{ + Summary: repositoriesbridge.Summary{ + ID: "r1", URL: "git@github.com:acme/infra.git", Health: "PULLABLE", AuthMethod: "SSH", + }, + Decrypt: true, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if model.mode != modeList || len(model.page.Items) != 2 { + t.Fatalf("list state = mode=%d count=%d", model.mode, len(model.page.Items)) + } + if !strings.Contains(model.View(80, 24), "git@github.com:acme/infra.git") { + t.Fatalf("list missing url:\n%s", model.View(80, 24)) + } + + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail || model.detail.URL != "git@github.com:acme/infra.git" { + t.Fatalf("detail = %#v mode=%d", model.detail, model.mode) + } + if !strings.Contains(model.View(80, 24), "SSH") { + t.Fatalf("detail view missing auth:\n%s", model.View(80, 24)) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeList { + t.Fatalf("mode after detail esc = %d", model.mode) + } + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Deployments}) { + t.Fatalf("expected deployments navigation") + } +} + +func TestNextPrevPage(t *testing.T) { + loader := &fakeLoader{ + page: repositoriesbridge.Page{ + Items: []repositoriesbridge.Summary{{ID: "r1", URL: "git@github.com:acme/a.git", Health: "PULLABLE"}}, + EndCursor: "r1", + HasNext: true, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !strings.Contains(model.View(80, 24), "n next") { + t.Fatalf("missing next pager:\n%s", model.View(80, 24)) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: 'n'}) + if cmd == nil { + t.Fatal("expected next-page list command") + } + loader.page = repositoriesbridge.Page{ + Items: []repositoriesbridge.Summary{{ID: "r2", URL: "git@github.com:acme/b.git", Health: "PULLABLE"}}, + } + model, _ = model.Update(cmd()) + if model.after == nil || *model.after != "r1" || len(model.prevCursors) != 1 { + t.Fatalf("after page turn after=%v prev=%v", model.after, model.prevCursors) + } + if !strings.Contains(model.View(80, 24), "p prev") { + t.Fatalf("missing prev pager:\n%s", model.View(80, 24)) + } + model, cmd = model.Update(tea.KeyPressMsg{Code: 'p'}) + if cmd == nil { + t.Fatal("expected prev-page list command") + } + model, _ = model.Update(cmd()) + if model.after != nil || len(model.prevCursors) != 0 { + t.Fatalf("after prev after=%v prev=%v", model.after, model.prevCursors) + } +} + +func TestNoConsoleNavigatesToAccess(t *testing.T) { + loader := &fakeLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !model.needsAuth { + t.Fatal("expected needsAuth") + } + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c'}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Access}) { + t.Fatalf("expected access navigation") + } +} + +func TestRepositoriesGoldens(t *testing.T) { + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = repositoriesbridge.Page{Items: []repositoriesbridge.Summary{ + {ID: "r1", URL: "git@github.com:acme/infra.git", Health: "PULLABLE", AuthMethod: "SSH"}, + {ID: "r2", URL: "https://github.com/acme/apps.git", Health: "FAILED", Error: "auth failed"}, + {ID: "r3", URL: "git@gitlab.com:acme/charts.git", Health: "PULLABLE", AuthMethod: "SSH"}, + }, HasNext: true, EndCursor: "r3"} + + detail := list + detail.mode = modeDetail + detail.detail = repositoriesbridge.Detail{ + Summary: repositoriesbridge.Summary{ + ID: "r1", URL: "git@github.com:acme/infra.git", Health: "PULLABLE", AuthMethod: "SSH", + }, + Decrypt: true, + } + + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeView(tc.model.View(tc.width, tc.height)) + golden := filepath.Join("testdata", "repositories-"+tc.name+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + lines := strings.Split(got, "\n") + if len(lines) != tc.height { + t.Fatalf("height = %d, want %d", len(lines), tc.height) + } + for _, line := range lines { + if w := lipgloss.Width(line); w > tc.width { + t.Fatalf("line width %d > %d: %q", w, tc.width, line) + } + } + }) + } +} + +func TestWriteRepositoriesGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = repositoriesbridge.Page{Items: []repositoriesbridge.Summary{ + {ID: "r1", URL: "git@github.com:acme/infra.git", Health: "PULLABLE", AuthMethod: "SSH"}, + {ID: "r2", URL: "https://github.com/acme/apps.git", Health: "FAILED", Error: "auth failed"}, + {ID: "r3", URL: "git@gitlab.com:acme/charts.git", Health: "PULLABLE", AuthMethod: "SSH"}, + }, HasNext: true, EndCursor: "r3"} + detail := list + detail.mode = modeDetail + detail.detail = repositoriesbridge.Detail{ + Summary: repositoriesbridge.Summary{ + ID: "r1", URL: "git@github.com:acme/infra.git", Health: "PULLABLE", AuthMethod: "SSH", + }, + Decrypt: true, + } + _ = os.MkdirAll("testdata", 0o755) + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + got := normalizeView(tc.model.View(tc.width, tc.height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "repositories-"+tc.name+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/repositories/testdata/repositories-detail-120.golden b/tui/screens/repositories/testdata/repositories-detail-120.golden new file mode 100644 index 000000000..af585ab34 --- /dev/null +++ b/tui/screens/repositories/testdata/repositories-detail-120.golden @@ -0,0 +1,30 @@ + Plural Repositories · git@github.com:acme/infra.git PULLABLE + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ URL git@github.com:acme/infra.git │ + │ Health PULLABLE │ + │ Auth SSH │ + │ Decrypt true │ + │ ID r1 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/repositories/testdata/repositories-detail-80.golden b/tui/screens/repositories/testdata/repositories-detail-80.golden new file mode 100644 index 000000000..8a0cc838d --- /dev/null +++ b/tui/screens/repositories/testdata/repositories-detail-80.golden @@ -0,0 +1,24 @@ + Plural Repositories · git@github.com:acme/infra.git PULLABLE + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────╮ + │ URL git@github.com:acme/infra.git │ + │ Health PULLABLE │ + │ Auth SSH │ + │ Decrypt true │ + │ ID r1 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/repositories/testdata/repositories-list-120.golden b/tui/screens/repositories/testdata/repositories-list-120.golden new file mode 100644 index 000000000..380b32d95 --- /dev/null +++ b/tui/screens/repositories/testdata/repositories-list-120.golden @@ -0,0 +1,30 @@ + Plural Repositories 3 repositories + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Repositories ─────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ URL HEALTH ERROR │ + │ › git@github.com:acme/infra.git PULLABLE — │ + │ https://github.com/acme/apps.git FAILED auth failed │ + │ git@gitlab.com:acme/charts.git PULLABLE — │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · enter open · / filter · n/p page · r refresh · esc back diff --git a/tui/screens/repositories/testdata/repositories-list-80.golden b/tui/screens/repositories/testdata/repositories-list-80.golden new file mode 100644 index 000000000..dee3af16f --- /dev/null +++ b/tui/screens/repositories/testdata/repositories-list-80.golden @@ -0,0 +1,24 @@ + Plural Repositories 3 repositories + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Repositories ─────────────────────────────────────────────────────────╮ + │ URL HEALTH ERROR │ + │ › git@github.com:acme/infra.git PULLABLE — │ + │ https://github.com/acme/apps.git FAILED auth fail… │ + │ git@gitlab.com:acme/charts.git PULLABLE — │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / · n/p page · esc back diff --git a/tui/screens/repositories/view.go b/tui/screens/repositories/view.go new file mode 100644 index 000000000..eb1243e65 --- /dev/null +++ b/tui/screens/repositories/view.go @@ -0,0 +1,209 @@ +package repositories + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + title := "Repositories" + if m.mode == modeDetail && m.detail.URL != "" { + title = "Repositories · " + shortURL(m.detail.URL, 40) + } + body, help := m.bodyAndHelp(contentWidth) + return page.Render(m.theme, width, height, title, m.headerStatus(), body, help) +} + +func (m Model) headerStatus() string { + if m.loading { + return m.theme.Warning.Render("◌ loading") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + if m.err != nil { + return m.theme.Danger.Render("✗ load failed") + } + switch m.mode { + case modeDetail: + switch m.detail.Health { + case "PULLABLE": + return m.theme.Success.Render("PULLABLE") + case "FAILED": + return m.theme.Danger.Render("FAILED") + default: + return m.theme.Muted.Render(loCoalesce(m.detail.Health, "UNKNOWN")) + } + case modeList: + if m.filter != "" { + return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.page.Items))) + } + return m.theme.Success.Render(fmt.Sprintf("%d repositories", len(m.page.Items))) + default: + return m.theme.Muted.Render("repositories") + } +} + +func (m Model) bodyAndHelp(width int) (string, string) { + if m.mode == modeFilter { + lines := []string{ + m.theme.Muted.Render("Filter by url, id, health, error, or auth method."), + "", + m.filterInput.View(), + } + return page.Panel(m.theme, "Filter repositories", lines, width, 6, true), "enter apply · esc cancel" + } + if m.needsAuth { + lines := []string{ + m.theme.Warning.Render("○ Console is not connected"), + m.theme.Muted.Render(" Connect a Console profile to browse repositories."), + "", + m.theme.Body.Render("Press c to open Access."), + } + return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" + } + if m.mode == modeDetail { + help := "r refresh · esc list · ctrl+c quit" + return page.Panel(m.theme, "Summary", m.detailLines(), width, 16, true), help + } + help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc back" + if width < 100 { + help = "↑/↓ · enter · / · n/p page · esc back" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help +} + +func (m Model) listTitle() string { + if m.filter != "" { + return "Repositories · filter “" + m.filter + "”" + } + return "Repositories" +} + +func (m Model) listLines(width int) []string { + if m.loading && len(m.page.Items) == 0 { + return []string{m.theme.Warning.Render("◌ Loading repositories…")} + } + if m.err != nil { + return []string{ + m.theme.Danger.Render("✗ Unable to load repositories"), + m.theme.Danger.Render("Error " + m.err.Error()), + m.theme.Muted.Render("Press r to retry."), + } + } + if len(m.page.Items) == 0 { + return []string{ + m.theme.Warning.Render("○ No repositories found"), + m.theme.Muted.Render(" Adjust the filter or connect another Console."), + } + } + urlWidth := max(24, min(48, width*2/3)) + lines := []string{m.theme.Muted.Render(" " + pad("URL", urlWidth) + " " + pad("HEALTH", 10) + " ERROR")} + start, end := visibleWindow(m.cursor, len(m.page.Items), 8) + for i := start; i < end; i++ { + item := m.page.Items[i] + cursor := " " + if i == m.cursor { + cursor = "› " + } + health := loCoalesce(item.Health, "UNKNOWN") + errText := loCoalesce(item.Error, "—") + row := cursor + pad(item.URL, urlWidth) + " " + pad(health, 10) + " " + errText + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + if start > 0 || end < len(m.page.Items) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" … %d–%d of %d", start+1, end, len(m.page.Items)))) + } + if m.page.HasNext || len(m.prevCursors) > 0 { + pager := "page" + if len(m.prevCursors) > 0 { + pager += " · p prev" + } + if m.page.HasNext { + pager += " · n next" + } + lines = append(lines, "", m.theme.Muted.Render(pager)) + } + return lines +} + +func visibleWindow(cursor, count, size int) (start, end int) { + if count <= 0 { + return 0, 0 + } + if size <= 0 { + size = count + } + if count <= size { + return 0, count + } + start = cursor - size/2 + if start < 0 { + start = 0 + } + end = start + size + if end > count { + end = count + start = end - size + } + return start, end +} + +func (m Model) detailLines() []string { + if m.loading { + return []string{m.theme.Warning.Render("◌ Loading repository detail…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load repository"), m.theme.Danger.Render(m.err.Error())} + } + lines := []string{ + m.labelValue("URL", m.detail.URL), + m.labelValue("Health", loCoalesce(m.detail.Health, "UNKNOWN")), + m.labelValue("Auth", loCoalesce(m.detail.AuthMethod, "—")), + m.labelValue("Decrypt", fmt.Sprintf("%v", m.detail.Decrypt)), + m.labelValue("ID", m.detail.ID), + } + if m.detail.Error != "" { + lines = append(lines, m.theme.Danger.Render("Error "+m.detail.Error)) + } + return lines +} + +func (m Model) labelValue(label, value string) string { + label = label + strings.Repeat(" ", max(1, 12-len(label))) + return label + " " + value +} + +func pad(value string, width int) string { + value = ansi.Truncate(value, width, "…") + if lipgloss.Width(value) >= width { + return value + } + return value + strings.Repeat(" ", width-lipgloss.Width(value)) +} + +func loCoalesce(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func shortURL(url string, maxLen int) string { + if lipgloss.Width(url) <= maxLen { + return url + } + return ansi.Truncate(url, maxLen, "…") +} diff --git a/tui/screens/services/model.go b/tui/screens/services/model.go index 5ecf859e8..bb62664eb 100644 --- a/tui/screens/services/model.go +++ b/tui/screens/services/model.go @@ -54,15 +54,15 @@ const ( var keyActionKeystrokes = map[keyAction][]string{ keyActionBack: {"esc"}, - keyActionMoveUp: {"up"}, - keyActionMoveDown: {"down"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, keyActionConfirm: {"enter"}, keyActionRefresh: {"r"}, keyActionFilter: {"/"}, - keyActionNextPage: {"right", "]"}, - keyActionPrevPage: {"left", "p", "["}, + keyActionNextPage: {"n", "right", "]"}, + keyActionPrevPage: {"p", "left", "["}, keyActionConnectConsole: {"c"}, - keyActionCreate: {"n"}, + keyActionCreate: {"a"}, keyActionBackground: {"b"}, } @@ -415,7 +415,7 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { if m.mode == modeClusters { return m.updateClusters(action) } - return m.updateList(action, text) + return m.updateList(action) } func (m Model) updateFilter(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { @@ -472,6 +472,12 @@ func (m Model) updateDetail(action keyAction, text string) (Model, tea.Cmd) { return m, m.beginDetail(m.detailID) } actions := detailActions() + for i, a := range actions { + if text == a.shortcut { + m.actionCursor = i + return m.openAction(a) + } + } switch action { case keyActionMoveUp: m.actionCursor = clampCursor(m.actionCursor-1, len(actions)) @@ -482,12 +488,6 @@ func (m Model) updateDetail(action keyAction, text string) (Model, tea.Cmd) { case keyActionConfirm: return m.openAction(actions[m.actionCursor]) } - for i, a := range actions { - if text == a.shortcut { - m.actionCursor = i - return m.openAction(a) - } - } return m, nil } @@ -944,8 +944,8 @@ func (m Model) updateClusters(action keyAction) (Model, tea.Cmd) { return m, nil } -func (m Model) updateList(action keyAction, text string) (Model, tea.Cmd) { - if action == keyActionCreate || text == "n" { +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + if action == keyActionCreate { if m.cluster.ID == "" { return m, nil } diff --git a/tui/screens/services/model_test.go b/tui/screens/services/model_test.go index 75240f688..d268fb4fb 100644 --- a/tui/screens/services/model_test.go +++ b/tui/screens/services/model_test.go @@ -134,6 +134,55 @@ func TestNoConsoleNavigatesToAccess(t *testing.T) { } } +func TestNextPrevPage(t *testing.T) { + loader := &fakeLoader{ + clusters: []servicesbridge.Cluster{{ID: "c1", Handle: "prod-eu"}}, + page: servicesbridge.Page{ + Items: []servicesbridge.Summary{{ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY"}}, + EndCursor: "1", + HasNext: true, + }, + } + model := loadClusters(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeList { + t.Fatalf("mode = %d", model.mode) + } + if !strings.Contains(model.View(80, 24), "n next") { + t.Fatalf("missing next pager:\n%s", model.View(80, 24)) + } + + model, cmd = model.Update(tea.KeyPressMsg{Code: 'n', Text: "n"}) + if cmd == nil { + t.Fatal("expected next-page list command") + } + loader.page = servicesbridge.Page{ + Items: []servicesbridge.Summary{{ID: "2", Name: "worker", Namespace: "jobs", Status: "FAILED"}}, + } + model, _ = model.Update(cmd()) + if model.after == nil || *model.after != "1" || len(model.prevCursors) != 1 { + t.Fatalf("after page turn after=%v prev=%v", model.after, model.prevCursors) + } + if !strings.Contains(model.View(80, 24), "worker") || !strings.Contains(model.View(80, 24), "p prev") { + t.Fatalf("second page view:\n%s", model.View(80, 24)) + } + + model, cmd = model.Update(tea.KeyPressMsg{Code: 'p', Text: "p"}) + if cmd == nil { + t.Fatal("expected prev-page list command") + } + loader.page = servicesbridge.Page{ + Items: []servicesbridge.Summary{{ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY"}}, + EndCursor: "1", + HasNext: true, + } + model, _ = model.Update(cmd()) + if model.after != nil || len(model.prevCursors) != 0 { + t.Fatalf("after prev after=%v prev=%v", model.after, model.prevCursors) + } +} + func TestKickFromDetail(t *testing.T) { loader := &fakeLoader{ clusters: []servicesbridge.Cluster{{ID: "c1", Name: "production", Handle: "prod-eu"}}, @@ -250,3 +299,32 @@ func TestBackFromClustersReturnsDeployments(t *testing.T) { t.Fatalf("msg = %#v", msg) } } + +func TestListScrollKeepsCursorVisible(t *testing.T) { + items := make([]servicesbridge.Summary, 0, 20) + for i := 0; i < 20; i++ { + items = append(items, servicesbridge.Summary{ + ID: string(rune('a'+i)), Name: "svc-" + string(rune('a'+i)), Namespace: "default", Status: "HEALTHY", + }) + } + loader := &fakeLoader{ + clusters: []servicesbridge.Cluster{{ID: "c1", Handle: "prod-eu"}}, + page: servicesbridge.Page{Items: items}, + } + model := loadClusters(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeList { + t.Fatalf("mode = %d", model.mode) + } + for i := 0; i < 12; i++ { + model, _ = model.Update(tea.KeyPressMsg{Code: 'j'}) + } + view := ansi.Strip(model.View(80, 24)) + if !strings.Contains(view, "svc-m") { + t.Fatalf("cursor row not visible after scroll:\n%s", view) + } + if !strings.Contains(view, "…") { + t.Fatalf("expected window indicator:\n%s", view) + } +} diff --git a/tui/screens/services/testdata/services-clusters-120.golden b/tui/screens/services/testdata/services-clusters-120.golden index 1095f872a..c23b0357d 100644 --- a/tui/screens/services/testdata/services-clusters-120.golden +++ b/tui/screens/services/testdata/services-clusters-120.golden @@ -27,4 +27,4 @@ - ↑/↓ select · enter open cluster · / filter · r refresh · esc back + ↑/↓ · j/k select · enter open cluster · / filter · r refresh · esc back diff --git a/tui/screens/services/testdata/services-list-120.golden b/tui/screens/services/testdata/services-list-120.golden index 48cab48b5..b361cad4e 100644 --- a/tui/screens/services/testdata/services-list-120.golden +++ b/tui/screens/services/testdata/services-list-120.golden @@ -7,7 +7,7 @@ │ worker jobs FAILED main services/worker │ │ canary default SYNCED release services/canary │ │ │ - │ page · ] next │ + │ page · n next │ │ │ │ │ │ │ @@ -27,4 +27,4 @@ - ↑/↓ · enter detail · n create · / filter · ]/[ page · r refresh · esc + ↑/↓ · j/k · enter detail · a create · / filter · n/p page · r refresh · esc diff --git a/tui/screens/services/testdata/services-list-80.golden b/tui/screens/services/testdata/services-list-80.golden index 4692ffe30..c2c9b3336 100644 --- a/tui/screens/services/testdata/services-list-80.golden +++ b/tui/screens/services/testdata/services-list-80.golden @@ -7,7 +7,7 @@ │ worker jobs FAILED main services/wor… │ │ canary default SYNCED release services/… │ │ │ - │ page · ] next │ + │ page · n next │ │ │ │ │ │ │ @@ -21,4 +21,4 @@ - ↑/↓ · enter · n create · / · esc + ↑/↓ · enter · a create · n/p page · esc diff --git a/tui/screens/services/view.go b/tui/screens/services/view.go index ae1f978e5..f08837bac 100644 --- a/tui/screens/services/view.go +++ b/tui/screens/services/view.go @@ -189,15 +189,15 @@ func (m Model) bodyAndHelp(width int) (string, string) { } return summary + "\n\n" + actions, help case modeClusters: - help := "↑/↓ select · enter open cluster · / filter · r refresh · esc back" + help := "↑/↓ · j/k select · enter open cluster · / filter · r refresh · esc back" if width < 100 { help = "↑/↓ · enter · / filter · esc back" } return page.Panel(m.theme, m.clusterTitle(), m.clusterLines(width), width, 14, true), help default: - help := "↑/↓ · enter detail · n create · / filter · ]/[ page · r refresh · esc" + help := "↑/↓ · j/k · enter detail · a create · / filter · n/p page · r refresh · esc" if width < 100 { - help = "↑/↓ · enter · n create · / · esc" + help = "↑/↓ · enter · a create · n/p page · esc" } return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help } @@ -290,7 +290,9 @@ func (m Model) clusterLines(width int) []string { } handleWidth := max(12, min(24, width/3)) lines := []string{m.theme.Muted.Render(" " + pad("HANDLE", handleWidth) + " " + pad("NAME", 24) + " ID")} - for i, cluster := range m.clusters { + start, end := visibleWindow(m.clusterCursor, len(m.clusters), 8) + for i := start; i < end; i++ { + cluster := m.clusters[i] cursor := " " if i == m.clusterCursor { cursor = "› " @@ -307,6 +309,9 @@ func (m Model) clusterLines(width int) []string { } lines = append(lines, ansi.Truncate(row, width-2, "…")) } + if start > 0 || end < len(m.clusters) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" … %d–%d of %d", start+1, end, len(m.clusters)))) + } return lines } @@ -318,11 +323,13 @@ func (m Model) listLines(width int) []string { return []string{m.theme.Danger.Render("✗ Unable to load services"), m.theme.Danger.Render("Error " + m.err.Error()), m.theme.Muted.Render("Press r to retry.")} } if len(m.page.Items) == 0 { - return []string{m.theme.Warning.Render("○ No services found"), m.theme.Muted.Render(" Press n to create, or adjust the filter.")} + return []string{m.theme.Warning.Render("○ No services found"), m.theme.Muted.Render(" Press a to create, or adjust the filter.")} } nameWidth := max(12, min(28, width/3)) lines := []string{m.theme.Muted.Render(" " + pad("NAME", nameWidth) + " " + pad("NAMESPACE", 14) + " " + pad("STATUS", 10) + " GIT")} - for i, item := range m.page.Items { + start, end := visibleWindow(m.cursor, len(m.page.Items), 8) + for i := start; i < end; i++ { + item := m.page.Items[i] cursor := " " if i == m.cursor { cursor = "› " @@ -334,19 +341,44 @@ func (m Model) listLines(width int) []string { row := cursor + pad(item.Name, nameWidth) + " " + pad(item.Namespace, 14) + " " + pad(item.Status, 10) + " " + git lines = append(lines, ansi.Truncate(row, width-2, "…")) } + if start > 0 || end < len(m.page.Items) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" … %d–%d of %d", start+1, end, len(m.page.Items)))) + } if m.page.HasNext || len(m.prevCursors) > 0 { pager := "page" if len(m.prevCursors) > 0 { - pager += " · [ prev" + pager += " · p prev" } if m.page.HasNext { - pager += " · ] next" + pager += " · n next" } lines = append(lines, "", m.theme.Muted.Render(pager)) } return lines } +func visibleWindow(cursor, count, size int) (start, end int) { + if count <= 0 { + return 0, 0 + } + if size <= 0 { + size = count + } + if count <= size { + return 0, count + } + start = cursor - size/2 + if start < 0 { + start = 0 + } + end = start + size + if end > count { + end = count + start = end - size + } + return start, end +} + func (m Model) detailLines() []string { if m.loading { return []string{m.theme.Warning.Render("◌ Loading service detail…")} From e40dad3a494dbfc316767e38dc28019a3f859599 Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Thu, 30 Jul 2026 14:00:27 +0200 Subject: [PATCH 09/16] add pipelines --- cmd/command/tui/tui.go | 3 + pkg/bridge/pipelines/pipelines.go | 235 ++++++++++++++ pkg/bridge/pipelines/pipelines_test.go | 115 +++++++ pkg/console/console.go | 2 + pkg/console/pipelines.go | 30 ++ pkg/test/mocks/ConsoleClient.go | 60 ++++ tui/app/model.go | 9 + tui/app/model_test.go | 5 + tui/app/view.go | 2 + tui/navigation/navigation.go | 1 + tui/screens/deployments/model.go | 2 +- tui/screens/deployments/model_test.go | 13 +- .../testdata/deployments-120.golden | 2 +- .../testdata/deployments-80.golden | 2 +- tui/screens/pipelines/model.go | 306 ++++++++++++++++++ tui/screens/pipelines/model_test.go | 233 +++++++++++++ .../testdata/pipelines-detail-120.golden | 30 ++ .../testdata/pipelines-detail-80.golden | 24 ++ .../testdata/pipelines-list-120.golden | 30 ++ .../testdata/pipelines-list-80.golden | 24 ++ tui/screens/pipelines/view.go | 207 ++++++++++++ 21 files changed, 1331 insertions(+), 4 deletions(-) create mode 100644 pkg/bridge/pipelines/pipelines.go create mode 100644 pkg/bridge/pipelines/pipelines_test.go create mode 100644 tui/screens/pipelines/model.go create mode 100644 tui/screens/pipelines/model_test.go create mode 100644 tui/screens/pipelines/testdata/pipelines-detail-120.golden create mode 100644 tui/screens/pipelines/testdata/pipelines-detail-80.golden create mode 100644 tui/screens/pipelines/testdata/pipelines-list-120.golden create mode 100644 tui/screens/pipelines/testdata/pipelines-list-80.golden create mode 100644 tui/screens/pipelines/view.go diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go index 982b54d98..f7b22dc47 100644 --- a/cmd/command/tui/tui.go +++ b/cmd/command/tui/tui.go @@ -9,6 +9,7 @@ import ( "github.com/pluralsh/plural-cli/pkg/bridge" accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" @@ -25,12 +26,14 @@ func Command() cli.Command { services := servicesbridge.NewService(access) clusters := clustersbridge.NewService(access) repositories := repositoriesbridge.NewService(access) + pipelines := pipelinesbridge.NewService(access) return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{ Welcome: welcome, Access: access, Services: services, Clusters: clusters, Repositories: repositories, + Pipelines: pipelines, }) }) } diff --git a/pkg/bridge/pipelines/pipelines.go b/pkg/bridge/pipelines/pipelines.go new file mode 100644 index 000000000..f4f9b868c --- /dev/null +++ b/pkg/bridge/pipelines/pipelines.go @@ -0,0 +1,235 @@ +// Package pipelines exposes read-only Console pipeline list/get use cases to +// presentation layers without importing TUI code. +package pipelines + +import ( + "context" + "errors" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +const defaultPageSize int64 = 10 + +var ( + errNoConsole = errors.New("connect a Console profile before browsing Console resources") + errMissingID = errors.New("pipeline id is required") + errMissingPipeline = errors.New("pipeline was not found") +) + +// Summary is a credential-free list row for a Console pipeline. +type Summary struct { + ID string + Name string + Project string + StageCount int +} + +// Stage is a credential-free pipeline stage summary. +type Stage struct { + Name string + Services []string +} + +// Edge is a credential-free stage promotion edge. +type Edge struct { + From string + To string +} + +// Detail is the credential-free detail payload for a Console pipeline. +type Detail struct { + Summary + Stages []Stage + Edges []Edge +} + +// Page is one cursor page of pipeline summaries. +type Page struct { + Items []Summary + EndCursor string + HasNext bool + TotalShown int +} + +// Loader is the narrow contract consumed by the Pipelines screen. +type Loader interface { + List(ctx context.Context, after *string, query string) (Page, error) + Get(ctx context.Context, id string) (Detail, error) +} + +// ConsoleResolver supplies the active Console URL and token. +type ConsoleResolver interface { + ActiveConsole(ctx context.Context) (url, token string, err error) +} + +// API is the Console surface required by this package. +type API interface { + ListPipelines() (*gqlclient.GetPipelines, error) + GetPipeline(id string) (*gqlclient.PipelineFragment, error) +} + +// ClientFactory builds a Console API for an authenticated endpoint. +type ClientFactory func(token, url string) (API, error) + +// Service implements Loader against Console GraphQL. +type Service struct { + resolve ConsoleResolver + newClient ClientFactory + pageSize int64 +} + +// NewService wires production Console credentials and client construction. +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + pageSize: defaultPageSize, + } +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s.resolve == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + url, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return nil, err + } + factory := s.newClient + if factory == nil { + factory = func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + } + } + return factory(token, url) +} + +func (s *Service) List(ctx context.Context, after *string, query string) (Page, error) { + if err := ctx.Err(); err != nil { + return Page{}, err + } + client, err := s.client(ctx) + if err != nil { + return Page{}, err + } + result, err := client.ListPipelines() + if err != nil { + return Page{}, err + } + if result == nil || result.Pipelines == nil { + return Page{}, nil + } + items := make([]Summary, 0, len(result.Pipelines.Edges)) + for _, edge := range result.Pipelines.Edges { + if edge == nil || edge.Node == nil { + continue + } + summary := summaryFromFragment(edge.Node) + if !matchesQuery(summary, query) { + continue + } + items = append(items, summary) + } + return pageItems(items, after, s.pageSize), nil +} + +func (s *Service) Get(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + pipeline, err := client.GetPipeline(id) + if err != nil { + return Detail{}, err + } + if pipeline == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingPipeline} + } + return detailFromFragment(pipeline), nil +} + +func pageItems(items []Summary, after *string, pageSize int64) Page { + if pageSize <= 0 { + pageSize = defaultPageSize + } + start := 0 + if after != nil && *after != "" { + for i, item := range items { + if item.ID == *after { + start = i + 1 + break + } + } + } + if start > len(items) { + start = len(items) + } + end := start + int(pageSize) + if end > len(items) { + end = len(items) + } + page := Page{Items: items[start:end], TotalShown: end - start, HasNext: end < len(items)} + if len(page.Items) > 0 { + page.EndCursor = page.Items[len(page.Items)-1].ID + } + return page +} + +func summaryFromFragment(node *gqlclient.PipelineFragment) Summary { + summary := Summary{ID: node.ID, Name: node.Name, StageCount: len(node.Stages)} + if node.Project != nil { + summary.Project = node.Project.Name + } + return summary +} + +func detailFromFragment(node *gqlclient.PipelineFragment) Detail { + detail := Detail{Summary: summaryFromFragment(node)} + for _, stage := range node.Stages { + if stage == nil { + continue + } + item := Stage{Name: stage.Name} + for _, svc := range stage.Services { + if svc == nil || svc.Service == nil { + continue + } + name := svc.Service.Name + if svc.Service.Namespace != "" { + name = svc.Service.Namespace + "/" + name + } + item.Services = append(item.Services, name) + } + detail.Stages = append(detail.Stages, item) + } + for _, edge := range node.Edges { + if edge == nil { + continue + } + detail.Edges = append(detail.Edges, Edge{From: edge.From.Name, To: edge.To.Name}) + } + return detail +} + +func matchesQuery(summary Summary, query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{summary.Name, summary.Project, summary.ID}, " ")) + return strings.Contains(haystack, query) +} diff --git a/pkg/bridge/pipelines/pipelines_test.go b/pkg/bridge/pipelines/pipelines_test.go new file mode 100644 index 000000000..dca1b1943 --- /dev/null +++ b/pkg/bridge/pipelines/pipelines_test.go @@ -0,0 +1,115 @@ +package pipelines + +import ( + "context" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeResolver struct { + url, token string + err error +} + +func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) { + return f.url, f.token, f.err +} + +type fakeAPI struct { + pipelines *gqlclient.GetPipelines + listErr error + detail *gqlclient.PipelineFragment + getErr error +} + +func (f *fakeAPI) ListPipelines() (*gqlclient.GetPipelines, error) { return f.pipelines, f.listErr } +func (f *fakeAPI) GetPipeline(string) (*gqlclient.PipelineFragment, error) { + return f.detail, f.getErr +} + +func TestListAndGet(t *testing.T) { + api := &fakeAPI{ + pipelines: &gqlclient.GetPipelines{Pipelines: &gqlclient.GetPipelines_Pipelines{ + Edges: []*gqlclient.PipelineEdgeFragment{ + {Node: &gqlclient.PipelineFragment{ + ID: "p1", Name: "deploy-prod", + Project: &gqlclient.TinyProjectFragment{Name: "acme"}, + Stages: []*gqlclient.PipelineStageFragment{{Name: "dev"}, {Name: "prod"}}, + }}, + {Node: &gqlclient.PipelineFragment{ID: "p2", Name: "canary"}}, + }, + }}, + detail: &gqlclient.PipelineFragment{ + ID: "p1", Name: "deploy-prod", + Project: &gqlclient.TinyProjectFragment{Name: "acme"}, + Stages: []*gqlclient.PipelineStageFragment{ + {Name: "dev", Services: []*gqlclient.PipelineStageFragment_Services{ + {Service: &gqlclient.ServiceDeploymentBaseFragment{Name: "api", Namespace: "default"}}, + }}, + {Name: "prod"}, + }, + Edges: []*gqlclient.PipelineStageEdgeFragment{ + {From: gqlclient.PipelineStageFragment{Name: "dev"}, To: gqlclient.PipelineStageFragment{Name: "prod"}}, + }, + }, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 10, + } + + page, err := service.List(t.Context(), nil, "deploy") + if err != nil || len(page.Items) != 1 || page.Items[0].Name != "deploy-prod" || page.Items[0].StageCount != 2 { + t.Fatalf("List() = %#v, %v", page, err) + } + + detail, err := service.Get(t.Context(), "p1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if detail.Project != "acme" || len(detail.Stages) != 2 || len(detail.Edges) != 1 || detail.Edges[0].From != "dev" { + t.Fatalf("detail = %#v", detail) + } + if len(detail.Stages[0].Services) != 1 || detail.Stages[0].Services[0] != "default/api" { + t.Fatalf("stage services = %#v", detail.Stages[0].Services) + } +} + +func TestListPages(t *testing.T) { + edges := make([]*gqlclient.PipelineEdgeFragment, 0, 3) + for _, id := range []string{"p1", "p2", "p3"} { + edges = append(edges, &gqlclient.PipelineEdgeFragment{ + Node: &gqlclient.PipelineFragment{ID: id, Name: id}, + }) + } + api := &fakeAPI{pipelines: &gqlclient.GetPipelines{Pipelines: &gqlclient.GetPipelines_Pipelines{Edges: edges}}} + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 2, + } + first, err := service.List(t.Context(), nil, "") + if err != nil || len(first.Items) != 2 || !first.HasNext || first.EndCursor != "p2" { + t.Fatalf("first = %#v, %v", first, err) + } + after := first.EndCursor + second, err := service.List(t.Context(), &after, "") + if err != nil || len(second.Items) != 1 || second.HasNext || second.Items[0].ID != "p3" { + t.Fatalf("second = %#v, %v", second, err) + } +} + +func TestGetRequiresID(t *testing.T) { + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return &fakeAPI{}, nil }, + } + _, err := service.Get(t.Context(), "") + if !bridge.IsCode(err, bridge.ErrorInvalid) { + t.Fatalf("Get() error = %v", err) + } +} diff --git a/pkg/console/console.go b/pkg/console/console.go index 0cc271406..887f2d4d9 100644 --- a/pkg/console/console.go +++ b/pkg/console/console.go @@ -44,6 +44,8 @@ type ConsoleClient interface { CreateProviderCredentials(name string, attr consoleclient.ProviderCredentialAttributes) (*consoleclient.CreateProviderCredential, error) DeleteProviderCredentials(id string) (*consoleclient.DeleteProviderCredential, error) SavePipeline(name string, attrs consoleclient.PipelineAttributes) (*consoleclient.PipelineFragmentMinimal, error) + ListPipelines() (*consoleclient.GetPipelines, error) + GetPipeline(id string) (*consoleclient.PipelineFragment, error) CreatePipelineContext(id string, attrs consoleclient.PipelineContextAttributes) (*consoleclient.PipelineContextFragment, error) GetPipelineContext(id string) (*consoleclient.PipelineContextFragment, error) CreateCluster(attributes consoleclient.ClusterAttributes) (*consoleclient.CreateCluster, error) diff --git a/pkg/console/pipelines.go b/pkg/console/pipelines.go index 2061d61c2..3e2029e5e 100644 --- a/pkg/console/pipelines.go +++ b/pkg/console/pipelines.go @@ -1,6 +1,7 @@ package console import ( + "fmt" "strings" gqlclient "github.com/pluralsh/console/go/client" @@ -53,6 +54,35 @@ func (c *consoleClient) SavePipeline(name string, attrs gqlclient.PipelineAttrib return result.SavePipeline, nil } +func (c *consoleClient) ListPipelines() (*gqlclient.GetPipelines, error) { + result, err := c.client.GetPipelines(c.ctx, nil) + if err != nil { + return nil, api.GetErrorResponse(err, "GetPipelines") + } + return result, nil +} + +// GetPipeline returns a full PipelineFragment. The generated GetPipeline query only +// returns id/name, so this resolves detail from the list query instead. +func (c *consoleClient) GetPipeline(id string) (*gqlclient.PipelineFragment, error) { + result, err := c.ListPipelines() + if err != nil { + return nil, err + } + if result == nil || result.Pipelines == nil { + return nil, fmt.Errorf("pipeline %s was not found", id) + } + for _, edge := range result.Pipelines.Edges { + if edge == nil || edge.Node == nil { + continue + } + if edge.Node.ID == id { + return edge.Node, nil + } + } + return nil, fmt.Errorf("pipeline %s was not found", id) +} + func (c *consoleClient) CreatePipelineContext(id string, attrs gqlclient.PipelineContextAttributes) (*gqlclient.PipelineContextFragment, error) { result, err := c.client.CreatePipelineContext(c.ctx, id, attrs) if err != nil { diff --git a/pkg/test/mocks/ConsoleClient.go b/pkg/test/mocks/ConsoleClient.go index 520d16e89..c8c6f1254 100644 --- a/pkg/test/mocks/ConsoleClient.go +++ b/pkg/test/mocks/ConsoleClient.go @@ -721,6 +721,36 @@ func (_m *ConsoleClient) GetGlobalSettingsMinimal() (*client.DeploymentSettingsF return r0, r1 } +// GetPipeline provides a mock function with given fields: id +func (_m *ConsoleClient) GetPipeline(id string) (*client.PipelineFragment, error) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for GetPipeline") + } + + var r0 *client.PipelineFragment + var r1 error + if rf, ok := ret.Get(0).(func(string) (*client.PipelineFragment, error)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) *client.PipelineFragment); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.PipelineFragment) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetPipelineContext provides a mock function with given fields: id func (_m *ConsoleClient) GetPipelineContext(id string) (*client.PipelineContextFragment, error) { ret := _m.Called(id) @@ -1111,6 +1141,36 @@ func (_m *ConsoleClient) ListProviders() (*client.ListProviders, error) { return r0, r1 } +// ListPipelines provides a mock function with no fields +func (_m *ConsoleClient) ListPipelines() (*client.GetPipelines, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ListPipelines") + } + + var r0 *client.GetPipelines + var r1 error + if rf, ok := ret.Get(0).(func() (*client.GetPipelines, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() *client.GetPipelines); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.GetPipelines) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // ListRepositories provides a mock function with no fields func (_m *ConsoleClient) ListRepositories() (*client.ListGitRepositories, error) { ret := _m.Called() diff --git a/tui/app/model.go b/tui/app/model.go index d591564ff..778d0cb05 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -9,6 +9,7 @@ import ( accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" @@ -17,6 +18,7 @@ import ( clustersscreen "github.com/pluralsh/plural-cli/tui/screens/clusters" deploymentsscreen "github.com/pluralsh/plural-cli/tui/screens/deployments" diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" + pipelinesscreen "github.com/pluralsh/plural-cli/tui/screens/pipelines" repositoriesscreen "github.com/pluralsh/plural-cli/tui/screens/repositories" servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" welcomescreen "github.com/pluralsh/plural-cli/tui/screens/welcome" @@ -30,6 +32,7 @@ type Dependencies struct { Services servicesbridge.Loader Clusters clustersbridge.Loader Repositories repositoriesbridge.Loader + Pipelines pipelinesbridge.Loader } // Model is the root TUI model. It owns global input and delegates screen state @@ -48,6 +51,7 @@ type Model struct { services servicesscreen.Model clusters clustersscreen.Model repositories repositoriesscreen.Model + pipelines pipelinesscreen.Model route navigation.Route } @@ -62,6 +66,7 @@ func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { services: servicesscreen.New(ctx, dependencies.Services, t), clusters: clustersscreen.New(ctx, dependencies.Clusters, t), repositories: repositoriesscreen.New(ctx, dependencies.Repositories, t), + pipelines: pipelinesscreen.New(ctx, dependencies.Pipelines, t), route: navigation.Welcome, quit: key.NewBinding( key.WithKeys("ctrl+c"), @@ -90,6 +95,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.clusters.Init() case navigation.Repositories: return m, m.repositories.Init() + case navigation.Pipelines: + return m, m.pipelines.Init() default: return m, m.welcome.Init() } @@ -121,6 +128,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.clusters, cmd = m.clusters.Update(msg) case navigation.Repositories: m.repositories, cmd = m.repositories.Update(msg) + case navigation.Pipelines: + m.pipelines, cmd = m.pipelines.Update(msg) default: m.welcome, cmd = m.welcome.Update(msg) } diff --git a/tui/app/model_test.go b/tui/app/model_test.go index 1f5dd892d..b01d3f9c6 100644 --- a/tui/app/model_test.go +++ b/tui/app/model_test.go @@ -65,6 +65,11 @@ func TestModelRoutesScreensWithoutRebuildingShell(t *testing.T) { if routed.route != navigation.Repositories || !strings.Contains(routed.View().Content, "Repositories") { t.Fatalf("repositories route/view = %q\n%s", routed.route, routed.View().Content) } + updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Pipelines}) + routed = updated.(Model) + if routed.route != navigation.Pipelines || !strings.Contains(routed.View().Content, "Pipelines") { + t.Fatalf("pipelines route/view = %q\n%s", routed.route, routed.View().Content) + } updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Welcome}) if got := updated.(Model).route; got != navigation.Welcome { t.Fatalf("route = %q", got) diff --git a/tui/app/view.go b/tui/app/view.go index d9d1c4c0f..c2009fb38 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -23,6 +23,8 @@ func (m Model) View() tea.View { content = m.clusters.View(m.width, m.height) case navigation.Repositories: content = m.repositories.View(m.width, m.height) + case navigation.Pipelines: + content = m.pipelines.View(m.width, m.height) } view := tea.NewView(content) view.AltScreen = true diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index 8b4ddda5b..0f7dd7382 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -16,6 +16,7 @@ const ( Services Route = "services" Clusters Route = "clusters" Repositories Route = "repositories" + Pipelines Route = "pipelines" ) // NavigateMsg requests a top-level route change. diff --git a/tui/screens/deployments/model.go b/tui/screens/deployments/model.go index 9442db561..ca1dc1eed 100644 --- a/tui/screens/deployments/model.go +++ b/tui/screens/deployments/model.go @@ -35,7 +35,7 @@ func resources() []resource { {id: resourceServices, number: "1", shortcut: "s", title: "Services", blurb: "browse · kick · create · …", route: navigation.Services}, {id: resourceClusters, number: "2", shortcut: "c", title: "Clusters", blurb: "list · describe", route: navigation.Clusters}, {id: resourceRepositories, number: "3", shortcut: "r", title: "Repositories", blurb: "list · describe", route: navigation.Repositories}, - {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "trigger", soon: true}, + {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "list · describe", route: navigation.Pipelines}, {id: resourceNotifications, number: "5", shortcut: "n", title: "Notifications", blurb: "sinks", soon: true}, {id: resourceProviders, number: "6", shortcut: "v", title: "Providers", blurb: "list", soon: true}, } diff --git a/tui/screens/deployments/model_test.go b/tui/screens/deployments/model_test.go index 39cbd4529..dba070dd0 100644 --- a/tui/screens/deployments/model_test.go +++ b/tui/screens/deployments/model_test.go @@ -87,9 +87,20 @@ func TestRepositoriesNavigates(t *testing.T) { } } +func TestPipelinesNavigates(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + _, cmd := model.Update(tea.KeyPressMsg{Code: 'p', Text: "p"}) + if cmd == nil { + t.Fatal("expected navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Pipelines}) { + t.Fatalf("msg = %#v", msg) + } +} + func TestSoonResourceDoesNotNavigate(t *testing.T) { model := New(t.Context(), theme.New(colorprofile.ASCII), "") - model.cursor = 3 // pipelines [soon] + model.cursor = 4 // notifications [soon] _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if cmd != nil { t.Fatalf("unexpected cmd %#v", cmd()) diff --git a/tui/screens/deployments/testdata/deployments-120.golden b/tui/screens/deployments/testdata/deployments-120.golden index a5b6a7f64..a30a1c5a8 100644 --- a/tui/screens/deployments/testdata/deployments-120.golden +++ b/tui/screens/deployments/testdata/deployments-120.golden @@ -5,7 +5,7 @@ │ › 1 s Services browse · kick · create · … │ │ 2 c Clusters list · describe │ │ 3 r Repositories list · describe │ - │ 4 p Pipelines trigger [soon] │ + │ 4 p Pipelines list · describe │ │ 5 n Notifications sinks [soon] │ │ 6 v Providers list [soon] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/tui/screens/deployments/testdata/deployments-80.golden b/tui/screens/deployments/testdata/deployments-80.golden index 4230cd22d..449f93584 100644 --- a/tui/screens/deployments/testdata/deployments-80.golden +++ b/tui/screens/deployments/testdata/deployments-80.golden @@ -5,7 +5,7 @@ │ › 1 s Services browse · kick · create · … │ │ 2 c Clusters list · describe │ │ 3 r Repositories list · describe │ - │ 4 p Pipelines trigger [soon] │ + │ 4 p Pipelines list · describe │ │ 5 n Notifications sinks [soon] │ │ 6 v Providers list [soon] │ ╰──────────────────────────────────────────────────────────────────────────╯ diff --git a/tui/screens/pipelines/model.go b/tui/screens/pipelines/model.go new file mode 100644 index 000000000..77c664b20 --- /dev/null +++ b/tui/screens/pipelines/model.go @@ -0,0 +1,306 @@ +// Package pipelines implements the read-only Console pipelines browser. +package pipelines + +import ( + "context" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeList mode = iota + modeDetail + modeFilter +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionFilter + keyActionConnectConsole + keyActionNextPage + keyActionPrevPage +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionRefresh: {"r"}, + keyActionFilter: {"/"}, + keyActionConnectConsole: {"c"}, + keyActionNextPage: {"n", "right", "]"}, + keyActionPrevPage: {"p", "left", "["}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + return keyActionNone +} + +type initMsg struct{} +type listedMsg struct { + page pipelinesbridge.Page + err error + request uint64 +} +type detailMsg struct { + detail pipelinesbridge.Detail + err error + request uint64 +} + +// Model owns Pipelines-screen interaction state. +type Model struct { + ctx context.Context + loader pipelinesbridge.Loader + theme theme.Theme + mode mode + loading bool + err error + needsAuth bool + request uint64 + + page pipelinesbridge.Page + cursor int + filter string + filterInput textinput.Model + after *string + prevCursors []string + + detail pipelinesbridge.Detail + detailID string + listCursor int + listAfter *string + listFilter string + listPrev []string +} + +func New(ctx context.Context, loader pipelinesbridge.Loader, t theme.Theme) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "filter pipelines" + input.CharLimit = 128 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + input.SetStyles(styles) + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, mode: modeList} +} + +func (m Model) Init() tea.Cmd { + return func() tea.Msg { return initMsg{} } +} + +func (m *Model) beginList(after *string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + query := m.filter + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + page, err := loader.List(ctx, after, query) + return listedMsg{page: page, err: err, request: request} + } +} + +func (m *Model) beginDetail(id string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + detail, err := loader.Get(ctx, id) + return detailMsg{detail: detail, err: err, request: request} + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.mode = modeList + m.page = pipelinesbridge.Page{} + m.cursor = 0 + m.after = nil + m.prevCursors = nil + m.err = nil + m.needsAuth = false + if m.loader == nil { + m.loading = false + return m, nil + } + return m, m.beginList(nil) + case listedMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.page = msg.page + m.cursor = clampCursor(m.cursor, len(m.page.Items)) + m.mode = modeList + } + return m, nil + case detailMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.detail = msg.detail + m.mode = modeDetail + } + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeFilter { + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + if m.mode == modeFilter { + switch action { + case keyActionBack: + m.mode = modeList + m.filterInput.Blur() + return m, nil + case keyActionConfirm: + m.filter = strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + m.mode = modeList + m.cursor = 0 + m.after = nil + m.prevCursors = nil + return m, m.beginList(nil) + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd + } + if action == keyActionBack { + if m.mode == modeDetail { + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.after = m.listAfter + m.filter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) + return m, nil + } + return m, navigation.Navigate(navigation.Deployments) + } + if m.loading { + return m, nil + } + if m.needsAuth && action == keyActionConnectConsole { + return m, navigation.Navigate(navigation.Access) + } + if m.mode == modeDetail { + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + return m, nil + } + return m.updateList(action) +} + +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionMoveUp: + m.cursor = clampCursor(m.cursor-1, len(m.page.Items)) + case keyActionMoveDown: + m.cursor = clampCursor(m.cursor+1, len(m.page.Items)) + case keyActionConfirm: + if len(m.page.Items) == 0 { + return m, nil + } + m.listCursor = m.cursor + m.listAfter = m.after + m.listFilter = m.filter + m.listPrev = append([]string(nil), m.prevCursors...) + m.detailID = m.page.Items[m.cursor].ID + return m, m.beginDetail(m.detailID) + case keyActionRefresh: + return m, m.beginList(m.after) + case keyActionFilter: + m.mode = modeFilter + m.filterInput.SetValue(m.filter) + m.filterInput.Focus() + case keyActionNextPage: + if !m.page.HasNext || m.page.EndCursor == "" { + return m, nil + } + if m.after != nil { + m.prevCursors = append(m.prevCursors, *m.after) + } else { + m.prevCursors = append(m.prevCursors, "") + } + cursor := m.page.EndCursor + m.after = &cursor + m.cursor = 0 + return m, m.beginList(m.after) + case keyActionPrevPage: + if len(m.prevCursors) == 0 { + return m, nil + } + previous := m.prevCursors[len(m.prevCursors)-1] + m.prevCursors = m.prevCursors[:len(m.prevCursors)-1] + if previous == "" { + m.after = nil + } else { + m.after = &previous + } + m.cursor = 0 + return m, m.beginList(m.after) + } + return m, nil +} + +func clampCursor(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} diff --git a/tui/screens/pipelines/model_test.go b/tui/screens/pipelines/model_test.go new file mode 100644 index 000000000..933aa77de --- /dev/null +++ b/tui/screens/pipelines/model_test.go @@ -0,0 +1,233 @@ +package pipelines + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/pkg/bridge" + pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + page pipelinesbridge.Page + detail pipelinesbridge.Detail + err error +} + +func (f *fakeLoader) List(context.Context, *string, string) (pipelinesbridge.Page, error) { + return f.page, f.err +} +func (f *fakeLoader) Get(context.Context, string) (pipelinesbridge.Detail, error) { + return f.detail, f.err +} + +func loadList(t *testing.T, model Model) Model { + t.Helper() + cmd := model.Init() + model, cmd = model.Update(cmd()) + if cmd == nil { + t.Fatal("expected list command") + } + model, _ = model.Update(cmd()) + return model +} + +func TestOpenPipelineDetailAndBack(t *testing.T) { + loader := &fakeLoader{ + page: pipelinesbridge.Page{Items: []pipelinesbridge.Summary{ + {ID: "p1", Name: "deploy-prod", Project: "acme", StageCount: 2}, + {ID: "p2", Name: "canary", Project: "acme", StageCount: 1}, + }}, + detail: pipelinesbridge.Detail{ + Summary: pipelinesbridge.Summary{ID: "p1", Name: "deploy-prod", Project: "acme", StageCount: 2}, + Stages: []pipelinesbridge.Stage{ + {Name: "dev", Services: []string{"default/api"}}, + {Name: "prod"}, + }, + Edges: []pipelinesbridge.Edge{{From: "dev", To: "prod"}}, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if model.mode != modeList || len(model.page.Items) != 2 { + t.Fatalf("list state = mode=%d count=%d", model.mode, len(model.page.Items)) + } + if !strings.Contains(model.View(80, 24), "deploy-prod") { + t.Fatalf("list missing name:\n%s", model.View(80, 24)) + } + + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail || model.detail.Name != "deploy-prod" { + t.Fatalf("detail = %#v mode=%d", model.detail, model.mode) + } + if !strings.Contains(model.View(80, 24), "dev → prod") { + t.Fatalf("detail missing edge:\n%s", model.View(80, 24)) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeList { + t.Fatalf("mode after detail esc = %d", model.mode) + } + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Deployments}) { + t.Fatalf("expected deployments navigation") + } +} + +func TestNextPrevPage(t *testing.T) { + loader := &fakeLoader{ + page: pipelinesbridge.Page{ + Items: []pipelinesbridge.Summary{{ID: "p1", Name: "a", StageCount: 1}}, + EndCursor: "p1", + HasNext: true, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !strings.Contains(model.View(80, 24), "n next") { + t.Fatalf("missing next pager:\n%s", model.View(80, 24)) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: 'n'}) + if cmd == nil { + t.Fatal("expected next-page list command") + } + loader.page = pipelinesbridge.Page{Items: []pipelinesbridge.Summary{{ID: "p2", Name: "b", StageCount: 1}}} + model, _ = model.Update(cmd()) + if model.after == nil || *model.after != "p1" || len(model.prevCursors) != 1 { + t.Fatalf("after page turn after=%v prev=%v", model.after, model.prevCursors) + } + model, cmd = model.Update(tea.KeyPressMsg{Code: 'p'}) + if cmd == nil { + t.Fatal("expected prev-page list command") + } + model, _ = model.Update(cmd()) + if model.after != nil || len(model.prevCursors) != 0 { + t.Fatalf("after prev after=%v prev=%v", model.after, model.prevCursors) + } +} + +func TestNoConsoleNavigatesToAccess(t *testing.T) { + loader := &fakeLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !model.needsAuth { + t.Fatal("expected needsAuth") + } + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c'}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Access}) { + t.Fatalf("expected access navigation") + } +} + +func TestPipelinesGoldens(t *testing.T) { + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = pipelinesbridge.Page{Items: []pipelinesbridge.Summary{ + {ID: "p1", Name: "deploy-prod", Project: "acme", StageCount: 2}, + {ID: "p2", Name: "canary", Project: "acme", StageCount: 3}, + {ID: "p3", Name: "hotfix", StageCount: 1}, + }, HasNext: true, EndCursor: "p3"} + + detail := list + detail.mode = modeDetail + detail.detail = pipelinesbridge.Detail{ + Summary: pipelinesbridge.Summary{ID: "p1", Name: "deploy-prod", Project: "acme", StageCount: 2}, + Stages: []pipelinesbridge.Stage{ + {Name: "dev", Services: []string{"default/api"}}, + {Name: "prod", Services: []string{"default/api"}}, + }, + Edges: []pipelinesbridge.Edge{{From: "dev", To: "prod"}}, + } + + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeView(tc.model.View(tc.width, tc.height)) + golden := filepath.Join("testdata", "pipelines-"+tc.name+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + lines := strings.Split(got, "\n") + if len(lines) != tc.height { + t.Fatalf("height = %d, want %d", len(lines), tc.height) + } + for _, line := range lines { + if w := lipgloss.Width(line); w > tc.width { + t.Fatalf("line width %d > %d: %q", w, tc.width, line) + } + } + }) + } +} + +func TestWritePipelinesGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = pipelinesbridge.Page{Items: []pipelinesbridge.Summary{ + {ID: "p1", Name: "deploy-prod", Project: "acme", StageCount: 2}, + {ID: "p2", Name: "canary", Project: "acme", StageCount: 3}, + {ID: "p3", Name: "hotfix", StageCount: 1}, + }, HasNext: true, EndCursor: "p3"} + detail := list + detail.mode = modeDetail + detail.detail = pipelinesbridge.Detail{ + Summary: pipelinesbridge.Summary{ID: "p1", Name: "deploy-prod", Project: "acme", StageCount: 2}, + Stages: []pipelinesbridge.Stage{ + {Name: "dev", Services: []string{"default/api"}}, + {Name: "prod", Services: []string{"default/api"}}, + }, + Edges: []pipelinesbridge.Edge{{From: "dev", To: "prod"}}, + } + _ = os.MkdirAll("testdata", 0o755) + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + got := normalizeView(tc.model.View(tc.width, tc.height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "pipelines-"+tc.name+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/pipelines/testdata/pipelines-detail-120.golden b/tui/screens/pipelines/testdata/pipelines-detail-120.golden new file mode 100644 index 000000000..a1d67c269 --- /dev/null +++ b/tui/screens/pipelines/testdata/pipelines-detail-120.golden @@ -0,0 +1,30 @@ + Plural Pipelines · deploy-prod 2 stages + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Name deploy-prod │ + │ Project acme │ + │ Stages 2 │ + │ ID p1 │ + │ │ + │ Stages │ + │ dev default/api │ + │ prod default/api │ + │ │ + │ Edges │ + │ dev → prod │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/pipelines/testdata/pipelines-detail-80.golden b/tui/screens/pipelines/testdata/pipelines-detail-80.golden new file mode 100644 index 000000000..c7d93873c --- /dev/null +++ b/tui/screens/pipelines/testdata/pipelines-detail-80.golden @@ -0,0 +1,24 @@ + Plural Pipelines · deploy-prod 2 stages + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────╮ + │ Name deploy-prod │ + │ Project acme │ + │ Stages 2 │ + │ ID p1 │ + │ │ + │ Stages │ + │ dev default/api │ + │ prod default/api │ + │ │ + │ Edges │ + │ dev → prod │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/pipelines/testdata/pipelines-list-120.golden b/tui/screens/pipelines/testdata/pipelines-list-120.golden new file mode 100644 index 000000000..4cb6e8fad --- /dev/null +++ b/tui/screens/pipelines/testdata/pipelines-list-120.golden @@ -0,0 +1,30 @@ + Plural Pipelines 3 pipelines + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Pipelines ────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ NAME PROJECT STAGES │ + │ › deploy-prod acme 2 │ + │ canary acme 3 │ + │ hotfix — 1 │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · enter open · / filter · n/p page · r refresh · esc back diff --git a/tui/screens/pipelines/testdata/pipelines-list-80.golden b/tui/screens/pipelines/testdata/pipelines-list-80.golden new file mode 100644 index 000000000..25d44f9b9 --- /dev/null +++ b/tui/screens/pipelines/testdata/pipelines-list-80.golden @@ -0,0 +1,24 @@ + Plural Pipelines 3 pipelines + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Pipelines ────────────────────────────────────────────────────────────╮ + │ NAME PROJECT STAGES │ + │ › deploy-prod acme 2 │ + │ canary acme 3 │ + │ hotfix — 1 │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / · n/p page · esc back diff --git a/tui/screens/pipelines/view.go b/tui/screens/pipelines/view.go new file mode 100644 index 000000000..31e40e09b --- /dev/null +++ b/tui/screens/pipelines/view.go @@ -0,0 +1,207 @@ +package pipelines + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + title := "Pipelines" + if m.mode == modeDetail && m.detail.Name != "" { + title = "Pipelines · " + m.detail.Name + } + body, help := m.bodyAndHelp(contentWidth) + return page.Render(m.theme, width, height, title, m.headerStatus(), body, help) +} + +func (m Model) headerStatus() string { + if m.loading { + return m.theme.Warning.Render("◌ loading") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + if m.err != nil { + return m.theme.Danger.Render("✗ load failed") + } + switch m.mode { + case modeDetail: + return m.theme.Success.Render(fmt.Sprintf("%d stages", m.detail.StageCount)) + case modeList: + if m.filter != "" { + return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.page.Items))) + } + return m.theme.Success.Render(fmt.Sprintf("%d pipelines", len(m.page.Items))) + default: + return m.theme.Muted.Render("pipelines") + } +} + +func (m Model) bodyAndHelp(width int) (string, string) { + if m.mode == modeFilter { + lines := []string{ + m.theme.Muted.Render("Filter by name, project, or id."), + "", + m.filterInput.View(), + } + return page.Panel(m.theme, "Filter pipelines", lines, width, 6, true), "enter apply · esc cancel" + } + if m.needsAuth { + lines := []string{ + m.theme.Warning.Render("○ Console is not connected"), + m.theme.Muted.Render(" Connect a Console profile to browse pipelines."), + "", + m.theme.Body.Render("Press c to open Access."), + } + return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" + } + if m.mode == modeDetail { + help := "r refresh · esc list · ctrl+c quit" + return page.Panel(m.theme, "Summary", m.detailLines(), width, 16, true), help + } + help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc back" + if width < 100 { + help = "↑/↓ · enter · / · n/p page · esc back" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help +} + +func (m Model) listTitle() string { + if m.filter != "" { + return "Pipelines · filter “" + m.filter + "”" + } + return "Pipelines" +} + +func (m Model) listLines(width int) []string { + if m.loading && len(m.page.Items) == 0 { + return []string{m.theme.Warning.Render("◌ Loading pipelines…")} + } + if m.err != nil { + return []string{ + m.theme.Danger.Render("✗ Unable to load pipelines"), + m.theme.Danger.Render("Error " + m.err.Error()), + m.theme.Muted.Render("Press r to retry."), + } + } + if len(m.page.Items) == 0 { + return []string{ + m.theme.Warning.Render("○ No pipelines found"), + m.theme.Muted.Render(" Adjust the filter or connect another Console."), + } + } + nameWidth := max(16, min(28, width/3)) + projectWidth := max(10, min(20, width/4)) + lines := []string{m.theme.Muted.Render(" " + pad("NAME", nameWidth) + " " + pad("PROJECT", projectWidth) + " STAGES")} + start, end := visibleWindow(m.cursor, len(m.page.Items), 8) + for i := start; i < end; i++ { + item := m.page.Items[i] + cursor := " " + if i == m.cursor { + cursor = "› " + } + project := loCoalesce(item.Project, "—") + row := cursor + pad(item.Name, nameWidth) + " " + pad(project, projectWidth) + " " + fmt.Sprintf("%d", item.StageCount) + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + if start > 0 || end < len(m.page.Items) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" … %d–%d of %d", start+1, end, len(m.page.Items)))) + } + if m.page.HasNext || len(m.prevCursors) > 0 { + pager := "page" + if len(m.prevCursors) > 0 { + pager += " · p prev" + } + if m.page.HasNext { + pager += " · n next" + } + lines = append(lines, "", m.theme.Muted.Render(pager)) + } + return lines +} + +func visibleWindow(cursor, count, size int) (start, end int) { + if count <= 0 { + return 0, 0 + } + if size <= 0 { + size = count + } + if count <= size { + return 0, count + } + start = cursor - size/2 + if start < 0 { + start = 0 + } + end = start + size + if end > count { + end = count + start = end - size + } + return start, end +} + +func (m Model) detailLines() []string { + if m.loading { + return []string{m.theme.Warning.Render("◌ Loading pipeline detail…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load pipeline"), m.theme.Danger.Render(m.err.Error())} + } + lines := []string{ + m.labelValue("Name", m.detail.Name), + m.labelValue("Project", loCoalesce(m.detail.Project, "—")), + m.labelValue("Stages", fmt.Sprintf("%d", m.detail.StageCount)), + m.labelValue("ID", m.detail.ID), + } + if len(m.detail.Stages) > 0 { + lines = append(lines, "", m.theme.Muted.Render("Stages")) + for _, stage := range m.detail.Stages { + services := strings.Join(stage.Services, ", ") + if services == "" { + services = "—" + } + lines = append(lines, " "+stage.Name+" "+m.theme.Muted.Render(services)) + } + } + if len(m.detail.Edges) > 0 { + lines = append(lines, "", m.theme.Muted.Render("Edges")) + for _, edge := range m.detail.Edges { + lines = append(lines, " "+edge.From+" → "+edge.To) + } + } + return lines +} + +func (m Model) labelValue(label, value string) string { + label = label + strings.Repeat(" ", max(1, 12-len(label))) + return label + " " + value +} + +func pad(value string, width int) string { + value = ansi.Truncate(value, width, "…") + if lipgloss.Width(value) >= width { + return value + } + return value + strings.Repeat(" ", width-lipgloss.Width(value)) +} + +func loCoalesce(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} From afa3d65ef11dacff46165b38d60b3e48a90b2877 Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Thu, 30 Jul 2026 14:08:30 +0200 Subject: [PATCH 10/16] add notifications --- cmd/command/tui/tui.go | 15 +- pkg/bridge/notifications/notifications.go | 235 ++++++++++++++ .../notifications/notifications_test.go | 123 +++++++ pkg/console/console.go | 1 + pkg/console/notification.go | 21 +- pkg/test/mocks/ConsoleClient.go | 30 ++ tui/app/model.go | 59 ++-- tui/app/view.go | 2 + tui/navigation/navigation.go | 17 +- tui/screens/deployments/model.go | 2 +- tui/screens/deployments/model_test.go | 13 +- .../testdata/deployments-120.golden | 2 +- .../testdata/deployments-80.golden | 2 +- tui/screens/notifications/model.go | 306 ++++++++++++++++++ tui/screens/notifications/model_test.go | 230 +++++++++++++ .../testdata/notifications-detail-120.golden | 30 ++ .../testdata/notifications-detail-80.golden | 24 ++ .../testdata/notifications-list-120.golden | 30 ++ .../testdata/notifications-list-80.golden | 24 ++ tui/screens/notifications/view.go | 198 ++++++++++++ 20 files changed, 1319 insertions(+), 45 deletions(-) create mode 100644 pkg/bridge/notifications/notifications.go create mode 100644 pkg/bridge/notifications/notifications_test.go create mode 100644 tui/screens/notifications/model.go create mode 100644 tui/screens/notifications/model_test.go create mode 100644 tui/screens/notifications/testdata/notifications-detail-120.golden create mode 100644 tui/screens/notifications/testdata/notifications-detail-80.golden create mode 100644 tui/screens/notifications/testdata/notifications-list-120.golden create mode 100644 tui/screens/notifications/testdata/notifications-list-80.golden create mode 100644 tui/screens/notifications/view.go diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go index f7b22dc47..0bc39d6d7 100644 --- a/cmd/command/tui/tui.go +++ b/cmd/command/tui/tui.go @@ -9,6 +9,7 @@ import ( "github.com/pluralsh/plural-cli/pkg/bridge" accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + notificationsbridge "github.com/pluralsh/plural-cli/pkg/bridge/notifications" pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" @@ -27,13 +28,15 @@ func Command() cli.Command { clusters := clustersbridge.NewService(access) repositories := repositoriesbridge.NewService(access) pipelines := pipelinesbridge.NewService(access) + notifications := notificationsbridge.NewService(access) return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{ - Welcome: welcome, - Access: access, - Services: services, - Clusters: clusters, - Repositories: repositories, - Pipelines: pipelines, + Welcome: welcome, + Access: access, + Services: services, + Clusters: clusters, + Repositories: repositories, + Pipelines: pipelines, + Notifications: notifications, }) }) } diff --git a/pkg/bridge/notifications/notifications.go b/pkg/bridge/notifications/notifications.go new file mode 100644 index 000000000..444d3e4e5 --- /dev/null +++ b/pkg/bridge/notifications/notifications.go @@ -0,0 +1,235 @@ +// Package notifications exposes read-only Console notification sink list/get +// use cases to presentation layers without importing TUI code. +package notifications + +import ( + "context" + "errors" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +const ( + defaultPageSize int64 = 10 + fetchPageSize int64 = 100 +) + +var ( + errNoConsole = errors.New("connect a Console profile before browsing Console resources") + errMissingID = errors.New("notification sink id is required") + errMissingSink = errors.New("notification sink was not found") +) + +// Summary is a credential-free list row for a notification sink. +type Summary struct { + ID string + Name string + Type string + URL string +} + +// Binding is a credential-free notification binding (user or group). +type Binding struct { + Kind string + Name string +} + +// Detail is the credential-free detail payload for a notification sink. +type Detail struct { + Summary + Bindings []Binding +} + +// Page is one cursor page of sink summaries. +type Page struct { + Items []Summary + EndCursor string + HasNext bool + TotalShown int +} + +// Loader is the narrow contract consumed by the Notifications screen. +type Loader interface { + List(ctx context.Context, after *string, query string) (Page, error) + Get(ctx context.Context, id string) (Detail, error) +} + +// ConsoleResolver supplies the active Console URL and token. +type ConsoleResolver interface { + ActiveConsole(ctx context.Context) (url, token string, err error) +} + +// API is the Console surface required by this package. +type API interface { + ListNotificationSinks(after *string, first *int64) (*gqlclient.ListNotificationSinks_NotificationSinks, error) + GetNotificationSink(id string) (*gqlclient.NotificationSinkFragment, error) +} + +// ClientFactory builds a Console API for an authenticated endpoint. +type ClientFactory func(token, url string) (API, error) + +// Service implements Loader against Console GraphQL. +type Service struct { + resolve ConsoleResolver + newClient ClientFactory + pageSize int64 +} + +// NewService wires production Console credentials and client construction. +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + pageSize: defaultPageSize, + } +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s.resolve == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + url, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return nil, err + } + factory := s.newClient + if factory == nil { + factory = func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + } + } + return factory(token, url) +} + +func (s *Service) List(ctx context.Context, after *string, query string) (Page, error) { + if err := ctx.Err(); err != nil { + return Page{}, err + } + client, err := s.client(ctx) + if err != nil { + return Page{}, err + } + first := fetchPageSize + result, err := client.ListNotificationSinks(nil, &first) + if err != nil { + return Page{}, err + } + if result == nil { + return Page{}, nil + } + items := make([]Summary, 0, len(result.Edges)) + for _, edge := range result.Edges { + if edge == nil || edge.Node == nil { + continue + } + summary := summaryFromFragment(edge.Node) + if !matchesQuery(summary, query) { + continue + } + items = append(items, summary) + } + return pageItems(items, after, s.pageSize), nil +} + +func (s *Service) Get(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + sink, err := client.GetNotificationSink(id) + if err != nil { + return Detail{}, err + } + if sink == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingSink} + } + return detailFromFragment(sink), nil +} + +func pageItems(items []Summary, after *string, pageSize int64) Page { + if pageSize <= 0 { + pageSize = defaultPageSize + } + start := 0 + if after != nil && *after != "" { + for i, item := range items { + if item.ID == *after { + start = i + 1 + break + } + } + } + if start > len(items) { + start = len(items) + } + end := start + int(pageSize) + if end > len(items) { + end = len(items) + } + page := Page{Items: items[start:end], TotalShown: end - start, HasNext: end < len(items)} + if len(page.Items) > 0 { + page.EndCursor = page.Items[len(page.Items)-1].ID + } + return page +} + +func summaryFromFragment(node *gqlclient.NotificationSinkFragment) Summary { + return Summary{ + ID: node.ID, + Name: node.Name, + Type: string(node.Type), + URL: sinkURL(node), + } +} + +func detailFromFragment(node *gqlclient.NotificationSinkFragment) Detail { + detail := Detail{Summary: summaryFromFragment(node)} + for _, binding := range node.NotificationBindings { + if binding == nil { + continue + } + switch { + case binding.User != nil: + name := binding.User.Email + if name == "" { + name = binding.User.Name + } + detail.Bindings = append(detail.Bindings, Binding{Kind: "user", Name: name}) + case binding.Group != nil: + detail.Bindings = append(detail.Bindings, Binding{Kind: "group", Name: binding.Group.Name}) + } + } + return detail +} + +func sinkURL(node *gqlclient.NotificationSinkFragment) string { + if node.Configuration.Slack != nil { + return node.Configuration.Slack.URL + } + if node.Configuration.Teams != nil { + return node.Configuration.Teams.URL + } + return "" +} + +func matchesQuery(summary Summary, query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{summary.Name, summary.Type, summary.URL, summary.ID}, " ")) + return strings.Contains(haystack, query) +} diff --git a/pkg/bridge/notifications/notifications_test.go b/pkg/bridge/notifications/notifications_test.go new file mode 100644 index 000000000..e5f79fad3 --- /dev/null +++ b/pkg/bridge/notifications/notifications_test.go @@ -0,0 +1,123 @@ +package notifications + +import ( + "context" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeResolver struct { + url, token string + err error +} + +func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) { + return f.url, f.token, f.err +} + +type fakeAPI struct { + sinks *gqlclient.ListNotificationSinks_NotificationSinks + listErr error + detail *gqlclient.NotificationSinkFragment + getErr error +} + +func (f *fakeAPI) ListNotificationSinks(*string, *int64) (*gqlclient.ListNotificationSinks_NotificationSinks, error) { + return f.sinks, f.listErr +} +func (f *fakeAPI) GetNotificationSink(string) (*gqlclient.NotificationSinkFragment, error) { + return f.detail, f.getErr +} + +func TestListAndGet(t *testing.T) { + api := &fakeAPI{ + sinks: &gqlclient.ListNotificationSinks_NotificationSinks{ + Edges: []*gqlclient.NotificationSinkEdgeFragment{ + {Node: &gqlclient.NotificationSinkFragment{ + ID: "s1", Name: "ops-slack", Type: gqlclient.SinkTypeSLACk, + Configuration: gqlclient.SinkConfigurationFragment{ + Slack: &gqlclient.URLSinkConfigurationFragment{URL: "https://hooks.slack.com/x"}, + }, + }}, + {Node: &gqlclient.NotificationSinkFragment{ + ID: "s2", Name: "ops-teams", Type: gqlclient.SinkTypeTeams, + Configuration: gqlclient.SinkConfigurationFragment{ + Teams: &gqlclient.URLSinkConfigurationFragment{URL: "https://teams.example/hook"}, + }, + }}, + }, + }, + detail: &gqlclient.NotificationSinkFragment{ + ID: "s1", Name: "ops-slack", Type: gqlclient.SinkTypeSLACk, + Configuration: gqlclient.SinkConfigurationFragment{ + Slack: &gqlclient.URLSinkConfigurationFragment{URL: "https://hooks.slack.com/x"}, + }, + NotificationBindings: []*gqlclient.PolicyBindingFragment{ + {User: &gqlclient.UserFragment{Email: "ops@acme.io", Name: "ops"}}, + {Group: &gqlclient.GroupFragment{Name: "platform"}}, + }, + }, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 10, + } + + page, err := service.List(t.Context(), nil, "slack") + if err != nil || len(page.Items) != 1 || page.Items[0].Name != "ops-slack" || page.Items[0].Type != "SLACK" { + t.Fatalf("List() = %#v, %v", page, err) + } + + detail, err := service.Get(t.Context(), "s1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if detail.URL != "https://hooks.slack.com/x" || len(detail.Bindings) != 2 { + t.Fatalf("detail = %#v", detail) + } + if detail.Bindings[0].Kind != "user" || detail.Bindings[0].Name != "ops@acme.io" { + t.Fatalf("user binding = %#v", detail.Bindings[0]) + } + if detail.Bindings[1].Kind != "group" || detail.Bindings[1].Name != "platform" { + t.Fatalf("group binding = %#v", detail.Bindings[1]) + } +} + +func TestListPages(t *testing.T) { + edges := make([]*gqlclient.NotificationSinkEdgeFragment, 0, 3) + for _, id := range []string{"s1", "s2", "s3"} { + edges = append(edges, &gqlclient.NotificationSinkEdgeFragment{ + Node: &gqlclient.NotificationSinkFragment{ID: id, Name: id, Type: gqlclient.SinkTypeSLACk}, + }) + } + api := &fakeAPI{sinks: &gqlclient.ListNotificationSinks_NotificationSinks{Edges: edges}} + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 2, + } + first, err := service.List(t.Context(), nil, "") + if err != nil || len(first.Items) != 2 || !first.HasNext || first.EndCursor != "s2" { + t.Fatalf("first = %#v, %v", first, err) + } + after := first.EndCursor + second, err := service.List(t.Context(), &after, "") + if err != nil || len(second.Items) != 1 || second.HasNext || second.Items[0].ID != "s3" { + t.Fatalf("second = %#v, %v", second, err) + } +} + +func TestGetRequiresID(t *testing.T) { + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return &fakeAPI{}, nil }, + } + _, err := service.Get(t.Context(), "") + if !bridge.IsCode(err, bridge.ErrorInvalid) { + t.Fatalf("Get() error = %v", err) + } +} diff --git a/pkg/console/console.go b/pkg/console/console.go index 887f2d4d9..f1b1c6377 100644 --- a/pkg/console/console.go +++ b/pkg/console/console.go @@ -55,6 +55,7 @@ type ConsoleClient interface { GetServiceContext(name string) (*consoleclient.ServiceContextFragment, error) KickClusterService(serviceId, serviceName, clusterName *string) (*consoleclient.ServiceDeploymentExtended, error) ListNotificationSinks(after *string, first *int64) (*consoleclient.ListNotificationSinks_NotificationSinks, error) + GetNotificationSink(id string) (*consoleclient.NotificationSinkFragment, error) CreateNotificationSinks(attr consoleclient.NotificationSinkAttributes) (*consoleclient.NotificationSinkFragment, error) UpdateDeploymentSettings(attr consoleclient.DeploymentSettingsAttributes) (*consoleclient.UpdateDeploymentSettings, error) GetGlobalSettings() (*consoleclient.DeploymentSettingsFragment, error) diff --git a/pkg/console/notification.go b/pkg/console/notification.go index 88e2b4284..2a88e6ef4 100644 --- a/pkg/console/notification.go +++ b/pkg/console/notification.go @@ -1,21 +1,38 @@ package console import ( + "fmt" + gqlclient "github.com/pluralsh/console/go/client" + "github.com/pluralsh/plural-cli/pkg/api" ) func (c *consoleClient) ListNotificationSinks(after *string, first *int64) (*gqlclient.ListNotificationSinks_NotificationSinks, error) { response, err := c.client.ListNotificationSinks(c.ctx, after, first, nil, nil) if err != nil { - return nil, err + return nil, api.GetErrorResponse(err, "ListNotificationSinks") + } + if response == nil { + return nil, fmt.Errorf("the result from ListNotificationSinks is null") } return response.NotificationSinks, nil } +func (c *consoleClient) GetNotificationSink(id string) (*gqlclient.NotificationSinkFragment, error) { + response, err := c.client.GetNotificationSink(c.ctx, id) + if err != nil { + return nil, api.GetErrorResponse(err, "GetNotificationSink") + } + if response == nil || response.NotificationSink == nil { + return nil, fmt.Errorf("notification sink %s was not found", id) + } + return response.NotificationSink, nil +} + func (c *consoleClient) CreateNotificationSinks(attr gqlclient.NotificationSinkAttributes) (*gqlclient.NotificationSinkFragment, error) { response, err := c.client.UpsertNotificationSink(c.ctx, attr) if err != nil { - return nil, err + return nil, api.GetErrorResponse(err, "UpsertNotificationSink") } return response.UpsertNotificationSink, nil } diff --git a/pkg/test/mocks/ConsoleClient.go b/pkg/test/mocks/ConsoleClient.go index c8c6f1254..03fcf5eef 100644 --- a/pkg/test/mocks/ConsoleClient.go +++ b/pkg/test/mocks/ConsoleClient.go @@ -721,6 +721,36 @@ func (_m *ConsoleClient) GetGlobalSettingsMinimal() (*client.DeploymentSettingsF return r0, r1 } +// GetNotificationSink provides a mock function with given fields: id +func (_m *ConsoleClient) GetNotificationSink(id string) (*client.NotificationSinkFragment, error) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for GetNotificationSink") + } + + var r0 *client.NotificationSinkFragment + var r1 error + if rf, ok := ret.Get(0).(func(string) (*client.NotificationSinkFragment, error)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) *client.NotificationSinkFragment); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.NotificationSinkFragment) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetPipeline provides a mock function with given fields: id func (_m *ConsoleClient) GetPipeline(id string) (*client.PipelineFragment, error) { ret := _m.Called(id) diff --git a/tui/app/model.go b/tui/app/model.go index 778d0cb05..88f518654 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -9,6 +9,7 @@ import ( accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + notificationsbridge "github.com/pluralsh/plural-cli/pkg/bridge/notifications" pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" @@ -18,6 +19,7 @@ import ( clustersscreen "github.com/pluralsh/plural-cli/tui/screens/clusters" deploymentsscreen "github.com/pluralsh/plural-cli/tui/screens/deployments" diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" + notificationsscreen "github.com/pluralsh/plural-cli/tui/screens/notifications" pipelinesscreen "github.com/pluralsh/plural-cli/tui/screens/pipelines" repositoriesscreen "github.com/pluralsh/plural-cli/tui/screens/repositories" servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" @@ -27,12 +29,13 @@ import ( // Dependencies contains the services required by TUI screens. type Dependencies struct { - Welcome welcomebridge.Loader - Access accessbridge.Manager - Services servicesbridge.Loader - Clusters clustersbridge.Loader - Repositories repositoriesbridge.Loader - Pipelines pipelinesbridge.Loader + Welcome welcomebridge.Loader + Access accessbridge.Manager + Services servicesbridge.Loader + Clusters clustersbridge.Loader + Repositories repositoriesbridge.Loader + Pipelines pipelinesbridge.Loader + Notifications notificationsbridge.Loader } // Model is the root TUI model. It owns global input and delegates screen state @@ -44,30 +47,32 @@ type Model struct { theme theme.Theme quit key.Binding - welcome welcomescreen.Model - access accessscreen.Model - diagnostics diagnosticsscreen.Model - deployments deploymentsscreen.Model - services servicesscreen.Model - clusters clustersscreen.Model - repositories repositoriesscreen.Model - pipelines pipelinesscreen.Model - route navigation.Route + welcome welcomescreen.Model + access accessscreen.Model + diagnostics diagnosticsscreen.Model + deployments deploymentsscreen.Model + services servicesscreen.Model + clusters clustersscreen.Model + repositories repositoriesscreen.Model + pipelines pipelinesscreen.Model + notifications notificationsscreen.Model + route navigation.Route } // New composes the root model with caller-provided dependencies. func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { return Model{ - theme: t, - welcome: welcomescreen.New(ctx, dependencies.Welcome, t), - access: accessscreen.New(ctx, dependencies.Access, t), - diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t), - deployments: deploymentsscreen.New(ctx, t, ""), - services: servicesscreen.New(ctx, dependencies.Services, t), - clusters: clustersscreen.New(ctx, dependencies.Clusters, t), - repositories: repositoriesscreen.New(ctx, dependencies.Repositories, t), - pipelines: pipelinesscreen.New(ctx, dependencies.Pipelines, t), - route: navigation.Welcome, + theme: t, + welcome: welcomescreen.New(ctx, dependencies.Welcome, t), + access: accessscreen.New(ctx, dependencies.Access, t), + diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t), + deployments: deploymentsscreen.New(ctx, t, ""), + services: servicesscreen.New(ctx, dependencies.Services, t), + clusters: clustersscreen.New(ctx, dependencies.Clusters, t), + repositories: repositoriesscreen.New(ctx, dependencies.Repositories, t), + pipelines: pipelinesscreen.New(ctx, dependencies.Pipelines, t), + notifications: notificationsscreen.New(ctx, dependencies.Notifications, t), + route: navigation.Welcome, quit: key.NewBinding( key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit"), @@ -97,6 +102,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.repositories.Init() case navigation.Pipelines: return m, m.pipelines.Init() + case navigation.Notifications: + return m, m.notifications.Init() default: return m, m.welcome.Init() } @@ -130,6 +137,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.repositories, cmd = m.repositories.Update(msg) case navigation.Pipelines: m.pipelines, cmd = m.pipelines.Update(msg) + case navigation.Notifications: + m.notifications, cmd = m.notifications.Update(msg) default: m.welcome, cmd = m.welcome.Update(msg) } diff --git a/tui/app/view.go b/tui/app/view.go index c2009fb38..f9731f00e 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -25,6 +25,8 @@ func (m Model) View() tea.View { content = m.repositories.View(m.width, m.height) case navigation.Pipelines: content = m.pipelines.View(m.width, m.height) + case navigation.Notifications: + content = m.notifications.View(m.width, m.height) } view := tea.NewView(content) view.AltScreen = true diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index 0f7dd7382..a48fb9e2f 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -9,14 +9,15 @@ import tea "charm.land/bubbletea/v2" type Route string const ( - Welcome Route = "welcome" - Access Route = "access" - Diagnostics Route = "diagnostics" - Deployments Route = "deployments" - Services Route = "services" - Clusters Route = "clusters" - Repositories Route = "repositories" - Pipelines Route = "pipelines" + Welcome Route = "welcome" + Access Route = "access" + Diagnostics Route = "diagnostics" + Deployments Route = "deployments" + Services Route = "services" + Clusters Route = "clusters" + Repositories Route = "repositories" + Pipelines Route = "pipelines" + Notifications Route = "notifications" ) // NavigateMsg requests a top-level route change. diff --git a/tui/screens/deployments/model.go b/tui/screens/deployments/model.go index ca1dc1eed..183e82b77 100644 --- a/tui/screens/deployments/model.go +++ b/tui/screens/deployments/model.go @@ -36,7 +36,7 @@ func resources() []resource { {id: resourceClusters, number: "2", shortcut: "c", title: "Clusters", blurb: "list · describe", route: navigation.Clusters}, {id: resourceRepositories, number: "3", shortcut: "r", title: "Repositories", blurb: "list · describe", route: navigation.Repositories}, {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "list · describe", route: navigation.Pipelines}, - {id: resourceNotifications, number: "5", shortcut: "n", title: "Notifications", blurb: "sinks", soon: true}, + {id: resourceNotifications, number: "5", shortcut: "n", title: "Notifications", blurb: "list · describe", route: navigation.Notifications}, {id: resourceProviders, number: "6", shortcut: "v", title: "Providers", blurb: "list", soon: true}, } } diff --git a/tui/screens/deployments/model_test.go b/tui/screens/deployments/model_test.go index dba070dd0..e02e02e55 100644 --- a/tui/screens/deployments/model_test.go +++ b/tui/screens/deployments/model_test.go @@ -98,9 +98,20 @@ func TestPipelinesNavigates(t *testing.T) { } } +func TestNotificationsNavigates(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + _, cmd := model.Update(tea.KeyPressMsg{Code: 'n', Text: "n"}) + if cmd == nil { + t.Fatal("expected navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Notifications}) { + t.Fatalf("msg = %#v", msg) + } +} + func TestSoonResourceDoesNotNavigate(t *testing.T) { model := New(t.Context(), theme.New(colorprofile.ASCII), "") - model.cursor = 4 // notifications [soon] + model.cursor = 5 // providers [soon] _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if cmd != nil { t.Fatalf("unexpected cmd %#v", cmd()) diff --git a/tui/screens/deployments/testdata/deployments-120.golden b/tui/screens/deployments/testdata/deployments-120.golden index a30a1c5a8..fe4e37a05 100644 --- a/tui/screens/deployments/testdata/deployments-120.golden +++ b/tui/screens/deployments/testdata/deployments-120.golden @@ -6,7 +6,7 @@ │ 2 c Clusters list · describe │ │ 3 r Repositories list · describe │ │ 4 p Pipelines list · describe │ - │ 5 n Notifications sinks [soon] │ + │ 5 n Notifications list · describe │ │ 6 v Providers list [soon] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/tui/screens/deployments/testdata/deployments-80.golden b/tui/screens/deployments/testdata/deployments-80.golden index 449f93584..c67a6789d 100644 --- a/tui/screens/deployments/testdata/deployments-80.golden +++ b/tui/screens/deployments/testdata/deployments-80.golden @@ -6,7 +6,7 @@ │ 2 c Clusters list · describe │ │ 3 r Repositories list · describe │ │ 4 p Pipelines list · describe │ - │ 5 n Notifications sinks [soon] │ + │ 5 n Notifications list · describe │ │ 6 v Providers list [soon] │ ╰──────────────────────────────────────────────────────────────────────────╯ diff --git a/tui/screens/notifications/model.go b/tui/screens/notifications/model.go new file mode 100644 index 000000000..b15e8469d --- /dev/null +++ b/tui/screens/notifications/model.go @@ -0,0 +1,306 @@ +// Package notifications implements the read-only Console notification sinks browser. +package notifications + +import ( + "context" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + notificationsbridge "github.com/pluralsh/plural-cli/pkg/bridge/notifications" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeList mode = iota + modeDetail + modeFilter +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionFilter + keyActionConnectConsole + keyActionNextPage + keyActionPrevPage +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionRefresh: {"r"}, + keyActionFilter: {"/"}, + keyActionConnectConsole: {"c"}, + keyActionNextPage: {"n", "right", "]"}, + keyActionPrevPage: {"p", "left", "["}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + return keyActionNone +} + +type initMsg struct{} +type listedMsg struct { + page notificationsbridge.Page + err error + request uint64 +} +type detailMsg struct { + detail notificationsbridge.Detail + err error + request uint64 +} + +// Model owns Notifications-screen interaction state. +type Model struct { + ctx context.Context + loader notificationsbridge.Loader + theme theme.Theme + mode mode + loading bool + err error + needsAuth bool + request uint64 + + page notificationsbridge.Page + cursor int + filter string + filterInput textinput.Model + after *string + prevCursors []string + + detail notificationsbridge.Detail + detailID string + listCursor int + listAfter *string + listFilter string + listPrev []string +} + +func New(ctx context.Context, loader notificationsbridge.Loader, t theme.Theme) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "filter notification sinks" + input.CharLimit = 128 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + input.SetStyles(styles) + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, mode: modeList} +} + +func (m Model) Init() tea.Cmd { + return func() tea.Msg { return initMsg{} } +} + +func (m *Model) beginList(after *string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + query := m.filter + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + page, err := loader.List(ctx, after, query) + return listedMsg{page: page, err: err, request: request} + } +} + +func (m *Model) beginDetail(id string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + detail, err := loader.Get(ctx, id) + return detailMsg{detail: detail, err: err, request: request} + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.mode = modeList + m.page = notificationsbridge.Page{} + m.cursor = 0 + m.after = nil + m.prevCursors = nil + m.err = nil + m.needsAuth = false + if m.loader == nil { + m.loading = false + return m, nil + } + return m, m.beginList(nil) + case listedMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.page = msg.page + m.cursor = clampCursor(m.cursor, len(m.page.Items)) + m.mode = modeList + } + return m, nil + case detailMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.detail = msg.detail + m.mode = modeDetail + } + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeFilter { + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + if m.mode == modeFilter { + switch action { + case keyActionBack: + m.mode = modeList + m.filterInput.Blur() + return m, nil + case keyActionConfirm: + m.filter = strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + m.mode = modeList + m.cursor = 0 + m.after = nil + m.prevCursors = nil + return m, m.beginList(nil) + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd + } + if action == keyActionBack { + if m.mode == modeDetail { + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.after = m.listAfter + m.filter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) + return m, nil + } + return m, navigation.Navigate(navigation.Deployments) + } + if m.loading { + return m, nil + } + if m.needsAuth && action == keyActionConnectConsole { + return m, navigation.Navigate(navigation.Access) + } + if m.mode == modeDetail { + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + return m, nil + } + return m.updateList(action) +} + +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionMoveUp: + m.cursor = clampCursor(m.cursor-1, len(m.page.Items)) + case keyActionMoveDown: + m.cursor = clampCursor(m.cursor+1, len(m.page.Items)) + case keyActionConfirm: + if len(m.page.Items) == 0 { + return m, nil + } + m.listCursor = m.cursor + m.listAfter = m.after + m.listFilter = m.filter + m.listPrev = append([]string(nil), m.prevCursors...) + m.detailID = m.page.Items[m.cursor].ID + return m, m.beginDetail(m.detailID) + case keyActionRefresh: + return m, m.beginList(m.after) + case keyActionFilter: + m.mode = modeFilter + m.filterInput.SetValue(m.filter) + m.filterInput.Focus() + case keyActionNextPage: + if !m.page.HasNext || m.page.EndCursor == "" { + return m, nil + } + if m.after != nil { + m.prevCursors = append(m.prevCursors, *m.after) + } else { + m.prevCursors = append(m.prevCursors, "") + } + cursor := m.page.EndCursor + m.after = &cursor + m.cursor = 0 + return m, m.beginList(m.after) + case keyActionPrevPage: + if len(m.prevCursors) == 0 { + return m, nil + } + previous := m.prevCursors[len(m.prevCursors)-1] + m.prevCursors = m.prevCursors[:len(m.prevCursors)-1] + if previous == "" { + m.after = nil + } else { + m.after = &previous + } + m.cursor = 0 + return m, m.beginList(m.after) + } + return m, nil +} + +func clampCursor(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} diff --git a/tui/screens/notifications/model_test.go b/tui/screens/notifications/model_test.go new file mode 100644 index 000000000..b288005f5 --- /dev/null +++ b/tui/screens/notifications/model_test.go @@ -0,0 +1,230 @@ +package notifications + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/pkg/bridge" + notificationsbridge "github.com/pluralsh/plural-cli/pkg/bridge/notifications" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + page notificationsbridge.Page + detail notificationsbridge.Detail + err error +} + +func (f *fakeLoader) List(context.Context, *string, string) (notificationsbridge.Page, error) { + return f.page, f.err +} +func (f *fakeLoader) Get(context.Context, string) (notificationsbridge.Detail, error) { + return f.detail, f.err +} + +func loadList(t *testing.T, model Model) Model { + t.Helper() + cmd := model.Init() + model, cmd = model.Update(cmd()) + if cmd == nil { + t.Fatal("expected list command") + } + model, _ = model.Update(cmd()) + return model +} + +func TestOpenSinkDetailAndBack(t *testing.T) { + loader := &fakeLoader{ + page: notificationsbridge.Page{Items: []notificationsbridge.Summary{ + {ID: "s1", Name: "ops-slack", Type: "SLACK", URL: "https://hooks.slack.com/x"}, + {ID: "s2", Name: "ops-teams", Type: "TEAMS", URL: "https://teams.example/hook"}, + }}, + detail: notificationsbridge.Detail{ + Summary: notificationsbridge.Summary{ID: "s1", Name: "ops-slack", Type: "SLACK", URL: "https://hooks.slack.com/x"}, + Bindings: []notificationsbridge.Binding{ + {Kind: "user", Name: "ops@acme.io"}, + {Kind: "group", Name: "platform"}, + }, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if model.mode != modeList || len(model.page.Items) != 2 { + t.Fatalf("list state = mode=%d count=%d", model.mode, len(model.page.Items)) + } + if !strings.Contains(model.View(80, 24), "ops-slack") { + t.Fatalf("list missing name:\n%s", model.View(80, 24)) + } + + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail || model.detail.Name != "ops-slack" { + t.Fatalf("detail = %#v mode=%d", model.detail, model.mode) + } + if !strings.Contains(model.View(80, 24), "ops@acme.io") { + t.Fatalf("detail missing binding:\n%s", model.View(80, 24)) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeList { + t.Fatalf("mode after detail esc = %d", model.mode) + } + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Deployments}) { + t.Fatalf("expected deployments navigation") + } +} + +func TestNextPrevPage(t *testing.T) { + loader := &fakeLoader{ + page: notificationsbridge.Page{ + Items: []notificationsbridge.Summary{{ID: "s1", Name: "a", Type: "SLACK"}}, + EndCursor: "s1", + HasNext: true, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !strings.Contains(model.View(80, 24), "n next") { + t.Fatalf("missing next pager:\n%s", model.View(80, 24)) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: 'n'}) + if cmd == nil { + t.Fatal("expected next-page list command") + } + loader.page = notificationsbridge.Page{Items: []notificationsbridge.Summary{{ID: "s2", Name: "b", Type: "TEAMS"}}} + model, _ = model.Update(cmd()) + if model.after == nil || *model.after != "s1" || len(model.prevCursors) != 1 { + t.Fatalf("after page turn after=%v prev=%v", model.after, model.prevCursors) + } + model, cmd = model.Update(tea.KeyPressMsg{Code: 'p'}) + if cmd == nil { + t.Fatal("expected prev-page list command") + } + model, _ = model.Update(cmd()) + if model.after != nil || len(model.prevCursors) != 0 { + t.Fatalf("after prev after=%v prev=%v", model.after, model.prevCursors) + } +} + +func TestNoConsoleNavigatesToAccess(t *testing.T) { + loader := &fakeLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !model.needsAuth { + t.Fatal("expected needsAuth") + } + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c'}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Access}) { + t.Fatalf("expected access navigation") + } +} + +func TestNotificationsGoldens(t *testing.T) { + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = notificationsbridge.Page{Items: []notificationsbridge.Summary{ + {ID: "s1", Name: "ops-slack", Type: "SLACK", URL: "https://hooks.slack.com/services/T/B/x"}, + {ID: "s2", Name: "ops-teams", Type: "TEAMS", URL: "https://teams.example/webhook"}, + {ID: "s3", Name: "plural-inbox", Type: "PLURAL"}, + }, HasNext: true, EndCursor: "s3"} + + detail := list + detail.mode = modeDetail + detail.detail = notificationsbridge.Detail{ + Summary: notificationsbridge.Summary{ID: "s1", Name: "ops-slack", Type: "SLACK", URL: "https://hooks.slack.com/services/T/B/x"}, + Bindings: []notificationsbridge.Binding{ + {Kind: "user", Name: "ops@acme.io"}, + {Kind: "group", Name: "platform"}, + }, + } + + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeView(tc.model.View(tc.width, tc.height)) + golden := filepath.Join("testdata", "notifications-"+tc.name+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + lines := strings.Split(got, "\n") + if len(lines) != tc.height { + t.Fatalf("height = %d, want %d", len(lines), tc.height) + } + for _, line := range lines { + if w := lipgloss.Width(line); w > tc.width { + t.Fatalf("line width %d > %d: %q", w, tc.width, line) + } + } + }) + } +} + +func TestWriteNotificationsGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = notificationsbridge.Page{Items: []notificationsbridge.Summary{ + {ID: "s1", Name: "ops-slack", Type: "SLACK", URL: "https://hooks.slack.com/services/T/B/x"}, + {ID: "s2", Name: "ops-teams", Type: "TEAMS", URL: "https://teams.example/webhook"}, + {ID: "s3", Name: "plural-inbox", Type: "PLURAL"}, + }, HasNext: true, EndCursor: "s3"} + detail := list + detail.mode = modeDetail + detail.detail = notificationsbridge.Detail{ + Summary: notificationsbridge.Summary{ID: "s1", Name: "ops-slack", Type: "SLACK", URL: "https://hooks.slack.com/services/T/B/x"}, + Bindings: []notificationsbridge.Binding{ + {Kind: "user", Name: "ops@acme.io"}, + {Kind: "group", Name: "platform"}, + }, + } + _ = os.MkdirAll("testdata", 0o755) + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + got := normalizeView(tc.model.View(tc.width, tc.height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "notifications-"+tc.name+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/notifications/testdata/notifications-detail-120.golden b/tui/screens/notifications/testdata/notifications-detail-120.golden new file mode 100644 index 000000000..247fa535d --- /dev/null +++ b/tui/screens/notifications/testdata/notifications-detail-120.golden @@ -0,0 +1,30 @@ + Plural Notifications · ops-slack SLACK + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Name ops-slack │ + │ Type SLACK │ + │ URL https://hooks.slack.com/services/T/B/x │ + │ ID s1 │ + │ │ + │ Bindings │ + │ user ops@acme.io │ + │ group platform │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/notifications/testdata/notifications-detail-80.golden b/tui/screens/notifications/testdata/notifications-detail-80.golden new file mode 100644 index 000000000..696097745 --- /dev/null +++ b/tui/screens/notifications/testdata/notifications-detail-80.golden @@ -0,0 +1,24 @@ + Plural Notifications · ops-slack SLACK + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────╮ + │ Name ops-slack │ + │ Type SLACK │ + │ URL https://hooks.slack.com/services/T/B/x │ + │ ID s1 │ + │ │ + │ Bindings │ + │ user ops@acme.io │ + │ group platform │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/notifications/testdata/notifications-list-120.golden b/tui/screens/notifications/testdata/notifications-list-120.golden new file mode 100644 index 000000000..d6b30b0ab --- /dev/null +++ b/tui/screens/notifications/testdata/notifications-list-120.golden @@ -0,0 +1,30 @@ + Plural Notifications 3 sinks + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Notification sinks ───────────────────────────────────────────────────────────────────────────────────────────╮ + │ NAME TYPE URL │ + │ › ops-slack SLACK https://hooks.slack.com/services/T/B/x │ + │ ops-teams TEAMS https://teams.example/webhook │ + │ plural-inbox PLURAL — │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · enter open · / filter · n/p page · r refresh · esc back diff --git a/tui/screens/notifications/testdata/notifications-list-80.golden b/tui/screens/notifications/testdata/notifications-list-80.golden new file mode 100644 index 000000000..2ab3e7335 --- /dev/null +++ b/tui/screens/notifications/testdata/notifications-list-80.golden @@ -0,0 +1,24 @@ + Plural Notifications 3 sinks + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Notification sinks ───────────────────────────────────────────────────╮ + │ NAME TYPE URL │ + │ › ops-slack SLACK https://hooks.slack.com/services/T/B/x │ + │ ops-teams TEAMS https://teams.example/webhook │ + │ plural-inbox PLURAL — │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / · n/p page · esc back diff --git a/tui/screens/notifications/view.go b/tui/screens/notifications/view.go new file mode 100644 index 000000000..2d5d669bc --- /dev/null +++ b/tui/screens/notifications/view.go @@ -0,0 +1,198 @@ +package notifications + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + title := "Notifications" + if m.mode == modeDetail && m.detail.Name != "" { + title = "Notifications · " + m.detail.Name + } + body, help := m.bodyAndHelp(contentWidth) + return page.Render(m.theme, width, height, title, m.headerStatus(), body, help) +} + +func (m Model) headerStatus() string { + if m.loading { + return m.theme.Warning.Render("◌ loading") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + if m.err != nil { + return m.theme.Danger.Render("✗ load failed") + } + switch m.mode { + case modeDetail: + return m.theme.Success.Render(loCoalesce(m.detail.Type, "sink")) + case modeList: + if m.filter != "" { + return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.page.Items))) + } + return m.theme.Success.Render(fmt.Sprintf("%d sinks", len(m.page.Items))) + default: + return m.theme.Muted.Render("notifications") + } +} + +func (m Model) bodyAndHelp(width int) (string, string) { + if m.mode == modeFilter { + lines := []string{ + m.theme.Muted.Render("Filter by name, type, url, or id."), + "", + m.filterInput.View(), + } + return page.Panel(m.theme, "Filter sinks", lines, width, 6, true), "enter apply · esc cancel" + } + if m.needsAuth { + lines := []string{ + m.theme.Warning.Render("○ Console is not connected"), + m.theme.Muted.Render(" Connect a Console profile to browse notification sinks."), + "", + m.theme.Body.Render("Press c to open Access."), + } + return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" + } + if m.mode == modeDetail { + help := "r refresh · esc list · ctrl+c quit" + return page.Panel(m.theme, "Summary", m.detailLines(), width, 16, true), help + } + help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc back" + if width < 100 { + help = "↑/↓ · enter · / · n/p page · esc back" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help +} + +func (m Model) listTitle() string { + if m.filter != "" { + return "Sinks · filter “" + m.filter + "”" + } + return "Notification sinks" +} + +func (m Model) listLines(width int) []string { + if m.loading && len(m.page.Items) == 0 { + return []string{m.theme.Warning.Render("◌ Loading notification sinks…")} + } + if m.err != nil { + return []string{ + m.theme.Danger.Render("✗ Unable to load notification sinks"), + m.theme.Danger.Render("Error " + m.err.Error()), + m.theme.Muted.Render("Press r to retry."), + } + } + if len(m.page.Items) == 0 { + return []string{ + m.theme.Warning.Render("○ No notification sinks found"), + m.theme.Muted.Render(" Adjust the filter or connect another Console."), + } + } + nameWidth := max(12, min(24, width/4)) + typeWidth := 8 + urlWidth := max(20, min(40, width/2)) + lines := []string{m.theme.Muted.Render(" " + pad("NAME", nameWidth) + " " + pad("TYPE", typeWidth) + " URL")} + start, end := visibleWindow(m.cursor, len(m.page.Items), 8) + for i := start; i < end; i++ { + item := m.page.Items[i] + cursor := " " + if i == m.cursor { + cursor = "› " + } + url := loCoalesce(item.URL, "—") + row := cursor + pad(item.Name, nameWidth) + " " + pad(item.Type, typeWidth) + " " + pad(url, urlWidth) + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + if start > 0 || end < len(m.page.Items) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" … %d–%d of %d", start+1, end, len(m.page.Items)))) + } + if m.page.HasNext || len(m.prevCursors) > 0 { + pager := "page" + if len(m.prevCursors) > 0 { + pager += " · p prev" + } + if m.page.HasNext { + pager += " · n next" + } + lines = append(lines, "", m.theme.Muted.Render(pager)) + } + return lines +} + +func visibleWindow(cursor, count, size int) (start, end int) { + if count <= 0 { + return 0, 0 + } + if size <= 0 { + size = count + } + if count <= size { + return 0, count + } + start = cursor - size/2 + if start < 0 { + start = 0 + } + end = start + size + if end > count { + end = count + start = end - size + } + return start, end +} + +func (m Model) detailLines() []string { + if m.loading { + return []string{m.theme.Warning.Render("◌ Loading sink detail…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load sink"), m.theme.Danger.Render(m.err.Error())} + } + lines := []string{ + m.labelValue("Name", m.detail.Name), + m.labelValue("Type", loCoalesce(m.detail.Type, "—")), + m.labelValue("URL", loCoalesce(m.detail.URL, "—")), + m.labelValue("ID", m.detail.ID), + } + if len(m.detail.Bindings) > 0 { + lines = append(lines, "", m.theme.Muted.Render("Bindings")) + for _, binding := range m.detail.Bindings { + lines = append(lines, " "+binding.Kind+" "+binding.Name) + } + } + return lines +} + +func (m Model) labelValue(label, value string) string { + label = label + strings.Repeat(" ", max(1, 12-len(label))) + return label + " " + value +} + +func pad(value string, width int) string { + value = ansi.Truncate(value, width, "…") + if lipgloss.Width(value) >= width { + return value + } + return value + strings.Repeat(" ", width-lipgloss.Width(value)) +} + +func loCoalesce(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} From 0cf0b6dc528b561dab2ec4e9c11b9eb018adfd96 Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Thu, 30 Jul 2026 14:32:41 +0200 Subject: [PATCH 11/16] add providers --- cmd/command/tui/tui.go | 3 + pkg/bridge/providers/providers.go | 241 ++++++++++++++ pkg/bridge/providers/providers_test.go | 112 +++++++ pkg/console/console.go | 1 + pkg/console/providers.go | 13 + pkg/test/mocks/ConsoleClient.go | 30 ++ tui/app/model.go | 9 + tui/app/view.go | 2 + tui/navigation/navigation.go | 1 + tui/screens/deployments/model.go | 2 +- tui/screens/deployments/model_test.go | 14 +- .../testdata/deployments-120.golden | 2 +- .../testdata/deployments-80.golden | 2 +- tui/screens/providers/model.go | 306 ++++++++++++++++++ tui/screens/providers/model_test.go | 230 +++++++++++++ .../testdata/providers-detail-120.golden | 30 ++ .../testdata/providers-detail-80.golden | 24 ++ .../testdata/providers-list-120.golden | 30 ++ .../testdata/providers-list-80.golden | 24 ++ tui/screens/providers/view.go | 203 ++++++++++++ 20 files changed, 1270 insertions(+), 9 deletions(-) create mode 100644 pkg/bridge/providers/providers.go create mode 100644 pkg/bridge/providers/providers_test.go create mode 100644 tui/screens/providers/model.go create mode 100644 tui/screens/providers/model_test.go create mode 100644 tui/screens/providers/testdata/providers-detail-120.golden create mode 100644 tui/screens/providers/testdata/providers-detail-80.golden create mode 100644 tui/screens/providers/testdata/providers-list-120.golden create mode 100644 tui/screens/providers/testdata/providers-list-80.golden create mode 100644 tui/screens/providers/view.go diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go index 0bc39d6d7..f35e4c19e 100644 --- a/cmd/command/tui/tui.go +++ b/cmd/command/tui/tui.go @@ -11,6 +11,7 @@ import ( clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" notificationsbridge "github.com/pluralsh/plural-cli/pkg/bridge/notifications" pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" + providersbridge "github.com/pluralsh/plural-cli/pkg/bridge/providers" repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" @@ -29,6 +30,7 @@ func Command() cli.Command { repositories := repositoriesbridge.NewService(access) pipelines := pipelinesbridge.NewService(access) notifications := notificationsbridge.NewService(access) + providers := providersbridge.NewService(access) return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{ Welcome: welcome, Access: access, @@ -37,6 +39,7 @@ func Command() cli.Command { Repositories: repositories, Pipelines: pipelines, Notifications: notifications, + Providers: providers, }) }) } diff --git a/pkg/bridge/providers/providers.go b/pkg/bridge/providers/providers.go new file mode 100644 index 000000000..8e1c8a182 --- /dev/null +++ b/pkg/bridge/providers/providers.go @@ -0,0 +1,241 @@ +// Package providers exposes read-only Console cluster provider list/get +// use cases to presentation layers without importing TUI code. +package providers + +import ( + "context" + "errors" + "strconv" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +const defaultPageSize int64 = 10 + +var ( + errNoConsole = errors.New("connect a Console profile before browsing Console resources") + errMissingID = errors.New("provider id is required") + errMissingProvider = errors.New("cluster provider was not found") +) + +// Summary is a credential-free list row for a cluster provider. +type Summary struct { + ID string + Name string + Cloud string + Namespace string + Editable string + RepoURL string +} + +// Credential is a credential-free provider credential summary. +type Credential struct { + Name string + Namespace string + Kind string +} + +// Detail is the credential-free detail payload for a cluster provider. +type Detail struct { + Summary + Service string + DeletedAt string + Credentials []Credential +} + +// Page is one cursor page of provider summaries. +type Page struct { + Items []Summary + EndCursor string + HasNext bool + TotalShown int +} + +// Loader is the narrow contract consumed by the Providers screen. +type Loader interface { + List(ctx context.Context, after *string, query string) (Page, error) + Get(ctx context.Context, id string) (Detail, error) +} + +// ConsoleResolver supplies the active Console URL and token. +type ConsoleResolver interface { + ActiveConsole(ctx context.Context) (url, token string, err error) +} + +// API is the Console surface required by this package. +type API interface { + ListProviders() (*gqlclient.ListProviders, error) + GetProvider(id string) (*gqlclient.ClusterProviderFragment, error) +} + +// ClientFactory builds a Console API for an authenticated endpoint. +type ClientFactory func(token, url string) (API, error) + +// Service implements Loader against Console GraphQL. +type Service struct { + resolve ConsoleResolver + newClient ClientFactory + pageSize int64 +} + +// NewService wires production Console credentials and client construction. +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + pageSize: defaultPageSize, + } +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s.resolve == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + url, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return nil, err + } + factory := s.newClient + if factory == nil { + factory = func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + } + } + return factory(token, url) +} + +func (s *Service) List(ctx context.Context, after *string, query string) (Page, error) { + if err := ctx.Err(); err != nil { + return Page{}, err + } + client, err := s.client(ctx) + if err != nil { + return Page{}, err + } + result, err := client.ListProviders() + if err != nil { + return Page{}, err + } + if result == nil || result.ClusterProviders == nil { + return Page{}, nil + } + items := make([]Summary, 0, len(result.ClusterProviders.Edges)) + for _, edge := range result.ClusterProviders.Edges { + if edge == nil || edge.Node == nil { + continue + } + summary := summaryFromFragment(edge.Node) + if !matchesQuery(summary, query) { + continue + } + items = append(items, summary) + } + return pageItems(items, after, s.pageSize), nil +} + +func (s *Service) Get(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + provider, err := client.GetProvider(id) + if err != nil { + return Detail{}, err + } + if provider == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingProvider} + } + return detailFromFragment(provider), nil +} + +func pageItems(items []Summary, after *string, pageSize int64) Page { + if pageSize <= 0 { + pageSize = defaultPageSize + } + start := 0 + if after != nil && *after != "" { + for i, item := range items { + if item.ID == *after { + start = i + 1 + break + } + } + } + if start > len(items) { + start = len(items) + } + end := start + int(pageSize) + if end > len(items) { + end = len(items) + } + page := Page{Items: items[start:end], TotalShown: end - start, HasNext: end < len(items)} + if len(page.Items) > 0 { + page.EndCursor = page.Items[len(page.Items)-1].ID + } + return page +} + +func summaryFromFragment(node *gqlclient.ClusterProviderFragment) Summary { + summary := Summary{ + ID: node.ID, + Name: node.Name, + Cloud: node.Cloud, + Namespace: node.Namespace, + } + if node.Editable != nil { + summary.Editable = strconv.FormatBool(*node.Editable) + } + if node.Repository != nil { + summary.RepoURL = node.Repository.URL + } + return summary +} + +func detailFromFragment(node *gqlclient.ClusterProviderFragment) Detail { + detail := Detail{Summary: summaryFromFragment(node)} + if node.DeletedAt != nil { + detail.DeletedAt = *node.DeletedAt + } + if node.Service != nil { + name := node.Service.Name + if node.Service.Namespace != "" { + name = node.Service.Namespace + "/" + name + } + detail.Service = name + } + for _, credential := range node.Credentials { + if credential == nil { + continue + } + detail.Credentials = append(detail.Credentials, Credential{ + Name: credential.Name, + Namespace: credential.Namespace, + Kind: credential.Kind, + }) + } + return detail +} + +func matchesQuery(summary Summary, query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{ + summary.Name, summary.Cloud, summary.Namespace, summary.RepoURL, summary.Editable, summary.ID, + }, " ")) + return strings.Contains(haystack, query) +} diff --git a/pkg/bridge/providers/providers_test.go b/pkg/bridge/providers/providers_test.go new file mode 100644 index 000000000..fc4b3264e --- /dev/null +++ b/pkg/bridge/providers/providers_test.go @@ -0,0 +1,112 @@ +package providers + +import ( + "context" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeResolver struct { + url, token string + err error +} + +func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) { + return f.url, f.token, f.err +} + +type fakeAPI struct { + providers *gqlclient.ListProviders + listErr error + detail *gqlclient.ClusterProviderFragment + getErr error +} + +func (f *fakeAPI) ListProviders() (*gqlclient.ListProviders, error) { return f.providers, f.listErr } +func (f *fakeAPI) GetProvider(string) (*gqlclient.ClusterProviderFragment, error) { + return f.detail, f.getErr +} + +func TestListAndGet(t *testing.T) { + api := &fakeAPI{ + providers: &gqlclient.ListProviders{ClusterProviders: &gqlclient.ListProviders_ClusterProviders{ + Edges: []*gqlclient.ListProviders_ClusterProviders_Edges{ + {Node: &gqlclient.ClusterProviderFragment{ + ID: "pr1", Name: "aws-west", Cloud: "aws", Namespace: "infra", + Editable: lo.ToPtr(true), + Repository: &gqlclient.GitRepositoryFragment{URL: "https://github.com/acme/infra"}, + }}, + {Node: &gqlclient.ClusterProviderFragment{ID: "pr2", Name: "gcp-east", Cloud: "gcp"}}, + }, + }}, + detail: &gqlclient.ClusterProviderFragment{ + ID: "pr1", Name: "aws-west", Cloud: "aws", Namespace: "infra", + Editable: lo.ToPtr(true), + Repository: &gqlclient.GitRepositoryFragment{URL: "https://github.com/acme/infra"}, + Service: &gqlclient.ServiceDeploymentFragment{Name: "provider", Namespace: "infra"}, + Credentials: []*gqlclient.ProviderCredentialFragment{ + {Name: "aws-creds", Namespace: "infra", Kind: "Secret"}, + }, + }, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 10, + } + + page, err := service.List(t.Context(), nil, "aws") + if err != nil || len(page.Items) != 1 || page.Items[0].Name != "aws-west" || page.Items[0].Cloud != "aws" { + t.Fatalf("List() = %#v, %v", page, err) + } + if page.Items[0].Editable != "true" || page.Items[0].RepoURL != "https://github.com/acme/infra" { + t.Fatalf("summary = %#v", page.Items[0]) + } + + detail, err := service.Get(t.Context(), "pr1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if detail.Service != "infra/provider" || len(detail.Credentials) != 1 || detail.Credentials[0].Name != "aws-creds" { + t.Fatalf("detail = %#v", detail) + } +} + +func TestListPages(t *testing.T) { + edges := make([]*gqlclient.ListProviders_ClusterProviders_Edges, 0, 3) + for _, id := range []string{"pr1", "pr2", "pr3"} { + edges = append(edges, &gqlclient.ListProviders_ClusterProviders_Edges{ + Node: &gqlclient.ClusterProviderFragment{ID: id, Name: id, Cloud: "aws"}, + }) + } + api := &fakeAPI{providers: &gqlclient.ListProviders{ClusterProviders: &gqlclient.ListProviders_ClusterProviders{Edges: edges}}} + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 2, + } + first, err := service.List(t.Context(), nil, "") + if err != nil || len(first.Items) != 2 || !first.HasNext || first.EndCursor != "pr2" { + t.Fatalf("first = %#v, %v", first, err) + } + after := first.EndCursor + second, err := service.List(t.Context(), &after, "") + if err != nil || len(second.Items) != 1 || second.HasNext || second.Items[0].ID != "pr3" { + t.Fatalf("second = %#v, %v", second, err) + } +} + +func TestGetRequiresID(t *testing.T) { + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return &fakeAPI{}, nil }, + } + _, err := service.Get(t.Context(), "") + if !bridge.IsCode(err, bridge.ErrorInvalid) { + t.Fatalf("Get() error = %v", err) + } +} diff --git a/pkg/console/console.go b/pkg/console/console.go index f1b1c6377..ca81abfe7 100644 --- a/pkg/console/console.go +++ b/pkg/console/console.go @@ -41,6 +41,7 @@ type ConsoleClient interface { GetClusterService(serviceId, serviceName, clusterName *string) (*consoleclient.ServiceDeploymentExtended, error) DeleteClusterService(serviceId string) (*consoleclient.DeleteServiceDeployment, error) ListProviders() (*consoleclient.ListProviders, error) + GetProvider(id string) (*consoleclient.ClusterProviderFragment, error) CreateProviderCredentials(name string, attr consoleclient.ProviderCredentialAttributes) (*consoleclient.CreateProviderCredential, error) DeleteProviderCredentials(id string) (*consoleclient.DeleteProviderCredential, error) SavePipeline(name string, attrs consoleclient.PipelineAttributes) (*consoleclient.PipelineFragmentMinimal, error) diff --git a/pkg/console/providers.go b/pkg/console/providers.go index 2f1dbdb25..96ab6e618 100644 --- a/pkg/console/providers.go +++ b/pkg/console/providers.go @@ -1,6 +1,8 @@ package console import ( + "fmt" + consoleclient "github.com/pluralsh/console/go/client" "github.com/pluralsh/plural-cli/pkg/api" ) @@ -14,6 +16,17 @@ func (c *consoleClient) ListProviders() (*consoleclient.ListProviders, error) { return result, nil } +func (c *consoleClient) GetProvider(id string) (*consoleclient.ClusterProviderFragment, error) { + response, err := c.client.GetClusterProvider(c.ctx, id) + if err != nil { + return nil, api.GetErrorResponse(err, "GetClusterProvider") + } + if response == nil || response.ClusterProvider == nil { + return nil, fmt.Errorf("cluster provider %s was not found", id) + } + return response.ClusterProvider, nil +} + func (c *consoleClient) CreateProviderCredentials(name string, attr consoleclient.ProviderCredentialAttributes) (*consoleclient.CreateProviderCredential, error) { result, err := c.client.CreateProviderCredential(c.ctx, attr, name) if err != nil { diff --git a/pkg/test/mocks/ConsoleClient.go b/pkg/test/mocks/ConsoleClient.go index 03fcf5eef..47329bddd 100644 --- a/pkg/test/mocks/ConsoleClient.go +++ b/pkg/test/mocks/ConsoleClient.go @@ -871,6 +871,36 @@ func (_m *ConsoleClient) GetProject(name string) (*client.ProjectFragment, error return r0, r1 } +// GetProvider provides a mock function with given fields: id +func (_m *ConsoleClient) GetProvider(id string) (*client.ClusterProviderFragment, error) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for GetProvider") + } + + var r0 *client.ClusterProviderFragment + var r1 error + if rf, ok := ret.Get(0).(func(string) (*client.ClusterProviderFragment, error)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) *client.ClusterProviderFragment); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.ClusterProviderFragment) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetRepository provides a mock function with given fields: id func (_m *ConsoleClient) GetRepository(id string) (*client.GetGitRepository, error) { ret := _m.Called(id) diff --git a/tui/app/model.go b/tui/app/model.go index 88f518654..167a5b4fb 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -11,6 +11,7 @@ import ( clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" notificationsbridge "github.com/pluralsh/plural-cli/pkg/bridge/notifications" pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" + providersbridge "github.com/pluralsh/plural-cli/pkg/bridge/providers" repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" @@ -21,6 +22,7 @@ import ( diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" notificationsscreen "github.com/pluralsh/plural-cli/tui/screens/notifications" pipelinesscreen "github.com/pluralsh/plural-cli/tui/screens/pipelines" + providersscreen "github.com/pluralsh/plural-cli/tui/screens/providers" repositoriesscreen "github.com/pluralsh/plural-cli/tui/screens/repositories" servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" welcomescreen "github.com/pluralsh/plural-cli/tui/screens/welcome" @@ -36,6 +38,7 @@ type Dependencies struct { Repositories repositoriesbridge.Loader Pipelines pipelinesbridge.Loader Notifications notificationsbridge.Loader + Providers providersbridge.Loader } // Model is the root TUI model. It owns global input and delegates screen state @@ -56,6 +59,7 @@ type Model struct { repositories repositoriesscreen.Model pipelines pipelinesscreen.Model notifications notificationsscreen.Model + providers providersscreen.Model route navigation.Route } @@ -72,6 +76,7 @@ func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { repositories: repositoriesscreen.New(ctx, dependencies.Repositories, t), pipelines: pipelinesscreen.New(ctx, dependencies.Pipelines, t), notifications: notificationsscreen.New(ctx, dependencies.Notifications, t), + providers: providersscreen.New(ctx, dependencies.Providers, t), route: navigation.Welcome, quit: key.NewBinding( key.WithKeys("ctrl+c"), @@ -104,6 +109,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.pipelines.Init() case navigation.Notifications: return m, m.notifications.Init() + case navigation.Providers: + return m, m.providers.Init() default: return m, m.welcome.Init() } @@ -139,6 +146,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pipelines, cmd = m.pipelines.Update(msg) case navigation.Notifications: m.notifications, cmd = m.notifications.Update(msg) + case navigation.Providers: + m.providers, cmd = m.providers.Update(msg) default: m.welcome, cmd = m.welcome.Update(msg) } diff --git a/tui/app/view.go b/tui/app/view.go index f9731f00e..c907ea314 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -27,6 +27,8 @@ func (m Model) View() tea.View { content = m.pipelines.View(m.width, m.height) case navigation.Notifications: content = m.notifications.View(m.width, m.height) + case navigation.Providers: + content = m.providers.View(m.width, m.height) } view := tea.NewView(content) view.AltScreen = true diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index a48fb9e2f..08dea8245 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -18,6 +18,7 @@ const ( Repositories Route = "repositories" Pipelines Route = "pipelines" Notifications Route = "notifications" + Providers Route = "providers" ) // NavigateMsg requests a top-level route change. diff --git a/tui/screens/deployments/model.go b/tui/screens/deployments/model.go index 183e82b77..5f2d7fc7d 100644 --- a/tui/screens/deployments/model.go +++ b/tui/screens/deployments/model.go @@ -37,7 +37,7 @@ func resources() []resource { {id: resourceRepositories, number: "3", shortcut: "r", title: "Repositories", blurb: "list · describe", route: navigation.Repositories}, {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "list · describe", route: navigation.Pipelines}, {id: resourceNotifications, number: "5", shortcut: "n", title: "Notifications", blurb: "list · describe", route: navigation.Notifications}, - {id: resourceProviders, number: "6", shortcut: "v", title: "Providers", blurb: "list", soon: true}, + {id: resourceProviders, number: "6", shortcut: "v", title: "Providers", blurb: "list · describe", route: navigation.Providers}, } } diff --git a/tui/screens/deployments/model_test.go b/tui/screens/deployments/model_test.go index e02e02e55..9e4ad535a 100644 --- a/tui/screens/deployments/model_test.go +++ b/tui/screens/deployments/model_test.go @@ -109,12 +109,14 @@ func TestNotificationsNavigates(t *testing.T) { } } -func TestSoonResourceDoesNotNavigate(t *testing.T) { - model := New(t.Context(), theme.New(colorprofile.ASCII), "") - model.cursor = 5 // providers [soon] - _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - if cmd != nil { - t.Fatalf("unexpected cmd %#v", cmd()) +func TestProvidersNavigates(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + _, cmd := model.Update(tea.KeyPressMsg{Code: 'v', Text: "v"}) + if cmd == nil { + t.Fatal("expected navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Providers}) { + t.Fatalf("msg = %#v", msg) } } diff --git a/tui/screens/deployments/testdata/deployments-120.golden b/tui/screens/deployments/testdata/deployments-120.golden index fe4e37a05..5c5661628 100644 --- a/tui/screens/deployments/testdata/deployments-120.golden +++ b/tui/screens/deployments/testdata/deployments-120.golden @@ -7,7 +7,7 @@ │ 3 r Repositories list · describe │ │ 4 p Pipelines list · describe │ │ 5 n Notifications list · describe │ - │ 6 v Providers list [soon] │ + │ 6 v Providers list · describe │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─ Connection ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ diff --git a/tui/screens/deployments/testdata/deployments-80.golden b/tui/screens/deployments/testdata/deployments-80.golden index c67a6789d..435b6ba7f 100644 --- a/tui/screens/deployments/testdata/deployments-80.golden +++ b/tui/screens/deployments/testdata/deployments-80.golden @@ -7,7 +7,7 @@ │ 3 r Repositories list · describe │ │ 4 p Pipelines list · describe │ │ 5 n Notifications list · describe │ - │ 6 v Providers list [soon] │ + │ 6 v Providers list · describe │ ╰──────────────────────────────────────────────────────────────────────────╯ ╭─ Connection ─────────────────────────────────────────────────────────────╮ diff --git a/tui/screens/providers/model.go b/tui/screens/providers/model.go new file mode 100644 index 000000000..7c20b2764 --- /dev/null +++ b/tui/screens/providers/model.go @@ -0,0 +1,306 @@ +// Package providers implements the read-only Console cluster providers browser. +package providers + +import ( + "context" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + providersbridge "github.com/pluralsh/plural-cli/pkg/bridge/providers" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeList mode = iota + modeDetail + modeFilter +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionFilter + keyActionConnectConsole + keyActionNextPage + keyActionPrevPage +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionRefresh: {"r"}, + keyActionFilter: {"/"}, + keyActionConnectConsole: {"c"}, + keyActionNextPage: {"n", "right", "]"}, + keyActionPrevPage: {"p", "left", "["}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + return keyActionNone +} + +type initMsg struct{} +type listedMsg struct { + page providersbridge.Page + err error + request uint64 +} +type detailMsg struct { + detail providersbridge.Detail + err error + request uint64 +} + +// Model owns Providers-screen interaction state. +type Model struct { + ctx context.Context + loader providersbridge.Loader + theme theme.Theme + mode mode + loading bool + err error + needsAuth bool + request uint64 + + page providersbridge.Page + cursor int + filter string + filterInput textinput.Model + after *string + prevCursors []string + + detail providersbridge.Detail + detailID string + listCursor int + listAfter *string + listFilter string + listPrev []string +} + +func New(ctx context.Context, loader providersbridge.Loader, t theme.Theme) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "filter providers" + input.CharLimit = 128 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + input.SetStyles(styles) + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, mode: modeList} +} + +func (m Model) Init() tea.Cmd { + return func() tea.Msg { return initMsg{} } +} + +func (m *Model) beginList(after *string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + query := m.filter + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + page, err := loader.List(ctx, after, query) + return listedMsg{page: page, err: err, request: request} + } +} + +func (m *Model) beginDetail(id string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + detail, err := loader.Get(ctx, id) + return detailMsg{detail: detail, err: err, request: request} + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.mode = modeList + m.page = providersbridge.Page{} + m.cursor = 0 + m.after = nil + m.prevCursors = nil + m.err = nil + m.needsAuth = false + if m.loader == nil { + m.loading = false + return m, nil + } + return m, m.beginList(nil) + case listedMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.page = msg.page + m.cursor = clampCursor(m.cursor, len(m.page.Items)) + m.mode = modeList + } + return m, nil + case detailMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.detail = msg.detail + m.mode = modeDetail + } + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeFilter { + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + if m.mode == modeFilter { + switch action { + case keyActionBack: + m.mode = modeList + m.filterInput.Blur() + return m, nil + case keyActionConfirm: + m.filter = strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + m.mode = modeList + m.cursor = 0 + m.after = nil + m.prevCursors = nil + return m, m.beginList(nil) + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd + } + if action == keyActionBack { + if m.mode == modeDetail { + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.after = m.listAfter + m.filter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) + return m, nil + } + return m, navigation.Navigate(navigation.Deployments) + } + if m.loading { + return m, nil + } + if m.needsAuth && action == keyActionConnectConsole { + return m, navigation.Navigate(navigation.Access) + } + if m.mode == modeDetail { + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + return m, nil + } + return m.updateList(action) +} + +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionMoveUp: + m.cursor = clampCursor(m.cursor-1, len(m.page.Items)) + case keyActionMoveDown: + m.cursor = clampCursor(m.cursor+1, len(m.page.Items)) + case keyActionConfirm: + if len(m.page.Items) == 0 { + return m, nil + } + m.listCursor = m.cursor + m.listAfter = m.after + m.listFilter = m.filter + m.listPrev = append([]string(nil), m.prevCursors...) + m.detailID = m.page.Items[m.cursor].ID + return m, m.beginDetail(m.detailID) + case keyActionRefresh: + return m, m.beginList(m.after) + case keyActionFilter: + m.mode = modeFilter + m.filterInput.SetValue(m.filter) + m.filterInput.Focus() + case keyActionNextPage: + if !m.page.HasNext || m.page.EndCursor == "" { + return m, nil + } + if m.after != nil { + m.prevCursors = append(m.prevCursors, *m.after) + } else { + m.prevCursors = append(m.prevCursors, "") + } + cursor := m.page.EndCursor + m.after = &cursor + m.cursor = 0 + return m, m.beginList(m.after) + case keyActionPrevPage: + if len(m.prevCursors) == 0 { + return m, nil + } + previous := m.prevCursors[len(m.prevCursors)-1] + m.prevCursors = m.prevCursors[:len(m.prevCursors)-1] + if previous == "" { + m.after = nil + } else { + m.after = &previous + } + m.cursor = 0 + return m, m.beginList(m.after) + } + return m, nil +} + +func clampCursor(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} diff --git a/tui/screens/providers/model_test.go b/tui/screens/providers/model_test.go new file mode 100644 index 000000000..6acb2744f --- /dev/null +++ b/tui/screens/providers/model_test.go @@ -0,0 +1,230 @@ +package providers + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/pkg/bridge" + providersbridge "github.com/pluralsh/plural-cli/pkg/bridge/providers" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + page providersbridge.Page + detail providersbridge.Detail + err error +} + +func (f *fakeLoader) List(context.Context, *string, string) (providersbridge.Page, error) { + return f.page, f.err +} +func (f *fakeLoader) Get(context.Context, string) (providersbridge.Detail, error) { + return f.detail, f.err +} + +func loadList(t *testing.T, model Model) Model { + t.Helper() + cmd := model.Init() + model, cmd = model.Update(cmd()) + if cmd == nil { + t.Fatal("expected list command") + } + model, _ = model.Update(cmd()) + return model +} + +func TestOpenProviderDetailAndBack(t *testing.T) { + loader := &fakeLoader{ + page: providersbridge.Page{Items: []providersbridge.Summary{ + {ID: "pr1", Name: "aws-west", Cloud: "aws", Editable: "true", RepoURL: "https://github.com/acme/infra"}, + {ID: "pr2", Name: "gcp-east", Cloud: "gcp"}, + }}, + detail: providersbridge.Detail{ + Summary: providersbridge.Summary{ID: "pr1", Name: "aws-west", Cloud: "aws", Namespace: "infra", Editable: "true", RepoURL: "https://github.com/acme/infra"}, + Service: "infra/provider", + Credentials: []providersbridge.Credential{ + {Name: "aws-creds", Namespace: "infra", Kind: "Secret"}, + }, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if model.mode != modeList || len(model.page.Items) != 2 { + t.Fatalf("list state = mode=%d count=%d", model.mode, len(model.page.Items)) + } + if !strings.Contains(model.View(80, 24), "aws-west") { + t.Fatalf("list missing name:\n%s", model.View(80, 24)) + } + + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail || model.detail.Name != "aws-west" { + t.Fatalf("detail = %#v mode=%d", model.detail, model.mode) + } + if !strings.Contains(model.View(80, 24), "aws-creds") { + t.Fatalf("detail missing credential:\n%s", model.View(80, 24)) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeList { + t.Fatalf("mode after detail esc = %d", model.mode) + } + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Deployments}) { + t.Fatalf("expected deployments navigation") + } +} + +func TestNextPrevPage(t *testing.T) { + loader := &fakeLoader{ + page: providersbridge.Page{ + Items: []providersbridge.Summary{{ID: "pr1", Name: "a", Cloud: "aws"}}, + EndCursor: "pr1", + HasNext: true, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !strings.Contains(model.View(80, 24), "n next") { + t.Fatalf("missing next pager:\n%s", model.View(80, 24)) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: 'n'}) + if cmd == nil { + t.Fatal("expected next-page list command") + } + loader.page = providersbridge.Page{Items: []providersbridge.Summary{{ID: "pr2", Name: "b", Cloud: "gcp"}}} + model, _ = model.Update(cmd()) + if model.after == nil || *model.after != "pr1" || len(model.prevCursors) != 1 { + t.Fatalf("after page turn after=%v prev=%v", model.after, model.prevCursors) + } + model, cmd = model.Update(tea.KeyPressMsg{Code: 'p'}) + if cmd == nil { + t.Fatal("expected prev-page list command") + } + model, _ = model.Update(cmd()) + if model.after != nil || len(model.prevCursors) != 0 { + t.Fatalf("after prev after=%v prev=%v", model.after, model.prevCursors) + } +} + +func TestNoConsoleNavigatesToAccess(t *testing.T) { + loader := &fakeLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !model.needsAuth { + t.Fatal("expected needsAuth") + } + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c'}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Access}) { + t.Fatalf("expected access navigation") + } +} + +func TestProvidersGoldens(t *testing.T) { + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = providersbridge.Page{Items: []providersbridge.Summary{ + {ID: "pr1", Name: "aws-west", Cloud: "aws", Editable: "true", RepoURL: "https://github.com/acme/infra"}, + {ID: "pr2", Name: "gcp-east", Cloud: "gcp", Editable: "false"}, + {ID: "pr3", Name: "azure-central", Cloud: "azure", RepoURL: "https://github.com/acme/azure"}, + }, HasNext: true, EndCursor: "pr3"} + + detail := list + detail.mode = modeDetail + detail.detail = providersbridge.Detail{ + Summary: providersbridge.Summary{ID: "pr1", Name: "aws-west", Cloud: "aws", Namespace: "infra", Editable: "true", RepoURL: "https://github.com/acme/infra"}, + Service: "infra/provider", + Credentials: []providersbridge.Credential{ + {Name: "aws-creds", Namespace: "infra", Kind: "Secret"}, + }, + } + + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeView(tc.model.View(tc.width, tc.height)) + golden := filepath.Join("testdata", "providers-"+tc.name+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + lines := strings.Split(got, "\n") + if len(lines) != tc.height { + t.Fatalf("height = %d, want %d", len(lines), tc.height) + } + for _, line := range lines { + if w := lipgloss.Width(line); w > tc.width { + t.Fatalf("line width %d > %d: %q", w, tc.width, line) + } + } + }) + } +} + +func TestWriteProvidersGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = providersbridge.Page{Items: []providersbridge.Summary{ + {ID: "pr1", Name: "aws-west", Cloud: "aws", Editable: "true", RepoURL: "https://github.com/acme/infra"}, + {ID: "pr2", Name: "gcp-east", Cloud: "gcp", Editable: "false"}, + {ID: "pr3", Name: "azure-central", Cloud: "azure", RepoURL: "https://github.com/acme/azure"}, + }, HasNext: true, EndCursor: "pr3"} + detail := list + detail.mode = modeDetail + detail.detail = providersbridge.Detail{ + Summary: providersbridge.Summary{ID: "pr1", Name: "aws-west", Cloud: "aws", Namespace: "infra", Editable: "true", RepoURL: "https://github.com/acme/infra"}, + Service: "infra/provider", + Credentials: []providersbridge.Credential{ + {Name: "aws-creds", Namespace: "infra", Kind: "Secret"}, + }, + } + _ = os.MkdirAll("testdata", 0o755) + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + got := normalizeView(tc.model.View(tc.width, tc.height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "providers-"+tc.name+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/providers/testdata/providers-detail-120.golden b/tui/screens/providers/testdata/providers-detail-120.golden new file mode 100644 index 000000000..6796d6638 --- /dev/null +++ b/tui/screens/providers/testdata/providers-detail-120.golden @@ -0,0 +1,30 @@ + Plural Providers · aws-west aws + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Name aws-west │ + │ Cloud aws │ + │ Namespace infra │ + │ Editable true │ + │ Repo https://github.com/acme/infra │ + │ Service infra/provider │ + │ ID pr1 │ + │ │ + │ Credentials │ + │ aws-creds infra · Secret │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/providers/testdata/providers-detail-80.golden b/tui/screens/providers/testdata/providers-detail-80.golden new file mode 100644 index 000000000..8374f8c00 --- /dev/null +++ b/tui/screens/providers/testdata/providers-detail-80.golden @@ -0,0 +1,24 @@ + Plural Providers · aws-west aws + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────╮ + │ Name aws-west │ + │ Cloud aws │ + │ Namespace infra │ + │ Editable true │ + │ Repo https://github.com/acme/infra │ + │ Service infra/provider │ + │ ID pr1 │ + │ │ + │ Credentials │ + │ aws-creds infra · Secret │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/providers/testdata/providers-list-120.golden b/tui/screens/providers/testdata/providers-list-120.golden new file mode 100644 index 000000000..71eec88e4 --- /dev/null +++ b/tui/screens/providers/testdata/providers-list-120.golden @@ -0,0 +1,30 @@ + Plural Providers 3 providers + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Providers ────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ NAME CLOUD EDITABLE REPO │ + │ › aws-west aws true https://github.com/acme/infra │ + │ gcp-east gcp false — │ + │ azure-central azure — https://github.com/acme/azure │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · enter open · / filter · n/p page · r refresh · esc back diff --git a/tui/screens/providers/testdata/providers-list-80.golden b/tui/screens/providers/testdata/providers-list-80.golden new file mode 100644 index 000000000..9facac59f --- /dev/null +++ b/tui/screens/providers/testdata/providers-list-80.golden @@ -0,0 +1,24 @@ + Plural Providers 3 providers + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Providers ────────────────────────────────────────────────────────────╮ + │ NAME CLOUD EDITABLE REPO │ + │ › aws-west aws true https://github.com/acme/infra │ + │ gcp-east gcp false — │ + │ azure-central azure — https://github.com/acme/azure │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / · n/p page · esc back diff --git a/tui/screens/providers/view.go b/tui/screens/providers/view.go new file mode 100644 index 000000000..605b2f3a6 --- /dev/null +++ b/tui/screens/providers/view.go @@ -0,0 +1,203 @@ +package providers + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + title := "Providers" + if m.mode == modeDetail && m.detail.Name != "" { + title = "Providers · " + m.detail.Name + } + body, help := m.bodyAndHelp(contentWidth) + return page.Render(m.theme, width, height, title, m.headerStatus(), body, help) +} + +func (m Model) headerStatus() string { + if m.loading { + return m.theme.Warning.Render("◌ loading") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + if m.err != nil { + return m.theme.Danger.Render("✗ load failed") + } + switch m.mode { + case modeDetail: + return m.theme.Success.Render(loCoalesce(m.detail.Cloud, "provider")) + case modeList: + if m.filter != "" { + return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.page.Items))) + } + return m.theme.Success.Render(fmt.Sprintf("%d providers", len(m.page.Items))) + default: + return m.theme.Muted.Render("providers") + } +} + +func (m Model) bodyAndHelp(width int) (string, string) { + if m.mode == modeFilter { + lines := []string{ + m.theme.Muted.Render("Filter by name, cloud, namespace, or id."), + "", + m.filterInput.View(), + } + return page.Panel(m.theme, "Filter providers", lines, width, 6, true), "enter apply · esc cancel" + } + if m.needsAuth { + lines := []string{ + m.theme.Warning.Render("○ Console is not connected"), + m.theme.Muted.Render(" Connect a Console profile to browse providers."), + "", + m.theme.Body.Render("Press c to open Access."), + } + return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" + } + if m.mode == modeDetail { + help := "r refresh · esc list · ctrl+c quit" + return page.Panel(m.theme, "Summary", m.detailLines(), width, 16, true), help + } + help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc back" + if width < 100 { + help = "↑/↓ · enter · / · n/p page · esc back" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help +} + +func (m Model) listTitle() string { + if m.filter != "" { + return "Providers · filter “" + m.filter + "”" + } + return "Providers" +} + +func (m Model) listLines(width int) []string { + if m.loading && len(m.page.Items) == 0 { + return []string{m.theme.Warning.Render("◌ Loading providers…")} + } + if m.err != nil { + return []string{ + m.theme.Danger.Render("✗ Unable to load providers"), + m.theme.Danger.Render("Error " + m.err.Error()), + m.theme.Muted.Render("Press r to retry."), + } + } + if len(m.page.Items) == 0 { + return []string{ + m.theme.Warning.Render("○ No providers found"), + m.theme.Muted.Render(" Adjust the filter or connect another Console."), + } + } + nameWidth := max(14, min(24, width/4)) + cloudWidth := max(6, min(10, width/8)) + editWidth := 8 + lines := []string{m.theme.Muted.Render(" " + pad("NAME", nameWidth) + " " + pad("CLOUD", cloudWidth) + " " + pad("EDITABLE", editWidth) + " REPO")} + start, end := visibleWindow(m.cursor, len(m.page.Items), 8) + for i := start; i < end; i++ { + item := m.page.Items[i] + cursor := " " + if i == m.cursor { + cursor = "› " + } + row := cursor + pad(item.Name, nameWidth) + " " + pad(item.Cloud, cloudWidth) + " " + pad(loCoalesce(item.Editable, "—"), editWidth) + " " + loCoalesce(item.RepoURL, "—") + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + if start > 0 || end < len(m.page.Items) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" … %d–%d of %d", start+1, end, len(m.page.Items)))) + } + if m.page.HasNext || len(m.prevCursors) > 0 { + pager := "page" + if len(m.prevCursors) > 0 { + pager += " · p prev" + } + if m.page.HasNext { + pager += " · n next" + } + lines = append(lines, "", m.theme.Muted.Render(pager)) + } + return lines +} + +func visibleWindow(cursor, count, size int) (start, end int) { + if count <= 0 { + return 0, 0 + } + if size <= 0 { + size = count + } + if count <= size { + return 0, count + } + start = cursor - size/2 + if start < 0 { + start = 0 + } + end = start + size + if end > count { + end = count + start = end - size + } + return start, end +} + +func (m Model) detailLines() []string { + if m.loading { + return []string{m.theme.Warning.Render("◌ Loading provider detail…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load provider"), m.theme.Danger.Render(m.err.Error())} + } + lines := []string{ + m.labelValue("Name", m.detail.Name), + m.labelValue("Cloud", loCoalesce(m.detail.Cloud, "—")), + m.labelValue("Namespace", loCoalesce(m.detail.Namespace, "—")), + m.labelValue("Editable", loCoalesce(m.detail.Editable, "—")), + m.labelValue("Repo", loCoalesce(m.detail.RepoURL, "—")), + m.labelValue("Service", loCoalesce(m.detail.Service, "—")), + m.labelValue("ID", m.detail.ID), + } + if m.detail.DeletedAt != "" { + lines = append(lines, m.labelValue("Deleted", m.detail.DeletedAt)) + } + if len(m.detail.Credentials) > 0 { + lines = append(lines, "", m.theme.Muted.Render("Credentials")) + for _, credential := range m.detail.Credentials { + lines = append(lines, " "+credential.Name+" "+m.theme.Muted.Render(credential.Namespace+" · "+credential.Kind)) + } + } + return lines +} + +func (m Model) labelValue(label, value string) string { + label = label + strings.Repeat(" ", max(1, 12-len(label))) + return label + " " + value +} + +func pad(value string, width int) string { + value = ansi.Truncate(value, width, "…") + if lipgloss.Width(value) >= width { + return value + } + return value + strings.Repeat(" ", width-lipgloss.Width(value)) +} + +func loCoalesce(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} From 3565c8ce4732d6125b7b98283f00194ea4287d78 Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Fri, 31 Jul 2026 12:46:50 +0200 Subject: [PATCH 12/16] add stacks and PR --- cmd/command/tui/tui.go | 3 + pkg/bridge/stacks/stacks.go | 259 +++++++++++++++ pkg/bridge/stacks/stacks_test.go | 136 ++++++++ pkg/console/console.go | 1 + pkg/console/stacks.go | 27 +- pkg/test/mocks/ConsoleClient.go | 30 ++ tui/app/model.go | 9 + tui/app/view.go | 2 + tui/navigation/navigation.go | 1 + tui/screens/deployments/model.go | 4 + tui/screens/deployments/model_test.go | 24 ++ .../testdata/deployments-120.golden | 6 +- .../testdata/deployments-80.golden | 6 +- tui/screens/deployments/view.go | 4 +- tui/screens/stacks/model.go | 306 ++++++++++++++++++ tui/screens/stacks/model_test.go | 227 +++++++++++++ .../stacks/testdata/stacks-detail-120.golden | 30 ++ .../stacks/testdata/stacks-detail-80.golden | 24 ++ .../stacks/testdata/stacks-list-120.golden | 30 ++ .../stacks/testdata/stacks-list-80.golden | 24 ++ tui/screens/stacks/view.go | 220 +++++++++++++ 21 files changed, 1363 insertions(+), 10 deletions(-) create mode 100644 pkg/bridge/stacks/stacks.go create mode 100644 pkg/bridge/stacks/stacks_test.go create mode 100644 tui/screens/stacks/model.go create mode 100644 tui/screens/stacks/model_test.go create mode 100644 tui/screens/stacks/testdata/stacks-detail-120.golden create mode 100644 tui/screens/stacks/testdata/stacks-detail-80.golden create mode 100644 tui/screens/stacks/testdata/stacks-list-120.golden create mode 100644 tui/screens/stacks/testdata/stacks-list-80.golden create mode 100644 tui/screens/stacks/view.go diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go index f35e4c19e..1c0a28528 100644 --- a/cmd/command/tui/tui.go +++ b/cmd/command/tui/tui.go @@ -14,6 +14,7 @@ import ( providersbridge "github.com/pluralsh/plural-cli/pkg/bridge/providers" repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" + stacksbridge "github.com/pluralsh/plural-cli/pkg/bridge/stacks" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" "github.com/pluralsh/plural-cli/pkg/common" tuiapp "github.com/pluralsh/plural-cli/tui/app" @@ -31,6 +32,7 @@ func Command() cli.Command { pipelines := pipelinesbridge.NewService(access) notifications := notificationsbridge.NewService(access) providers := providersbridge.NewService(access) + stacks := stacksbridge.NewService(access) return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{ Welcome: welcome, Access: access, @@ -40,6 +42,7 @@ func Command() cli.Command { Pipelines: pipelines, Notifications: notifications, Providers: providers, + Stacks: stacks, }) }) } diff --git a/pkg/bridge/stacks/stacks.go b/pkg/bridge/stacks/stacks.go new file mode 100644 index 000000000..3c98131f2 --- /dev/null +++ b/pkg/bridge/stacks/stacks.go @@ -0,0 +1,259 @@ +// Package stacks exposes read-only Console infrastructure stack list/get +// use cases to presentation layers without importing TUI code. +package stacks + +import ( + "context" + "errors" + "strconv" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +const defaultPageSize int64 = 10 + +var ( + errNoConsole = errors.New("connect a Console profile before browsing Console resources") + errMissingID = errors.New("stack id is required") + errMissingStack = errors.New("infrastructure stack was not found") +) + +// Summary is a credential-free list row for an infrastructure stack. +type Summary struct { + ID string + Name string + Type string + Project string + Cluster string + Approval string + RepoURL string +} + +// Detail is the credential-free detail payload for an infrastructure stack. +// Environment and output values are omitted; only names are exposed. +type Detail struct { + Summary + Workdir string + ManageState string + GitRef string + GitFolder string + ConfigVersion string + DeletedAt string + EnvNames []string + OutputNames []string +} + +// Page is one cursor page of stack summaries. +type Page struct { + Items []Summary + EndCursor string + HasNext bool + TotalShown int +} + +// Loader is the narrow contract consumed by the Stacks screen. +type Loader interface { + List(ctx context.Context, after *string, query string) (Page, error) + Get(ctx context.Context, id string) (Detail, error) +} + +// ConsoleResolver supplies the active Console URL and token. +type ConsoleResolver interface { + ActiveConsole(ctx context.Context) (url, token string, err error) +} + +// API is the Console surface required by this package. +type API interface { + ListStacks() (*gqlclient.ListInfrastructureStacks, error) + GetStack(id string) (*gqlclient.InfrastructureStackFragment, error) +} + +// ClientFactory builds a Console API for an authenticated endpoint. +type ClientFactory func(token, url string) (API, error) + +// Service implements Loader against Console GraphQL. +type Service struct { + resolve ConsoleResolver + newClient ClientFactory + pageSize int64 +} + +// NewService wires production Console credentials and client construction. +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + pageSize: defaultPageSize, + } +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s.resolve == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + url, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return nil, err + } + factory := s.newClient + if factory == nil { + factory = func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + } + } + return factory(token, url) +} + +func (s *Service) List(ctx context.Context, after *string, query string) (Page, error) { + if err := ctx.Err(); err != nil { + return Page{}, err + } + client, err := s.client(ctx) + if err != nil { + return Page{}, err + } + result, err := client.ListStacks() + if err != nil { + return Page{}, err + } + if result == nil || result.InfrastructureStacks == nil { + return Page{}, nil + } + items := make([]Summary, 0, len(result.InfrastructureStacks.Edges)) + for _, edge := range result.InfrastructureStacks.Edges { + if edge == nil || edge.Node == nil { + continue + } + summary := summaryFromFragment(edge.Node) + if !matchesQuery(summary, query) { + continue + } + items = append(items, summary) + } + return pageItems(items, after, s.pageSize), nil +} + +func (s *Service) Get(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + stack, err := client.GetStack(id) + if err != nil { + return Detail{}, err + } + if stack == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingStack} + } + return detailFromFragment(stack), nil +} + +func pageItems(items []Summary, after *string, pageSize int64) Page { + if pageSize <= 0 { + pageSize = defaultPageSize + } + start := 0 + if after != nil && *after != "" { + for i, item := range items { + if item.ID == *after { + start = i + 1 + break + } + } + } + if start > len(items) { + start = len(items) + } + end := start + int(pageSize) + if end > len(items) { + end = len(items) + } + page := Page{Items: items[start:end], TotalShown: end - start, HasNext: end < len(items)} + if len(page.Items) > 0 { + page.EndCursor = page.Items[len(page.Items)-1].ID + } + return page +} + +func summaryFromFragment(node *gqlclient.InfrastructureStackFragment) Summary { + summary := Summary{ + ID: lo.FromPtr(node.ID), + Name: node.Name, + Type: string(node.Type), + } + if node.Approval != nil { + summary.Approval = strconv.FormatBool(*node.Approval) + } + if node.Project != nil { + summary.Project = node.Project.Name + } + if node.Cluster != nil { + summary.Cluster = node.Cluster.Name + } + if node.Repository != nil { + summary.RepoURL = node.Repository.URL + } + return summary +} + +func detailFromFragment(node *gqlclient.InfrastructureStackFragment) Detail { + detail := Detail{ + Summary: summaryFromFragment(node), + GitRef: node.Git.Ref, + GitFolder: node.Git.Folder, + } + if node.Workdir != nil { + detail.Workdir = *node.Workdir + } + if node.ManageState != nil { + detail.ManageState = strconv.FormatBool(*node.ManageState) + } + if node.Configuration.Version != nil { + detail.ConfigVersion = *node.Configuration.Version + } + if node.DeletedAt != nil { + detail.DeletedAt = *node.DeletedAt + } + for _, env := range node.Environment { + if env == nil || env.Name == "" { + continue + } + detail.EnvNames = append(detail.EnvNames, env.Name) + } + for _, out := range node.Output { + if out == nil || out.Name == "" { + continue + } + name := out.Name + if out.Secret != nil && *out.Secret { + name += " (secret)" + } + detail.OutputNames = append(detail.OutputNames, name) + } + return detail +} + +func matchesQuery(summary Summary, query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{ + summary.Name, summary.Type, summary.Project, summary.Cluster, summary.RepoURL, summary.Approval, summary.ID, + }, " ")) + return strings.Contains(haystack, query) +} diff --git a/pkg/bridge/stacks/stacks_test.go b/pkg/bridge/stacks/stacks_test.go new file mode 100644 index 000000000..81de0a25d --- /dev/null +++ b/pkg/bridge/stacks/stacks_test.go @@ -0,0 +1,136 @@ +package stacks + +import ( + "context" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeResolver struct { + url, token string + err error +} + +func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) { + return f.url, f.token, f.err +} + +type fakeAPI struct { + stacks *gqlclient.ListInfrastructureStacks + listErr error + detail *gqlclient.InfrastructureStackFragment + getErr error +} + +func (f *fakeAPI) ListStacks() (*gqlclient.ListInfrastructureStacks, error) { + return f.stacks, f.listErr +} +func (f *fakeAPI) GetStack(string) (*gqlclient.InfrastructureStackFragment, error) { + return f.detail, f.getErr +} + +func TestListAndGet(t *testing.T) { + api := &fakeAPI{ + stacks: &gqlclient.ListInfrastructureStacks{InfrastructureStacks: &gqlclient.ListInfrastructureStacks_InfrastructureStacks{ + Edges: []*gqlclient.InfrastructureStackEdgeFragment{ + {Node: &gqlclient.InfrastructureStackFragment{ + ID: lo.ToPtr("s1"), Name: "gke-demo", Type: gqlclient.StackTypeTerraform, + Approval: lo.ToPtr(true), + Project: &gqlclient.TinyProjectFragment{Name: "acme"}, + Cluster: &gqlclient.TinyClusterFragment{Name: "mgmt"}, + Repository: &gqlclient.GitRepositoryFragment{URL: "https://github.com/acme/fleet"}, + Git: gqlclient.GitRefFragment{Ref: "main", Folder: "terraform"}, + }}, + {Node: &gqlclient.InfrastructureStackFragment{ + ID: lo.ToPtr("s2"), Name: "ansible-edge", Type: gqlclient.StackTypeAnsible, + }}, + }, + }}, + detail: &gqlclient.InfrastructureStackFragment{ + ID: lo.ToPtr("s1"), Name: "gke-demo", Type: gqlclient.StackTypeTerraform, + Approval: lo.ToPtr(true), + Workdir: lo.ToPtr("gke-cluster"), + ManageState: lo.ToPtr(true), + Project: &gqlclient.TinyProjectFragment{Name: "acme"}, + Cluster: &gqlclient.TinyClusterFragment{Name: "mgmt"}, + Repository: &gqlclient.GitRepositoryFragment{URL: "https://github.com/acme/fleet"}, + Git: gqlclient.GitRefFragment{Ref: "main", Folder: "terraform"}, + Configuration: gqlclient.StackConfigurationFragment{ + Version: lo.ToPtr("1.8.2"), + }, + Environment: []*gqlclient.StackEnvironmentFragment{ + {Name: "TF_VAR_cluster", Value: "secret-value", Secret: lo.ToPtr(true)}, + }, + Output: []*gqlclient.StackOutputFragment{ + {Name: "cluster_name", Value: "gke-demo"}, + {Name: "token", Value: "x", Secret: lo.ToPtr(true)}, + }, + }, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 10, + } + + page, err := service.List(t.Context(), nil, "gke") + if err != nil || len(page.Items) != 1 || page.Items[0].Name != "gke-demo" || page.Items[0].Type != "TERRAFORM" { + t.Fatalf("List() = %#v, %v", page, err) + } + if page.Items[0].Cluster != "mgmt" || page.Items[0].Approval != "true" { + t.Fatalf("summary = %#v", page.Items[0]) + } + + detail, err := service.Get(t.Context(), "s1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if detail.Workdir != "gke-cluster" || detail.GitRef != "main" || detail.ConfigVersion != "1.8.2" { + t.Fatalf("detail = %#v", detail) + } + if len(detail.EnvNames) != 1 || detail.EnvNames[0] != "TF_VAR_cluster" { + t.Fatalf("env names leaked values? %#v", detail.EnvNames) + } + if len(detail.OutputNames) != 2 || detail.OutputNames[1] != "token (secret)" { + t.Fatalf("outputs = %#v", detail.OutputNames) + } +} + +func TestListPages(t *testing.T) { + edges := make([]*gqlclient.InfrastructureStackEdgeFragment, 0, 3) + for _, id := range []string{"s1", "s2", "s3"} { + edges = append(edges, &gqlclient.InfrastructureStackEdgeFragment{ + Node: &gqlclient.InfrastructureStackFragment{ID: lo.ToPtr(id), Name: id, Type: gqlclient.StackTypeTerraform}, + }) + } + api := &fakeAPI{stacks: &gqlclient.ListInfrastructureStacks{InfrastructureStacks: &gqlclient.ListInfrastructureStacks_InfrastructureStacks{Edges: edges}}} + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 2, + } + first, err := service.List(t.Context(), nil, "") + if err != nil || len(first.Items) != 2 || !first.HasNext || first.EndCursor != "s2" { + t.Fatalf("first = %#v, %v", first, err) + } + after := first.EndCursor + second, err := service.List(t.Context(), &after, "") + if err != nil || len(second.Items) != 1 || second.HasNext || second.Items[0].ID != "s3" { + t.Fatalf("second = %#v, %v", second, err) + } +} + +func TestGetRequiresID(t *testing.T) { + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return &fakeAPI{}, nil }, + } + _, err := service.Get(t.Context(), "") + if !bridge.IsCode(err, bridge.ErrorInvalid) { + t.Fatalf("Get() error = %v", err) + } +} diff --git a/pkg/console/console.go b/pkg/console/console.go index ca81abfe7..873d153bc 100644 --- a/pkg/console/console.go +++ b/pkg/console/console.go @@ -73,6 +73,7 @@ type ConsoleClient interface { IsClusterRegistrationComplete(machineID string) (bool, *consoleclient.ClusterRegistrationFragment) GetUser(email string) (*consoleclient.UserFragment, error) ListStacks() (*consoleclient.ListInfrastructureStacks, error) + GetStack(id string) (*consoleclient.InfrastructureStackFragment, error) } type authedTransport struct { diff --git a/pkg/console/stacks.go b/pkg/console/stacks.go index 15ab55948..8303a9532 100644 --- a/pkg/console/stacks.go +++ b/pkg/console/stacks.go @@ -1,14 +1,37 @@ package console import ( + "fmt" + gqlclient "github.com/pluralsh/console/go/client" "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/pkg/api" ) func (c *consoleClient) ListStackRuns(stackID string) (*gqlclient.ListStackRuns, error) { - return c.client.ListStackRuns(c.ctx, stackID, nil, nil, lo.ToPtr(int64(100)), nil) + result, err := c.client.ListStackRuns(c.ctx, stackID, nil, nil, lo.ToPtr(int64(100)), nil) + if err != nil { + return nil, api.GetErrorResponse(err, "ListStackRuns") + } + return result, nil } func (c *consoleClient) ListStacks() (*gqlclient.ListInfrastructureStacks, error) { - return c.client.ListInfrastructureStacks(c.ctx, nil, lo.ToPtr(int64(100)), nil, nil) + result, err := c.client.ListInfrastructureStacks(c.ctx, nil, lo.ToPtr(int64(100)), nil, nil) + if err != nil { + return nil, api.GetErrorResponse(err, "ListInfrastructureStacks") + } + return result, nil +} + +func (c *consoleClient) GetStack(id string) (*gqlclient.InfrastructureStackFragment, error) { + response, err := c.client.GetInfrastructureStack(c.ctx, lo.ToPtr(id), nil) + if err != nil { + return nil, api.GetErrorResponse(err, "GetInfrastructureStack") + } + if response == nil || response.InfrastructureStack == nil { + return nil, fmt.Errorf("infrastructure stack %s was not found", id) + } + return response.InfrastructureStack, nil } diff --git a/pkg/test/mocks/ConsoleClient.go b/pkg/test/mocks/ConsoleClient.go index 47329bddd..bbc87e657 100644 --- a/pkg/test/mocks/ConsoleClient.go +++ b/pkg/test/mocks/ConsoleClient.go @@ -961,6 +961,36 @@ func (_m *ConsoleClient) GetServiceContext(name string) (*client.ServiceContextF return r0, r1 } +// GetStack provides a mock function with given fields: id +func (_m *ConsoleClient) GetStack(id string) (*client.InfrastructureStackFragment, error) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for GetStack") + } + + var r0 *client.InfrastructureStackFragment + var r1 error + if rf, ok := ret.Get(0).(func(string) (*client.InfrastructureStackFragment, error)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) *client.InfrastructureStackFragment); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.InfrastructureStackFragment) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetUser provides a mock function with given fields: email func (_m *ConsoleClient) GetUser(email string) (*client.UserFragment, error) { ret := _m.Called(email) diff --git a/tui/app/model.go b/tui/app/model.go index 167a5b4fb..c69e2c74c 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -14,6 +14,7 @@ import ( providersbridge "github.com/pluralsh/plural-cli/pkg/bridge/providers" repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" + stacksbridge "github.com/pluralsh/plural-cli/pkg/bridge/stacks" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" "github.com/pluralsh/plural-cli/tui/navigation" accessscreen "github.com/pluralsh/plural-cli/tui/screens/access" @@ -25,6 +26,7 @@ import ( providersscreen "github.com/pluralsh/plural-cli/tui/screens/providers" repositoriesscreen "github.com/pluralsh/plural-cli/tui/screens/repositories" servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" + stacksscreen "github.com/pluralsh/plural-cli/tui/screens/stacks" welcomescreen "github.com/pluralsh/plural-cli/tui/screens/welcome" "github.com/pluralsh/plural-cli/tui/theme" ) @@ -39,6 +41,7 @@ type Dependencies struct { Pipelines pipelinesbridge.Loader Notifications notificationsbridge.Loader Providers providersbridge.Loader + Stacks stacksbridge.Loader } // Model is the root TUI model. It owns global input and delegates screen state @@ -60,6 +63,7 @@ type Model struct { pipelines pipelinesscreen.Model notifications notificationsscreen.Model providers providersscreen.Model + stacks stacksscreen.Model route navigation.Route } @@ -77,6 +81,7 @@ func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { pipelines: pipelinesscreen.New(ctx, dependencies.Pipelines, t), notifications: notificationsscreen.New(ctx, dependencies.Notifications, t), providers: providersscreen.New(ctx, dependencies.Providers, t), + stacks: stacksscreen.New(ctx, dependencies.Stacks, t), route: navigation.Welcome, quit: key.NewBinding( key.WithKeys("ctrl+c"), @@ -111,6 +116,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.notifications.Init() case navigation.Providers: return m, m.providers.Init() + case navigation.Stacks: + return m, m.stacks.Init() default: return m, m.welcome.Init() } @@ -148,6 +155,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.notifications, cmd = m.notifications.Update(msg) case navigation.Providers: m.providers, cmd = m.providers.Update(msg) + case navigation.Stacks: + m.stacks, cmd = m.stacks.Update(msg) default: m.welcome, cmd = m.welcome.Update(msg) } diff --git a/tui/app/view.go b/tui/app/view.go index c907ea314..9d834e622 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -29,6 +29,8 @@ func (m Model) View() tea.View { content = m.notifications.View(m.width, m.height) case navigation.Providers: content = m.providers.View(m.width, m.height) + case navigation.Stacks: + content = m.stacks.View(m.width, m.height) } view := tea.NewView(content) view.AltScreen = true diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index 08dea8245..55709287b 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -19,6 +19,7 @@ const ( Pipelines Route = "pipelines" Notifications Route = "notifications" Providers Route = "providers" + Stacks Route = "stacks" ) // NavigateMsg requests a top-level route change. diff --git a/tui/screens/deployments/model.go b/tui/screens/deployments/model.go index 5f2d7fc7d..f00ba2167 100644 --- a/tui/screens/deployments/model.go +++ b/tui/screens/deployments/model.go @@ -18,6 +18,8 @@ const ( resourcePipelines resourceNotifications resourceProviders + resourceStacks + resourcePullRequests ) type resource struct { @@ -38,6 +40,8 @@ func resources() []resource { {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "list · describe", route: navigation.Pipelines}, {id: resourceNotifications, number: "5", shortcut: "n", title: "Notifications", blurb: "list · describe", route: navigation.Notifications}, {id: resourceProviders, number: "6", shortcut: "v", title: "Providers", blurb: "list · describe", route: navigation.Providers}, + {id: resourceStacks, number: "7", shortcut: "t", title: "Stacks", blurb: "list · describe", route: navigation.Stacks}, + {id: resourcePullRequests, number: "8", shortcut: "u", title: "Pull requests", blurb: "automations", soon: true}, } } diff --git a/tui/screens/deployments/model_test.go b/tui/screens/deployments/model_test.go index 9e4ad535a..2891b334f 100644 --- a/tui/screens/deployments/model_test.go +++ b/tui/screens/deployments/model_test.go @@ -120,6 +120,30 @@ func TestProvidersNavigates(t *testing.T) { } } +func TestSoonResourceDoesNotNavigate(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "") + model.cursor = 7 // pull requests [soon] + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd != nil { + t.Fatalf("unexpected cmd %#v", cmd()) + } + _, cmd = model.Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + if cmd != nil { + t.Fatalf("unexpected pull-requests shortcut cmd %#v", cmd()) + } +} + +func TestStacksNavigates(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + _, cmd := model.Update(tea.KeyPressMsg{Code: 't', Text: "t"}) + if cmd == nil { + t.Fatal("expected navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Stacks}) { + t.Fatalf("msg = %#v", msg) + } +} + func TestEscReturnsWelcome(t *testing.T) { model := New(t.Context(), theme.New(colorprofile.ASCII), "") _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) diff --git a/tui/screens/deployments/testdata/deployments-120.golden b/tui/screens/deployments/testdata/deployments-120.golden index 5c5661628..7860db059 100644 --- a/tui/screens/deployments/testdata/deployments-120.golden +++ b/tui/screens/deployments/testdata/deployments-120.golden @@ -8,6 +8,8 @@ │ 4 p Pipelines list · describe │ │ 5 n Notifications list · describe │ │ 6 v Providers list · describe │ + │ 7 t Stacks list · describe │ + │ 8 u Pull requests automations [soon] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─ Connection ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ @@ -25,6 +27,4 @@ - - - 1–6 / letter · enter open · esc welcome · ctrl+c quit + 1–8 / letter · enter open · esc welcome · ctrl+c quit diff --git a/tui/screens/deployments/testdata/deployments-80.golden b/tui/screens/deployments/testdata/deployments-80.golden index 435b6ba7f..cd8caa655 100644 --- a/tui/screens/deployments/testdata/deployments-80.golden +++ b/tui/screens/deployments/testdata/deployments-80.golden @@ -8,6 +8,8 @@ │ 4 p Pipelines list · describe │ │ 5 n Notifications list · describe │ │ 6 v Providers list · describe │ + │ 7 t Stacks list · describe │ + │ 8 u Pull requests automations [soon] │ ╰──────────────────────────────────────────────────────────────────────────╯ ╭─ Connection ─────────────────────────────────────────────────────────────╮ @@ -19,6 +21,4 @@ - - - 1–6 / letter · enter open · esc welcome · ctrl+c quit + 1–8 / letter · enter open · esc welcome · ctrl+c quit diff --git a/tui/screens/deployments/view.go b/tui/screens/deployments/view.go index 9639b3da7..695be7cbd 100644 --- a/tui/screens/deployments/view.go +++ b/tui/screens/deployments/view.go @@ -20,10 +20,10 @@ func (m Model) View(width, height int) string { status = m.theme.Success.Render(ansi.Truncate(m.console, 40, "…")) } - body := page.Panel(m.theme, "Resources", m.resourceLines(contentWidth-4), contentWidth, 8, true) + "\n\n" + + body := page.Panel(m.theme, "Resources", m.resourceLines(contentWidth-4), contentWidth, 10, true) + "\n\n" + page.Panel(m.theme, "Connection", m.connectionLines(), contentWidth, 4, false) - help := "1–6 / letter · enter open · esc welcome · ctrl+c quit" + help := "1–8 / letter · enter open · esc welcome · ctrl+c quit" return page.Render(m.theme, width, height, "CD / Deployments", status, body, help) } diff --git a/tui/screens/stacks/model.go b/tui/screens/stacks/model.go new file mode 100644 index 000000000..0b82df6f4 --- /dev/null +++ b/tui/screens/stacks/model.go @@ -0,0 +1,306 @@ +// Package stacks implements the read-only Console infrastructure stacks browser. +package stacks + +import ( + "context" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + stacksbridge "github.com/pluralsh/plural-cli/pkg/bridge/stacks" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeList mode = iota + modeDetail + modeFilter +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionFilter + keyActionConnectConsole + keyActionNextPage + keyActionPrevPage +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionRefresh: {"r"}, + keyActionFilter: {"/"}, + keyActionConnectConsole: {"c"}, + keyActionNextPage: {"n", "right", "]"}, + keyActionPrevPage: {"p", "left", "["}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + return keyActionNone +} + +type initMsg struct{} +type listedMsg struct { + page stacksbridge.Page + err error + request uint64 +} +type detailMsg struct { + detail stacksbridge.Detail + err error + request uint64 +} + +// Model owns Stacks-screen interaction state. +type Model struct { + ctx context.Context + loader stacksbridge.Loader + theme theme.Theme + mode mode + loading bool + err error + needsAuth bool + request uint64 + + page stacksbridge.Page + cursor int + filter string + filterInput textinput.Model + after *string + prevCursors []string + + detail stacksbridge.Detail + detailID string + listCursor int + listAfter *string + listFilter string + listPrev []string +} + +func New(ctx context.Context, loader stacksbridge.Loader, t theme.Theme) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "filter stacks" + input.CharLimit = 128 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + input.SetStyles(styles) + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, mode: modeList} +} + +func (m Model) Init() tea.Cmd { + return func() tea.Msg { return initMsg{} } +} + +func (m *Model) beginList(after *string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + query := m.filter + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + page, err := loader.List(ctx, after, query) + return listedMsg{page: page, err: err, request: request} + } +} + +func (m *Model) beginDetail(id string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + detail, err := loader.Get(ctx, id) + return detailMsg{detail: detail, err: err, request: request} + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.mode = modeList + m.page = stacksbridge.Page{} + m.cursor = 0 + m.after = nil + m.prevCursors = nil + m.err = nil + m.needsAuth = false + if m.loader == nil { + m.loading = false + return m, nil + } + return m, m.beginList(nil) + case listedMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.page = msg.page + m.cursor = clampCursor(m.cursor, len(m.page.Items)) + m.mode = modeList + } + return m, nil + case detailMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.detail = msg.detail + m.mode = modeDetail + } + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeFilter { + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + if m.mode == modeFilter { + switch action { + case keyActionBack: + m.mode = modeList + m.filterInput.Blur() + return m, nil + case keyActionConfirm: + m.filter = strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + m.mode = modeList + m.cursor = 0 + m.after = nil + m.prevCursors = nil + return m, m.beginList(nil) + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd + } + if action == keyActionBack { + if m.mode == modeDetail { + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.after = m.listAfter + m.filter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) + return m, nil + } + return m, navigation.Navigate(navigation.Deployments) + } + if m.loading { + return m, nil + } + if m.needsAuth && action == keyActionConnectConsole { + return m, navigation.Navigate(navigation.Access) + } + if m.mode == modeDetail { + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + return m, nil + } + return m.updateList(action) +} + +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionMoveUp: + m.cursor = clampCursor(m.cursor-1, len(m.page.Items)) + case keyActionMoveDown: + m.cursor = clampCursor(m.cursor+1, len(m.page.Items)) + case keyActionConfirm: + if len(m.page.Items) == 0 { + return m, nil + } + m.listCursor = m.cursor + m.listAfter = m.after + m.listFilter = m.filter + m.listPrev = append([]string(nil), m.prevCursors...) + m.detailID = m.page.Items[m.cursor].ID + return m, m.beginDetail(m.detailID) + case keyActionRefresh: + return m, m.beginList(m.after) + case keyActionFilter: + m.mode = modeFilter + m.filterInput.SetValue(m.filter) + m.filterInput.Focus() + case keyActionNextPage: + if !m.page.HasNext || m.page.EndCursor == "" { + return m, nil + } + if m.after != nil { + m.prevCursors = append(m.prevCursors, *m.after) + } else { + m.prevCursors = append(m.prevCursors, "") + } + cursor := m.page.EndCursor + m.after = &cursor + m.cursor = 0 + return m, m.beginList(m.after) + case keyActionPrevPage: + if len(m.prevCursors) == 0 { + return m, nil + } + previous := m.prevCursors[len(m.prevCursors)-1] + m.prevCursors = m.prevCursors[:len(m.prevCursors)-1] + if previous == "" { + m.after = nil + } else { + m.after = &previous + } + m.cursor = 0 + return m, m.beginList(m.after) + } + return m, nil +} + +func clampCursor(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} diff --git a/tui/screens/stacks/model_test.go b/tui/screens/stacks/model_test.go new file mode 100644 index 000000000..f348439e8 --- /dev/null +++ b/tui/screens/stacks/model_test.go @@ -0,0 +1,227 @@ +package stacks + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/pkg/bridge" + stacksbridge "github.com/pluralsh/plural-cli/pkg/bridge/stacks" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + page stacksbridge.Page + detail stacksbridge.Detail + err error +} + +func (f *fakeLoader) List(context.Context, *string, string) (stacksbridge.Page, error) { + return f.page, f.err +} +func (f *fakeLoader) Get(context.Context, string) (stacksbridge.Detail, error) { + return f.detail, f.err +} + +func loadList(t *testing.T, model Model) Model { + t.Helper() + cmd := model.Init() + model, cmd = model.Update(cmd()) + if cmd == nil { + t.Fatal("expected list command") + } + model, _ = model.Update(cmd()) + return model +} + +func TestOpenStackDetailAndBack(t *testing.T) { + loader := &fakeLoader{ + page: stacksbridge.Page{Items: []stacksbridge.Summary{ + {ID: "s1", Name: "gke-demo", Type: "TERRAFORM", Project: "acme", Cluster: "mgmt", Approval: "true"}, + {ID: "s2", Name: "ansible-edge", Type: "ANSIBLE"}, + }}, + detail: stacksbridge.Detail{ + Summary: stacksbridge.Summary{ID: "s1", Name: "gke-demo", Type: "TERRAFORM", Project: "acme", Cluster: "mgmt", Approval: "true", RepoURL: "https://github.com/acme/fleet"}, + Workdir: "gke-cluster", ManageState: "true", GitRef: "main", GitFolder: "terraform", ConfigVersion: "1.8.2", + EnvNames: []string{"TF_VAR_cluster"}, + OutputNames: []string{"cluster_name", "token (secret)"}, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if model.mode != modeList || len(model.page.Items) != 2 { + t.Fatalf("list state = mode=%d count=%d", model.mode, len(model.page.Items)) + } + if !strings.Contains(model.View(80, 24), "gke-demo") { + t.Fatalf("list missing name:\n%s", model.View(80, 24)) + } + + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail || model.detail.Name != "gke-demo" { + t.Fatalf("detail = %#v mode=%d", model.detail, model.mode) + } + if !strings.Contains(model.View(80, 24), "TF_VAR_cluster") || !strings.Contains(model.View(80, 24), "token (secret)") { + t.Fatalf("detail missing env/outputs:\n%s", model.View(80, 24)) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeList { + t.Fatalf("mode after detail esc = %d", model.mode) + } + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Deployments}) { + t.Fatalf("expected deployments navigation") + } +} + +func TestNextPrevPage(t *testing.T) { + loader := &fakeLoader{ + page: stacksbridge.Page{ + Items: []stacksbridge.Summary{{ID: "s1", Name: "a", Type: "TERRAFORM"}}, + EndCursor: "s1", + HasNext: true, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !strings.Contains(model.View(80, 24), "n next") { + t.Fatalf("missing next pager:\n%s", model.View(80, 24)) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: 'n'}) + if cmd == nil { + t.Fatal("expected next-page list command") + } + loader.page = stacksbridge.Page{Items: []stacksbridge.Summary{{ID: "s2", Name: "b", Type: "ANSIBLE"}}} + model, _ = model.Update(cmd()) + if model.after == nil || *model.after != "s1" || len(model.prevCursors) != 1 { + t.Fatalf("after page turn after=%v prev=%v", model.after, model.prevCursors) + } + model, cmd = model.Update(tea.KeyPressMsg{Code: 'p'}) + if cmd == nil { + t.Fatal("expected prev-page list command") + } + model, _ = model.Update(cmd()) + if model.after != nil || len(model.prevCursors) != 0 { + t.Fatalf("after prev after=%v prev=%v", model.after, model.prevCursors) + } +} + +func TestNoConsoleNavigatesToAccess(t *testing.T) { + loader := &fakeLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !model.needsAuth { + t.Fatal("expected needsAuth") + } + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c'}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Access}) { + t.Fatalf("expected access navigation") + } +} + +func TestStacksGoldens(t *testing.T) { + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = stacksbridge.Page{Items: []stacksbridge.Summary{ + {ID: "s1", Name: "gke-demo", Type: "TERRAFORM", Project: "acme", Cluster: "mgmt", Approval: "true"}, + {ID: "s2", Name: "ansible-edge", Type: "ANSIBLE", Project: "acme", Cluster: "edge"}, + {ID: "s3", Name: "pulumi-net", Type: "PULUMI"}, + }, HasNext: true, EndCursor: "s3"} + + detail := list + detail.mode = modeDetail + detail.detail = stacksbridge.Detail{ + Summary: stacksbridge.Summary{ID: "s1", Name: "gke-demo", Type: "TERRAFORM", Project: "acme", Cluster: "mgmt", Approval: "true", RepoURL: "https://github.com/acme/fleet"}, + Workdir: "gke-cluster", ManageState: "true", GitRef: "main", GitFolder: "terraform", ConfigVersion: "1.8.2", + EnvNames: []string{"TF_VAR_cluster"}, + OutputNames: []string{"cluster_name", "token (secret)"}, + } + + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeView(tc.model.View(tc.width, tc.height)) + golden := filepath.Join("testdata", "stacks-"+tc.name+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + lines := strings.Split(got, "\n") + if len(lines) != tc.height { + t.Fatalf("height = %d, want %d", len(lines), tc.height) + } + for _, line := range lines { + if w := lipgloss.Width(line); w > tc.width { + t.Fatalf("line width %d > %d: %q", w, tc.width, line) + } + } + }) + } +} + +func TestWriteStacksGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = stacksbridge.Page{Items: []stacksbridge.Summary{ + {ID: "s1", Name: "gke-demo", Type: "TERRAFORM", Project: "acme", Cluster: "mgmt", Approval: "true"}, + {ID: "s2", Name: "ansible-edge", Type: "ANSIBLE", Project: "acme", Cluster: "edge"}, + {ID: "s3", Name: "pulumi-net", Type: "PULUMI"}, + }, HasNext: true, EndCursor: "s3"} + detail := list + detail.mode = modeDetail + detail.detail = stacksbridge.Detail{ + Summary: stacksbridge.Summary{ID: "s1", Name: "gke-demo", Type: "TERRAFORM", Project: "acme", Cluster: "mgmt", Approval: "true", RepoURL: "https://github.com/acme/fleet"}, + Workdir: "gke-cluster", ManageState: "true", GitRef: "main", GitFolder: "terraform", ConfigVersion: "1.8.2", + EnvNames: []string{"TF_VAR_cluster"}, + OutputNames: []string{"cluster_name", "token (secret)"}, + } + _ = os.MkdirAll("testdata", 0o755) + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + got := normalizeView(tc.model.View(tc.width, tc.height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "stacks-"+tc.name+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/stacks/testdata/stacks-detail-120.golden b/tui/screens/stacks/testdata/stacks-detail-120.golden new file mode 100644 index 000000000..df493369e --- /dev/null +++ b/tui/screens/stacks/testdata/stacks-detail-120.golden @@ -0,0 +1,30 @@ + Plural Stacks · gke-demo TERRAFORM + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Name gke-demo │ + │ Type TERRAFORM │ + │ Project acme │ + │ Cluster mgmt │ + │ Approval true │ + │ Repo https://github.com/acme/fleet │ + │ Git main · terraform │ + │ Workdir gke-cluster │ + │ State true │ + │ Version 1.8.2 │ + │ ID s1 │ + │ Env TF_VAR_cluster │ + │ Outputs cluster_name, token (secret) │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/stacks/testdata/stacks-detail-80.golden b/tui/screens/stacks/testdata/stacks-detail-80.golden new file mode 100644 index 000000000..7d39a82e8 --- /dev/null +++ b/tui/screens/stacks/testdata/stacks-detail-80.golden @@ -0,0 +1,24 @@ + Plural Stacks · gke-demo TERRAFORM + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────╮ + │ Name gke-demo │ + │ Type TERRAFORM │ + │ Project acme │ + │ Cluster mgmt │ + │ Approval true │ + │ Repo https://github.com/acme/fleet │ + │ Git main · terraform │ + │ Workdir gke-cluster │ + │ State true │ + │ Version 1.8.2 │ + │ ID s1 │ + │ Env TF_VAR_cluster │ + │ Outputs cluster_name, token (secret) │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/stacks/testdata/stacks-list-120.golden b/tui/screens/stacks/testdata/stacks-list-120.golden new file mode 100644 index 000000000..b9d8aa2f6 --- /dev/null +++ b/tui/screens/stacks/testdata/stacks-list-120.golden @@ -0,0 +1,30 @@ + Plural Stacks 3 stacks + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Stacks ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ NAME TYPE CLUSTER PROJECT │ + │ › gke-demo TERRAFORM mgmt acme │ + │ ansible-edge ANSIBLE edge acme │ + │ pulumi-net PULUMI — — │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · enter open · / filter · n/p page · r refresh · esc back diff --git a/tui/screens/stacks/testdata/stacks-list-80.golden b/tui/screens/stacks/testdata/stacks-list-80.golden new file mode 100644 index 000000000..e1efaf24d --- /dev/null +++ b/tui/screens/stacks/testdata/stacks-list-80.golden @@ -0,0 +1,24 @@ + Plural Stacks 3 stacks + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Stacks ───────────────────────────────────────────────────────────────╮ + │ NAME TYPE CLUSTER PROJECT │ + │ › gke-demo TERRAFORM mgmt acme │ + │ ansible-edge ANSIBLE edge acme │ + │ pulumi-net PULUMI — — │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / · n/p page · esc back diff --git a/tui/screens/stacks/view.go b/tui/screens/stacks/view.go new file mode 100644 index 000000000..51842dad6 --- /dev/null +++ b/tui/screens/stacks/view.go @@ -0,0 +1,220 @@ +package stacks + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + title := "Stacks" + if m.mode == modeDetail && m.detail.Name != "" { + title = "Stacks · " + m.detail.Name + } + body, help := m.bodyAndHelp(contentWidth) + return page.Render(m.theme, width, height, title, m.headerStatus(), body, help) +} + +func (m Model) headerStatus() string { + if m.loading { + return m.theme.Warning.Render("◌ loading") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + if m.err != nil { + return m.theme.Danger.Render("✗ load failed") + } + switch m.mode { + case modeDetail: + return m.theme.Success.Render(loCoalesce(m.detail.Type, "stack")) + case modeList: + if m.filter != "" { + return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.page.Items))) + } + return m.theme.Success.Render(fmt.Sprintf("%d stacks", len(m.page.Items))) + default: + return m.theme.Muted.Render("stacks") + } +} + +func (m Model) bodyAndHelp(width int) (string, string) { + if m.mode == modeFilter { + lines := []string{ + m.theme.Muted.Render("Filter by name, type, project, cluster, or id."), + "", + m.filterInput.View(), + } + return page.Panel(m.theme, "Filter stacks", lines, width, 6, true), "enter apply · esc cancel" + } + if m.needsAuth { + lines := []string{ + m.theme.Warning.Render("○ Console is not connected"), + m.theme.Muted.Render(" Connect a Console profile to browse stacks."), + "", + m.theme.Body.Render("Press c to open Access."), + } + return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" + } + if m.mode == modeDetail { + help := "r refresh · esc list · ctrl+c quit" + return page.Panel(m.theme, "Summary", m.detailLines(), width, 16, true), help + } + help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc back" + if width < 100 { + help = "↑/↓ · enter · / · n/p page · esc back" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help +} + +func (m Model) listTitle() string { + if m.filter != "" { + return "Stacks · filter “" + m.filter + "”" + } + return "Stacks" +} + +func (m Model) listLines(width int) []string { + if m.loading && len(m.page.Items) == 0 { + return []string{m.theme.Warning.Render("◌ Loading stacks…")} + } + if m.err != nil { + return []string{ + m.theme.Danger.Render("✗ Unable to load stacks"), + m.theme.Danger.Render("Error " + m.err.Error()), + m.theme.Muted.Render("Press r to retry."), + } + } + if len(m.page.Items) == 0 { + return []string{ + m.theme.Warning.Render("○ No stacks found"), + m.theme.Muted.Render(" Adjust the filter or connect another Console."), + } + } + nameWidth := max(12, min(20, width/4)) + typeWidth := max(8, min(12, width/8)) + clusterWidth := max(8, min(14, width/5)) + lines := []string{m.theme.Muted.Render(" " + pad("NAME", nameWidth) + " " + pad("TYPE", typeWidth) + " " + pad("CLUSTER", clusterWidth) + " PROJECT")} + start, end := visibleWindow(m.cursor, len(m.page.Items), 8) + for i := start; i < end; i++ { + item := m.page.Items[i] + cursor := " " + if i == m.cursor { + cursor = "› " + } + row := cursor + pad(item.Name, nameWidth) + " " + pad(item.Type, typeWidth) + " " + pad(loCoalesce(item.Cluster, "—"), clusterWidth) + " " + loCoalesce(item.Project, "—") + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + if start > 0 || end < len(m.page.Items) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" … %d–%d of %d", start+1, end, len(m.page.Items)))) + } + if m.page.HasNext || len(m.prevCursors) > 0 { + pager := "page" + if len(m.prevCursors) > 0 { + pager += " · p prev" + } + if m.page.HasNext { + pager += " · n next" + } + lines = append(lines, "", m.theme.Muted.Render(pager)) + } + return lines +} + +func visibleWindow(cursor, count, size int) (start, end int) { + if count <= 0 { + return 0, 0 + } + if size <= 0 { + size = count + } + if count <= size { + return 0, count + } + start = cursor - size/2 + if start < 0 { + start = 0 + } + end = start + size + if end > count { + end = count + start = end - size + } + return start, end +} + +func (m Model) detailLines() []string { + if m.loading { + return []string{m.theme.Warning.Render("◌ Loading stack detail…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load stack"), m.theme.Danger.Render(m.err.Error())} + } + lines := []string{ + m.labelValue("Name", m.detail.Name), + m.labelValue("Type", loCoalesce(m.detail.Type, "—")), + m.labelValue("Project", loCoalesce(m.detail.Project, "—")), + m.labelValue("Cluster", loCoalesce(m.detail.Cluster, "—")), + m.labelValue("Approval", loCoalesce(m.detail.Approval, "—")), + m.labelValue("Repo", loCoalesce(m.detail.RepoURL, "—")), + m.labelValue("Git", formatGit(m.detail.GitRef, m.detail.GitFolder)), + m.labelValue("Workdir", loCoalesce(m.detail.Workdir, "—")), + m.labelValue("State", loCoalesce(m.detail.ManageState, "—")), + m.labelValue("Version", loCoalesce(m.detail.ConfigVersion, "—")), + m.labelValue("ID", m.detail.ID), + } + if m.detail.DeletedAt != "" { + lines = append(lines, m.labelValue("Deleted", m.detail.DeletedAt)) + } + if len(m.detail.EnvNames) > 0 { + lines = append(lines, m.labelValue("Env", strings.Join(m.detail.EnvNames, ", "))) + } + if len(m.detail.OutputNames) > 0 { + lines = append(lines, m.labelValue("Outputs", strings.Join(m.detail.OutputNames, ", "))) + } + return lines +} + +func formatGit(ref, folder string) string { + switch { + case ref != "" && folder != "": + return ref + " · " + folder + case ref != "": + return ref + case folder != "": + return folder + default: + return "—" + } +} + +func (m Model) labelValue(label, value string) string { + label = label + strings.Repeat(" ", max(1, 12-len(label))) + return label + " " + value +} + +func pad(value string, width int) string { + value = ansi.Truncate(value, width, "…") + if lipgloss.Width(value) >= width { + return value + } + return value + strings.Repeat(" ", width-lipgloss.Width(value)) +} + +func loCoalesce(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} From e0d55be17263fccfe38329497dd9edf526f0e659 Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Fri, 31 Jul 2026 13:36:59 +0200 Subject: [PATCH 13/16] improve stacks --- cmd/command/tui/tui.go | 3 + pkg/bridge/pullrequests/actions.go | 138 ++++ pkg/bridge/pullrequests/pullrequests.go | 199 ++++++ pkg/bridge/pullrequests/pullrequests_test.go | 141 ++++ pkg/bridge/stacks/actions.go | 127 ++++ pkg/bridge/stacks/stacks.go | 18 +- pkg/bridge/stacks/stacks_test.go | 56 ++ pkg/console/console.go | 2 + pkg/console/pr.go | 19 + pkg/stacks/helpers.go | 19 +- pkg/test/mocks/ConsoleClient.go | 60 ++ tui/app/model.go | 9 + tui/app/view.go | 2 + tui/navigation/navigation.go | 1 + tui/screens/deployments/model.go | 4 +- tui/screens/deployments/model_test.go | 38 +- .../testdata/deployments-120.golden | 4 +- .../testdata/deployments-80.golden | 4 +- tui/screens/pullrequests/actions.go | 165 +++++ tui/screens/pullrequests/model.go | 622 ++++++++++++++++++ tui/screens/pullrequests/model_test.go | 292 ++++++++ .../testdata/pullrequests-detail-120.golden | 30 + .../testdata/pullrequests-detail-80.golden | 24 + .../testdata/pullrequests-list-120.golden | 30 + .../testdata/pullrequests-list-80.golden | 24 + tui/screens/pullrequests/view.go | 297 +++++++++ tui/screens/stacks/actions.go | 93 +++ tui/screens/stacks/model.go | 320 +++++++-- tui/screens/stacks/model_test.go | 74 ++- .../stacks/testdata/stacks-detail-120.golden | 16 +- .../stacks/testdata/stacks-detail-80.golden | 16 +- tui/screens/stacks/view.go | 101 ++- 32 files changed, 2835 insertions(+), 113 deletions(-) create mode 100644 pkg/bridge/pullrequests/actions.go create mode 100644 pkg/bridge/pullrequests/pullrequests.go create mode 100644 pkg/bridge/pullrequests/pullrequests_test.go create mode 100644 pkg/bridge/stacks/actions.go create mode 100644 tui/screens/pullrequests/actions.go create mode 100644 tui/screens/pullrequests/model.go create mode 100644 tui/screens/pullrequests/model_test.go create mode 100644 tui/screens/pullrequests/testdata/pullrequests-detail-120.golden create mode 100644 tui/screens/pullrequests/testdata/pullrequests-detail-80.golden create mode 100644 tui/screens/pullrequests/testdata/pullrequests-list-120.golden create mode 100644 tui/screens/pullrequests/testdata/pullrequests-list-80.golden create mode 100644 tui/screens/pullrequests/view.go create mode 100644 tui/screens/stacks/actions.go diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go index 1c0a28528..c3f2f7534 100644 --- a/cmd/command/tui/tui.go +++ b/cmd/command/tui/tui.go @@ -12,6 +12,7 @@ import ( notificationsbridge "github.com/pluralsh/plural-cli/pkg/bridge/notifications" pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" providersbridge "github.com/pluralsh/plural-cli/pkg/bridge/providers" + pullrequestsbridge "github.com/pluralsh/plural-cli/pkg/bridge/pullrequests" repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" stacksbridge "github.com/pluralsh/plural-cli/pkg/bridge/stacks" @@ -33,6 +34,7 @@ func Command() cli.Command { notifications := notificationsbridge.NewService(access) providers := providersbridge.NewService(access) stacks := stacksbridge.NewService(access) + pullrequests := pullrequestsbridge.NewService(access) return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{ Welcome: welcome, Access: access, @@ -43,6 +45,7 @@ func Command() cli.Command { Notifications: notifications, Providers: providers, Stacks: stacks, + PullRequests: pullrequests, }) }) } diff --git a/pkg/bridge/pullrequests/actions.go b/pkg/bridge/pullrequests/actions.go new file mode 100644 index 000000000..b4b047baa --- /dev/null +++ b/pkg/bridge/pullrequests/actions.go @@ -0,0 +1,138 @@ +package pullrequests + +import ( + "context" + "encoding/json" + "errors" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +var ( + errMissingAutomationID = errors.New("pr automation id is required") + errMissingPR = errors.New("pull request was not created") +) + +// CreatePRInput creates a PR from an automation id (CLI: plural pr create). +type CreatePRInput struct { + AutomationID string + Branch string + Context string // optional raw JSON +} + +// TriggerPRInput triggers an automation with configuration (CLI: plural pr trigger). +type TriggerPRInput struct { + AutomationID string + Name string + Branch string + Configuration map[string]string +} + +// CreatedPR is the credential-free result of create/trigger. +type CreatedPR struct { + ID string + URL string + Title string + Status string + Creator string + Ref string +} + +// Loader is the narrow contract consumed by the Pull requests screen. +type Loader interface { + List(ctx context.Context, after *string, query string) (Page, error) + Get(ctx context.Context, id string) (Detail, error) + CreatePR(ctx context.Context, input CreatePRInput) (CreatedPR, error) + TriggerPR(ctx context.Context, input TriggerPRInput) (CreatedPR, error) +} + +// API is the Console surface required by this package. +type API interface { + ListPrAutomations() (*gqlclient.ListPrAutomations, error) + GetPrAutomation(id string) (*gqlclient.PrAutomationFragment, error) + CreatePullRequest(id string, branch, context *string) (*gqlclient.PullRequestFragment, error) +} + +func (s *Service) CreatePR(ctx context.Context, input CreatePRInput) (CreatedPR, error) { + if err := ctx.Err(); err != nil { + return CreatedPR{}, err + } + id := strings.TrimSpace(input.AutomationID) + if id == "" { + return CreatedPR{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingAutomationID} + } + client, err := s.client(ctx) + if err != nil { + return CreatedPR{}, err + } + var branch, context *string + if b := strings.TrimSpace(input.Branch); b != "" { + branch = &b + } + if c := strings.TrimSpace(input.Context); c != "" { + context = &c + } + pr, err := client.CreatePullRequest(id, branch, context) + if err != nil { + return CreatedPR{}, err + } + if pr == nil { + return CreatedPR{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingPR} + } + return createdFromFragment(pr), nil +} + +func (s *Service) TriggerPR(ctx context.Context, input TriggerPRInput) (CreatedPR, error) { + if err := ctx.Err(); err != nil { + return CreatedPR{}, err + } + id := strings.TrimSpace(input.AutomationID) + if id == "" { + return CreatedPR{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingAutomationID} + } + client, err := s.client(ctx) + if err != nil { + return CreatedPR{}, err + } + cfg := input.Configuration + if cfg == nil { + cfg = map[string]string{} + } + contextJSON, err := json.Marshal(cfg) + if err != nil { + return CreatedPR{}, err + } + var branch *string + if b := strings.TrimSpace(input.Branch); b != "" { + branch = &b + } + pr, err := client.CreatePullRequest(id, branch, lo.ToPtr(string(contextJSON))) + if err != nil { + return CreatedPR{}, err + } + if pr == nil { + return CreatedPR{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingPR} + } + return createdFromFragment(pr), nil +} + +func createdFromFragment(pr *gqlclient.PullRequestFragment) CreatedPR { + created := CreatedPR{ID: pr.ID, URL: pr.URL} + if pr.Title != nil { + created.Title = *pr.Title + } + if pr.Status != nil { + created.Status = string(*pr.Status) + } + if pr.Creator != nil { + created.Creator = *pr.Creator + } + if pr.Ref != nil { + created.Ref = *pr.Ref + } + return created +} diff --git a/pkg/bridge/pullrequests/pullrequests.go b/pkg/bridge/pullrequests/pullrequests.go new file mode 100644 index 000000000..5150c9222 --- /dev/null +++ b/pkg/bridge/pullrequests/pullrequests.go @@ -0,0 +1,199 @@ +// Package pullrequests exposes read-only Console PR automation list/get +// use cases to presentation layers without importing TUI code. +package pullrequests + +import ( + "context" + "errors" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +const defaultPageSize int64 = 10 + +var ( + errNoConsole = errors.New("connect a Console profile before browsing Console resources") + errMissingID = errors.New("pr automation id is required") + errMissingAutomation = errors.New("pr automation was not found") +) + +// Summary is a credential-free list row for a PR automation. +type Summary struct { + ID string + Name string + Title string + Addon string + Identifier string +} + +// Detail is the credential-free detail payload for a PR automation. +type Detail struct { + Summary + Message string + InsertedAt string + UpdatedAt string +} + +// Page is one cursor page of PR automation summaries. +type Page struct { + Items []Summary + EndCursor string + HasNext bool + TotalShown int +} + +// ConsoleResolver supplies the active Console URL and token. +type ConsoleResolver interface { + ActiveConsole(ctx context.Context) (url, token string, err error) +} + +// ClientFactory builds a Console API for an authenticated endpoint. +type ClientFactory func(token, url string) (API, error) + +// Service implements Loader against Console GraphQL. +type Service struct { + resolve ConsoleResolver + newClient ClientFactory + pageSize int64 +} + +// NewService wires production Console credentials and client construction. +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + pageSize: defaultPageSize, + } +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s.resolve == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + url, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return nil, err + } + factory := s.newClient + if factory == nil { + factory = func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + } + } + return factory(token, url) +} + +func (s *Service) List(ctx context.Context, after *string, query string) (Page, error) { + if err := ctx.Err(); err != nil { + return Page{}, err + } + client, err := s.client(ctx) + if err != nil { + return Page{}, err + } + result, err := client.ListPrAutomations() + if err != nil { + return Page{}, err + } + if result == nil || result.PrAutomations == nil { + return Page{}, nil + } + items := make([]Summary, 0, len(result.PrAutomations.Edges)) + for _, edge := range result.PrAutomations.Edges { + if edge == nil || edge.Node == nil { + continue + } + summary := summaryFromFragment(edge.Node) + if !matchesQuery(summary, query) { + continue + } + items = append(items, summary) + } + return pageItems(items, after, s.pageSize), nil +} + +func (s *Service) Get(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + automation, err := client.GetPrAutomation(id) + if err != nil { + return Detail{}, err + } + if automation == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingAutomation} + } + return detailFromFragment(automation), nil +} + +func pageItems(items []Summary, after *string, pageSize int64) Page { + if pageSize <= 0 { + pageSize = defaultPageSize + } + start := 0 + if after != nil && *after != "" { + for i, item := range items { + if item.ID == *after { + start = i + 1 + break + } + } + } + if start > len(items) { + start = len(items) + } + end := start + int(pageSize) + if end > len(items) { + end = len(items) + } + page := Page{Items: items[start:end], TotalShown: end - start, HasNext: end < len(items)} + if len(page.Items) > 0 { + page.EndCursor = page.Items[len(page.Items)-1].ID + } + return page +} + +func summaryFromFragment(node *gqlclient.PrAutomationFragment) Summary { + return Summary{ + ID: node.ID, + Name: node.Name, + Title: lo.FromPtr(node.Title), + Addon: lo.FromPtr(node.Addon), + Identifier: lo.FromPtr(node.Identifier), + } +} + +func detailFromFragment(node *gqlclient.PrAutomationFragment) Detail { + return Detail{ + Summary: summaryFromFragment(node), + Message: lo.FromPtr(node.Message), + InsertedAt: lo.FromPtr(node.InsertedAt), + UpdatedAt: lo.FromPtr(node.UpdatedAt), + } +} + +func matchesQuery(summary Summary, query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{ + summary.Name, summary.Title, summary.Addon, summary.Identifier, summary.ID, + }, " ")) + return strings.Contains(haystack, query) +} diff --git a/pkg/bridge/pullrequests/pullrequests_test.go b/pkg/bridge/pullrequests/pullrequests_test.go new file mode 100644 index 000000000..0a117668a --- /dev/null +++ b/pkg/bridge/pullrequests/pullrequests_test.go @@ -0,0 +1,141 @@ +package pullrequests + +import ( + "context" + "strings" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeResolver struct { + url, token string + err error +} + +func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) { + return f.url, f.token, f.err +} + +type fakeAPI struct { + automations *gqlclient.ListPrAutomations + listErr error + detail *gqlclient.PrAutomationFragment + getErr error + created *gqlclient.PullRequestFragment + createErr error +} + +func (f *fakeAPI) ListPrAutomations() (*gqlclient.ListPrAutomations, error) { + return f.automations, f.listErr +} +func (f *fakeAPI) GetPrAutomation(string) (*gqlclient.PrAutomationFragment, error) { + return f.detail, f.getErr +} +func (f *fakeAPI) CreatePullRequest(string, *string, *string) (*gqlclient.PullRequestFragment, error) { + return f.created, f.createErr +} + +func TestListAndGet(t *testing.T) { + api := &fakeAPI{ + automations: &gqlclient.ListPrAutomations{PrAutomations: &gqlclient.ListPrAutomations_PrAutomations{ + Edges: []*gqlclient.ListPrAutomations_PrAutomations_Edges{ + {Node: &gqlclient.PrAutomationFragment{ + ID: "pra1", Name: "cluster-create", Title: lo.ToPtr("Create cluster"), + Addon: lo.ToPtr("cluster"), Identifier: lo.ToPtr("ops/cluster-create"), + }}, + {Node: &gqlclient.PrAutomationFragment{ID: "pra2", Name: "service-bump"}}, + }, + }}, + detail: &gqlclient.PrAutomationFragment{ + ID: "pra1", Name: "cluster-create", Title: lo.ToPtr("Create cluster"), + Addon: lo.ToPtr("cluster"), Identifier: lo.ToPtr("ops/cluster-create"), + Message: lo.ToPtr("Opens a PR to provision a new cluster"), InsertedAt: lo.ToPtr("2026-01-01T00:00:00Z"), + }, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 10, + } + + page, err := service.List(t.Context(), nil, "cluster") + if err != nil || len(page.Items) != 1 || page.Items[0].Name != "cluster-create" { + t.Fatalf("List() = %#v, %v", page, err) + } + if page.Items[0].Title != "Create cluster" || page.Items[0].Identifier != "ops/cluster-create" { + t.Fatalf("summary = %#v", page.Items[0]) + } + + detail, err := service.Get(t.Context(), "pra1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if detail.Message == "" || !strings.Contains(detail.Message, "provision") { + t.Fatalf("detail = %#v", detail) + } +} + +func TestCreateAndTrigger(t *testing.T) { + api := &fakeAPI{ + created: &gqlclient.PullRequestFragment{ + ID: "pr1", URL: "https://github.com/acme/fleet/pull/1", + Title: lo.ToPtr("Create cluster"), Status: lo.ToPtr(gqlclient.PrStatusOpen), + }, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + } + + created, err := service.CreatePR(t.Context(), CreatePRInput{AutomationID: "pra1", Branch: "feat/cluster"}) + if err != nil || created.ID != "pr1" || created.URL == "" { + t.Fatalf("CreatePR() = %#v, %v", created, err) + } + + triggered, err := service.TriggerPR(t.Context(), TriggerPRInput{ + AutomationID: "pra1", Name: "cluster-create", + Configuration: map[string]string{"cluster": "demo"}, + }) + if err != nil || triggered.ID != "pr1" { + t.Fatalf("TriggerPR() = %#v, %v", triggered, err) + } +} + +func TestListPages(t *testing.T) { + edges := make([]*gqlclient.ListPrAutomations_PrAutomations_Edges, 0, 3) + for _, id := range []string{"pra1", "pra2", "pra3"} { + edges = append(edges, &gqlclient.ListPrAutomations_PrAutomations_Edges{ + Node: &gqlclient.PrAutomationFragment{ID: id, Name: id}, + }) + } + api := &fakeAPI{automations: &gqlclient.ListPrAutomations{PrAutomations: &gqlclient.ListPrAutomations_PrAutomations{Edges: edges}}} + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 2, + } + first, err := service.List(t.Context(), nil, "") + if err != nil || len(first.Items) != 2 || !first.HasNext || first.EndCursor != "pra2" { + t.Fatalf("first = %#v, %v", first, err) + } + after := first.EndCursor + second, err := service.List(t.Context(), &after, "") + if err != nil || len(second.Items) != 1 || second.HasNext || second.Items[0].ID != "pra3" { + t.Fatalf("second = %#v, %v", second, err) + } +} + +func TestGetRequiresID(t *testing.T) { + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return &fakeAPI{}, nil }, + } + _, err := service.Get(t.Context(), "") + if !bridge.IsCode(err, bridge.ErrorInvalid) { + t.Fatalf("Get() error = %v", err) + } +} diff --git a/pkg/bridge/stacks/actions.go b/pkg/bridge/stacks/actions.go new file mode 100644 index 000000000..6c52bace6 --- /dev/null +++ b/pkg/bridge/stacks/actions.go @@ -0,0 +1,127 @@ +package stacks + +import ( + "context" + "errors" + "path/filepath" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/config" + "github.com/pluralsh/plural-cli/pkg/stacks" + "github.com/pluralsh/plural-cli/pkg/utils/git" +) + +var ( + errMissingStackID = errors.New("stack id is required") + errMissingActor = errors.New("plural app email is required to generate a terraform backend (run plural login)") +) + +// GenBackendInput generates '_override.tf' for a stack (CLI: plural stacks gen-backend). +type GenBackendInput struct { + StackID string + Dir string + Address string // optional override; otherwise fetched from Console runs + LockAddress string + UnlockAddress string +} + +// GenBackendResult is the credential-free outcome of gen-backend. +type GenBackendResult struct { + FilePath string + Dir string +} + +// Loader is the narrow contract consumed by the Stacks screen. +type Loader interface { + List(ctx context.Context, after *string, query string) (Page, error) + Get(ctx context.Context, id string) (Detail, error) + GenBackend(ctx context.Context, input GenBackendInput) (GenBackendResult, error) +} + +// API is the Console surface required by this package. +type API interface { + ListStacks() (*gqlclient.ListInfrastructureStacks, error) + GetStack(id string) (*gqlclient.InfrastructureStackFragment, error) + ListStackRuns(stackID string) (*gqlclient.ListStackRuns, error) +} + +func (s *Service) GenBackend(ctx context.Context, input GenBackendInput) (GenBackendResult, error) { + if err := ctx.Err(); err != nil { + return GenBackendResult{}, err + } + id := strings.TrimSpace(input.StackID) + if id == "" { + return GenBackendResult{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingStackID} + } + dir := strings.TrimSpace(input.Dir) + if dir == "" { + dir = "." + } + + if s.resolve == nil { + return GenBackendResult{}, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + _, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return GenBackendResult{}, err + } + client, err := s.client(ctx) + if err != nil { + return GenBackendResult{}, err + } + + address, lock, unlock := strings.TrimSpace(input.Address), strings.TrimSpace(input.LockAddress), strings.TrimSpace(input.UnlockAddress) + if address == "" || lock == "" || unlock == "" { + stateUrls, err := stacks.GetTerraformStateUrls(client, id) + if err != nil { + return GenBackendResult{}, err + } + if address == "" { + address = lo.FromPtr(stateUrls.Address) + } + if lock == "" { + lock = lo.FromPtr(stateUrls.Lock) + } + if unlock == "" { + unlock = lo.FromPtr(stateUrls.Unlock) + } + } + + actor, err := s.actorEmail() + if err != nil { + return GenBackendResult{}, err + } + + fileName, err := stacks.GenerateOverrideTemplate(&stacks.OverrideTemplateInput{ + Address: address, + LockAddress: lock, + UnlockAddress: unlock, + Actor: actor, + DeployToken: token, + }, dir) + if err != nil { + return GenBackendResult{}, err + } + if err := git.AppendGitIgnore(dir, []string{fileName}); err != nil { + return GenBackendResult{}, err + } + return GenBackendResult{FilePath: filepath.Join(dir, fileName), Dir: dir}, nil +} + +func (s *Service) actorEmail() (string, error) { + if s.actor != nil { + return s.actor() + } + if !config.Exists() { + return "", &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errMissingActor} + } + cfg := config.Read() + if strings.TrimSpace(cfg.Email) == "" { + return "", &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errMissingActor} + } + return cfg.Email, nil +} diff --git a/pkg/bridge/stacks/stacks.go b/pkg/bridge/stacks/stacks.go index 3c98131f2..a0c428a79 100644 --- a/pkg/bridge/stacks/stacks.go +++ b/pkg/bridge/stacks/stacks.go @@ -1,4 +1,4 @@ -// Package stacks exposes read-only Console infrastructure stack list/get +// Package stacks exposes Console infrastructure stack list/get/gen-backend // use cases to presentation layers without importing TUI code. package stacks @@ -56,30 +56,22 @@ type Page struct { TotalShown int } -// Loader is the narrow contract consumed by the Stacks screen. -type Loader interface { - List(ctx context.Context, after *string, query string) (Page, error) - Get(ctx context.Context, id string) (Detail, error) -} - // ConsoleResolver supplies the active Console URL and token. type ConsoleResolver interface { ActiveConsole(ctx context.Context) (url, token string, err error) } -// API is the Console surface required by this package. -type API interface { - ListStacks() (*gqlclient.ListInfrastructureStacks, error) - GetStack(id string) (*gqlclient.InfrastructureStackFragment, error) -} - // ClientFactory builds a Console API for an authenticated endpoint. type ClientFactory func(token, url string) (API, error) +// ActorFunc resolves the Plural App email used as terraform backend username. +type ActorFunc func() (string, error) + // Service implements Loader against Console GraphQL. type Service struct { resolve ConsoleResolver newClient ClientFactory + actor ActorFunc pageSize int64 } diff --git a/pkg/bridge/stacks/stacks_test.go b/pkg/bridge/stacks/stacks_test.go index 81de0a25d..02236b074 100644 --- a/pkg/bridge/stacks/stacks_test.go +++ b/pkg/bridge/stacks/stacks_test.go @@ -2,6 +2,9 @@ package stacks import ( "context" + "os" + "path/filepath" + "strings" "testing" gqlclient "github.com/pluralsh/console/go/client" @@ -24,6 +27,8 @@ type fakeAPI struct { listErr error detail *gqlclient.InfrastructureStackFragment getErr error + runs *gqlclient.ListStackRuns + runsErr error } func (f *fakeAPI) ListStacks() (*gqlclient.ListInfrastructureStacks, error) { @@ -32,6 +37,9 @@ func (f *fakeAPI) ListStacks() (*gqlclient.ListInfrastructureStacks, error) { func (f *fakeAPI) GetStack(string) (*gqlclient.InfrastructureStackFragment, error) { return f.detail, f.getErr } +func (f *fakeAPI) ListStackRuns(string) (*gqlclient.ListStackRuns, error) { + return f.runs, f.runsErr +} func TestListAndGet(t *testing.T) { api := &fakeAPI{ @@ -134,3 +142,51 @@ func TestGetRequiresID(t *testing.T) { t.Fatalf("Get() error = %v", err) } } + +func TestGenBackend(t *testing.T) { + dir := t.TempDir() + api := &fakeAPI{ + runs: &gqlclient.ListStackRuns{InfrastructureStack: &gqlclient.ListStackRuns_InfrastructureStack{ + Runs: &gqlclient.ListStackRuns_InfrastructureStack_Runs{ + Edges: []*gqlclient.ListStackRuns_InfrastructureStack_Runs_Edges{{ + Node: &gqlclient.StackRunFragment{ + Type: gqlclient.StackTypeTerraform, + StateUrls: &gqlclient.StackRunFragment_StateUrls{ + Terraform: &gqlclient.StackRunFragment_StateUrls_Terraform{ + Address: lo.ToPtr("https://console.example.com/v1/tf/state"), + Lock: lo.ToPtr("https://console.example.com/v1/tf/lock"), + Unlock: lo.ToPtr("https://console.example.com/v1/tf/unlock"), + }, + }, + }, + }}, + }, + }}, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "deploy-token"}, + newClient: func(string, string) (API, error) { return api, nil }, + actor: func() (string, error) { return "ops@acme.io", nil }, + } + result, err := service.GenBackend(t.Context(), GenBackendInput{StackID: "s1", Dir: dir}) + if err != nil { + t.Fatalf("GenBackend() error = %v", err) + } + if result.FilePath == "" || result.Dir != dir { + t.Fatalf("result = %#v", result) + } + contents, err := os.ReadFile(filepath.Join(dir, "_override.tf")) + if err != nil { + t.Fatalf("read override: %v", err) + } + text := string(contents) + if !strings.Contains(text, "https://console.example.com/v1/tf/state") || + !strings.Contains(text, "ops@acme.io") || + !strings.Contains(text, "deploy-token") { + t.Fatalf("override contents:\n%s", text) + } + ignore, err := os.ReadFile(filepath.Join(dir, ".gitignore")) + if err != nil || !strings.Contains(string(ignore), "_override.tf") { + t.Fatalf("gitignore = %q, %v", ignore, err) + } +} diff --git a/pkg/console/console.go b/pkg/console/console.go index 873d153bc..b5512f1c4 100644 --- a/pkg/console/console.go +++ b/pkg/console/console.go @@ -68,6 +68,8 @@ type ConsoleClient interface { CreateWorkbenchPRFollowup(url, prompt string) (string, error) EnqueueWorkbenchPRFollowup(url, prompt string, deferBy time.Duration) (*consoleclient.EnqueueWorkbenchPrFollowup_EnqueueWorkbenchPrFollowup, error) GetPrAutomationByName(name string) (*consoleclient.PrAutomationFragment, error) + ListPrAutomations() (*consoleclient.ListPrAutomations, error) + GetPrAutomation(id string) (*consoleclient.PrAutomationFragment, error) CreateBootstrapToken(attributes consoleclient.BootstrapTokenAttributes) (string, error) CreateClusterRegistration(attributes consoleclient.ClusterRegistrationCreateAttributes) (*consoleclient.ClusterRegistrationFragment, error) IsClusterRegistrationComplete(machineID string) (bool, *consoleclient.ClusterRegistrationFragment) diff --git a/pkg/console/pr.go b/pkg/console/pr.go index 2e2ce2182..2d93e13eb 100644 --- a/pkg/console/pr.go +++ b/pkg/console/pr.go @@ -16,6 +16,25 @@ func (c *consoleClient) CreatePullRequest(id string, branch, context *string) (* return result.CreatePullRequest, nil } +func (c *consoleClient) ListPrAutomations() (*consoleclient.ListPrAutomations, error) { + result, err := c.client.ListPrAutomations(c.ctx, nil, nil, nil) + if err != nil { + return nil, api.GetErrorResponse(err, "ListPrAutomations") + } + return result, nil +} + +func (c *consoleClient) GetPrAutomation(id string) (*consoleclient.PrAutomationFragment, error) { + result, err := c.client.GetPrAutomation(c.ctx, id) + if err != nil { + return nil, api.GetErrorResponse(err, "GetPrAutomation") + } + if result == nil || result.PrAutomation == nil { + return nil, fmt.Errorf("pr automation %s was not found", id) + } + return result.PrAutomation, nil +} + func (c *consoleClient) GetPrAutomationByName(name string) (*consoleclient.PrAutomationFragment, error) { result, err := c.client.GetPrAutomationByName(c.ctx, name) if err != nil { diff --git a/pkg/stacks/helpers.go b/pkg/stacks/helpers.go index 089485c58..7fd7f9ef5 100644 --- a/pkg/stacks/helpers.go +++ b/pkg/stacks/helpers.go @@ -4,20 +4,24 @@ import ( "fmt" gqlclient "github.com/pluralsh/console/go/client" - - "github.com/pluralsh/plural-cli/pkg/console" ) -func GetTerraformStateUrls(client console.ConsoleClient, stackID string) (*gqlclient.TerraformStateUrls, error) { +// StackRunsLister is the Console surface needed to resolve terraform backend URLs. +type StackRunsLister interface { + ListStackRuns(stackID string) (*gqlclient.ListStackRuns, error) +} + +func GetTerraformStateUrls(client StackRunsLister, stackID string) (*gqlclient.TerraformStateUrls, error) { stackRuns, err := client.ListStackRuns(stackID) if err != nil { return nil, err } - if stackRuns.InfrastructureStack == nil || + if stackRuns == nil || + stackRuns.InfrastructureStack == nil || stackRuns.InfrastructureStack.Runs == nil || len(stackRuns.InfrastructureStack.Runs.Edges) == 0 { - return nil, nil + return nil, fmt.Errorf("no terraform state urls found for stack %s", stackID) } stateUrls := toTerraformStateUrls(stackRuns.InfrastructureStack.Runs.Edges) @@ -30,8 +34,11 @@ func GetTerraformStateUrls(client console.ConsoleClient, stackID string) (*gqlcl func toTerraformStateUrls(stackRuns []*gqlclient.ListStackRuns_InfrastructureStack_Runs_Edges) *gqlclient.TerraformStateUrls { for _, edge := range stackRuns { + if edge == nil || edge.Node == nil { + continue + } run := edge.Node - if run.Type != gqlclient.StackTypeTerraform || run.StateUrls.Terraform == nil { + if run.Type != gqlclient.StackTypeTerraform || run.StateUrls == nil || run.StateUrls.Terraform == nil { continue } diff --git a/pkg/test/mocks/ConsoleClient.go b/pkg/test/mocks/ConsoleClient.go index bbc87e657..aca678aec 100644 --- a/pkg/test/mocks/ConsoleClient.go +++ b/pkg/test/mocks/ConsoleClient.go @@ -811,6 +811,36 @@ func (_m *ConsoleClient) GetPipelineContext(id string) (*client.PipelineContextF return r0, r1 } +// GetPrAutomation provides a mock function with given fields: id +func (_m *ConsoleClient) GetPrAutomation(id string) (*client.PrAutomationFragment, error) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for GetPrAutomation") + } + + var r0 *client.PrAutomationFragment + var r1 error + if rf, ok := ret.Get(0).(func(string) (*client.PrAutomationFragment, error)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) *client.PrAutomationFragment); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.PrAutomationFragment) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetPrAutomationByName provides a mock function with given fields: name func (_m *ConsoleClient) GetPrAutomationByName(name string) (*client.PrAutomationFragment, error) { ret := _m.Called(name) @@ -1261,6 +1291,36 @@ func (_m *ConsoleClient) ListPipelines() (*client.GetPipelines, error) { return r0, r1 } +// ListPrAutomations provides a mock function with no fields +func (_m *ConsoleClient) ListPrAutomations() (*client.ListPrAutomations, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ListPrAutomations") + } + + var r0 *client.ListPrAutomations + var r1 error + if rf, ok := ret.Get(0).(func() (*client.ListPrAutomations, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() *client.ListPrAutomations); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.ListPrAutomations) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // ListRepositories provides a mock function with no fields func (_m *ConsoleClient) ListRepositories() (*client.ListGitRepositories, error) { ret := _m.Called() diff --git a/tui/app/model.go b/tui/app/model.go index c69e2c74c..2e9d88a02 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -12,6 +12,7 @@ import ( notificationsbridge "github.com/pluralsh/plural-cli/pkg/bridge/notifications" pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" providersbridge "github.com/pluralsh/plural-cli/pkg/bridge/providers" + pullrequestsbridge "github.com/pluralsh/plural-cli/pkg/bridge/pullrequests" repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" stacksbridge "github.com/pluralsh/plural-cli/pkg/bridge/stacks" @@ -24,6 +25,7 @@ import ( notificationsscreen "github.com/pluralsh/plural-cli/tui/screens/notifications" pipelinesscreen "github.com/pluralsh/plural-cli/tui/screens/pipelines" providersscreen "github.com/pluralsh/plural-cli/tui/screens/providers" + pullrequestsscreen "github.com/pluralsh/plural-cli/tui/screens/pullrequests" repositoriesscreen "github.com/pluralsh/plural-cli/tui/screens/repositories" servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" stacksscreen "github.com/pluralsh/plural-cli/tui/screens/stacks" @@ -42,6 +44,7 @@ type Dependencies struct { Notifications notificationsbridge.Loader Providers providersbridge.Loader Stacks stacksbridge.Loader + PullRequests pullrequestsbridge.Loader } // Model is the root TUI model. It owns global input and delegates screen state @@ -64,6 +67,7 @@ type Model struct { notifications notificationsscreen.Model providers providersscreen.Model stacks stacksscreen.Model + pullrequests pullrequestsscreen.Model route navigation.Route } @@ -82,6 +86,7 @@ func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { notifications: notificationsscreen.New(ctx, dependencies.Notifications, t), providers: providersscreen.New(ctx, dependencies.Providers, t), stacks: stacksscreen.New(ctx, dependencies.Stacks, t), + pullrequests: pullrequestsscreen.New(ctx, dependencies.PullRequests, t), route: navigation.Welcome, quit: key.NewBinding( key.WithKeys("ctrl+c"), @@ -118,6 +123,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.providers.Init() case navigation.Stacks: return m, m.stacks.Init() + case navigation.PullRequests: + return m, m.pullrequests.Init() default: return m, m.welcome.Init() } @@ -157,6 +164,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.providers, cmd = m.providers.Update(msg) case navigation.Stacks: m.stacks, cmd = m.stacks.Update(msg) + case navigation.PullRequests: + m.pullrequests, cmd = m.pullrequests.Update(msg) default: m.welcome, cmd = m.welcome.Update(msg) } diff --git a/tui/app/view.go b/tui/app/view.go index 9d834e622..aa1f2cae3 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -31,6 +31,8 @@ func (m Model) View() tea.View { content = m.providers.View(m.width, m.height) case navigation.Stacks: content = m.stacks.View(m.width, m.height) + case navigation.PullRequests: + content = m.pullrequests.View(m.width, m.height) } view := tea.NewView(content) view.AltScreen = true diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index 55709287b..47c63ed2a 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -20,6 +20,7 @@ const ( Notifications Route = "notifications" Providers Route = "providers" Stacks Route = "stacks" + PullRequests Route = "pullrequests" ) // NavigateMsg requests a top-level route change. diff --git a/tui/screens/deployments/model.go b/tui/screens/deployments/model.go index f00ba2167..ce08d4571 100644 --- a/tui/screens/deployments/model.go +++ b/tui/screens/deployments/model.go @@ -40,8 +40,8 @@ func resources() []resource { {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "list · describe", route: navigation.Pipelines}, {id: resourceNotifications, number: "5", shortcut: "n", title: "Notifications", blurb: "list · describe", route: navigation.Notifications}, {id: resourceProviders, number: "6", shortcut: "v", title: "Providers", blurb: "list · describe", route: navigation.Providers}, - {id: resourceStacks, number: "7", shortcut: "t", title: "Stacks", blurb: "list · describe", route: navigation.Stacks}, - {id: resourcePullRequests, number: "8", shortcut: "u", title: "Pull requests", blurb: "automations", soon: true}, + {id: resourceStacks, number: "7", shortcut: "t", title: "Stacks", blurb: "browse · gen-backend", route: navigation.Stacks}, + {id: resourcePullRequests, number: "8", shortcut: "u", title: "Pull requests", blurb: "browse · create · trigger · …", route: navigation.PullRequests}, } } diff --git a/tui/screens/deployments/model_test.go b/tui/screens/deployments/model_test.go index 2891b334f..8143cd90d 100644 --- a/tui/screens/deployments/model_test.go +++ b/tui/screens/deployments/model_test.go @@ -121,15 +121,37 @@ func TestProvidersNavigates(t *testing.T) { } func TestSoonResourceDoesNotNavigate(t *testing.T) { + // All hub resources are live; keep a no-op guard if a soon stub returns. model := New(t.Context(), theme.New(colorprofile.ASCII), "") - model.cursor = 7 // pull requests [soon] - _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - if cmd != nil { - t.Fatalf("unexpected cmd %#v", cmd()) - } - _, cmd = model.Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) - if cmd != nil { - t.Fatalf("unexpected pull-requests shortcut cmd %#v", cmd()) + for _, item := range model.items { + if !item.soon { + continue + } + model.cursor = indexOfResource(model.items, item.id) + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd != nil { + t.Fatalf("unexpected cmd for soon resource %s: %#v", item.title, cmd()) + } + } +} + +func indexOfResource(items []resource, id resourceID) int { + for i, item := range items { + if item.id == id { + return i + } + } + return 0 +} + +func TestPullRequestsNavigates(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + _, cmd := model.Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + if cmd == nil { + t.Fatal("expected navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.PullRequests}) { + t.Fatalf("msg = %#v", msg) } } diff --git a/tui/screens/deployments/testdata/deployments-120.golden b/tui/screens/deployments/testdata/deployments-120.golden index 7860db059..40fa628e9 100644 --- a/tui/screens/deployments/testdata/deployments-120.golden +++ b/tui/screens/deployments/testdata/deployments-120.golden @@ -8,8 +8,8 @@ │ 4 p Pipelines list · describe │ │ 5 n Notifications list · describe │ │ 6 v Providers list · describe │ - │ 7 t Stacks list · describe │ - │ 8 u Pull requests automations [soon] │ + │ 7 t Stacks browse · gen-backend │ + │ 8 u Pull requests browse · create · trigger · … │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─ Connection ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ diff --git a/tui/screens/deployments/testdata/deployments-80.golden b/tui/screens/deployments/testdata/deployments-80.golden index cd8caa655..1957fcc18 100644 --- a/tui/screens/deployments/testdata/deployments-80.golden +++ b/tui/screens/deployments/testdata/deployments-80.golden @@ -8,8 +8,8 @@ │ 4 p Pipelines list · describe │ │ 5 n Notifications list · describe │ │ 6 v Providers list · describe │ - │ 7 t Stacks list · describe │ - │ 8 u Pull requests automations [soon] │ + │ 7 t Stacks browse · gen-backend │ + │ 8 u Pull requests browse · create · trigger · … │ ╰──────────────────────────────────────────────────────────────────────────╯ ╭─ Connection ─────────────────────────────────────────────────────────────╮ diff --git a/tui/screens/pullrequests/actions.go b/tui/screens/pullrequests/actions.go new file mode 100644 index 000000000..335e751e7 --- /dev/null +++ b/tui/screens/pullrequests/actions.go @@ -0,0 +1,165 @@ +package pullrequests + +import ( + "fmt" + "sort" + "strings" + + pullrequestsbridge "github.com/pluralsh/plural-cli/pkg/bridge/pullrequests" +) + +type actionKind uint8 + +const ( + actionCreate actionKind = iota + actionTrigger + actionTemplate + actionTest + actionContracts +) + +type detailAction struct { + kind actionKind + shortcut string + title string + blurb string + cliOnly bool +} + +func detailActions() []detailAction { + return []detailAction{ + {kind: actionCreate, shortcut: "c", title: "Create", blurb: "open PR from automation"}, + {kind: actionTrigger, shortcut: "t", title: "Trigger", blurb: "name · configuration · branch"}, + {kind: actionTemplate, shortcut: "m", title: "Template", blurb: "local file · apply tree", cliOnly: true}, + {kind: actionTest, shortcut: "e", title: "Test", blurb: "local CRD", cliOnly: true}, + {kind: actionContracts, shortcut: "o", title: "Contracts", blurb: "contract suite", cliOnly: true}, + } +} + +type pendingOp struct { + kind actionKind + title string + cli string + lines []string + create *pullrequestsbridge.CreatePRInput + trigger *pullrequestsbridge.TriggerPRInput +} + +func (m Model) createPlan(input pullrequestsbridge.CreatePRInput) pendingOp { + d := m.detail + cli := fmt.Sprintf("plural pr create %s", d.ID) + if input.Branch != "" { + cli += " --branch " + shellQuote(input.Branch) + } + if input.Context != "" { + cli += " --context " + shellQuote(input.Context) + } + lines := []string{ + "Action Create pull request", + "Automation " + d.Name, + "ID " + d.ID, + "Branch " + loCoalesce(input.Branch, "—"), + "Context " + loCoalesce(truncate(input.Context, 48), "—"), + } + return pendingOp{kind: actionCreate, title: "Create PR · " + d.Name, cli: cli, create: &input, lines: lines} +} + +func (m Model) triggerPlan(input pullrequestsbridge.TriggerPRInput) pendingOp { + d := m.detail + cli := fmt.Sprintf("plural pr trigger %s", shellQuote(d.Name)) + if input.Branch != "" { + cli += " --branch " + shellQuote(input.Branch) + } + keys := make([]string, 0, len(input.Configuration)) + for k := range input.Configuration { + keys = append(keys, k) + } + sort.Strings(keys) + cfg := "—" + if len(keys) > 0 { + parts := make([]string, 0, len(keys)) + for _, k := range keys { + v := input.Configuration[k] + cli += " --configuration " + shellQuote(k+"="+v) + parts = append(parts, k+"="+v) + } + cfg = strings.Join(parts, ", ") + } + lines := []string{ + "Action Trigger PR automation", + "Automation " + d.Name, + "Branch " + loCoalesce(input.Branch, "—"), + "Config " + cfg, + } + return pendingOp{kind: actionTrigger, title: "Trigger · " + d.Name, cli: cli, trigger: &input, lines: lines} +} + +func cliTipPlan(kind actionKind, name, file string) pendingOp { + file = loCoalesce(strings.TrimSpace(file), "./automation.yaml") + var title, cli string + var lines []string + switch kind { + case actionTemplate: + title = "Template · CLI" + cli = fmt.Sprintf("plural pr template --file %s", shellQuote(file)) + lines = []string{ + "Action Apply PR template in the local source tree", + "File " + file, + "", + "Local-only — TUI shows the CLI equivalent.", + } + case actionTest: + title = "Test · CLI" + cli = fmt.Sprintf("plural pr test --file %s", shellQuote(file)) + lines = []string{ + "Action Test a PR automation CRD locally", + "File " + file, + "", + "Local-only — TUI shows the CLI equivalent.", + } + case actionContracts: + title = "Contracts · CLI" + cli = fmt.Sprintf("plural pr contracts --file %s", shellQuote(file)) + lines = []string{ + "Action Run PR automation contract tests", + "File " + file, + "", + "Local-only — TUI shows the CLI equivalent.", + } + } + _ = name + return pendingOp{kind: kind, title: title, cli: cli, lines: lines} +} + +func parseConfiguration(raw string) (map[string]string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return map[string]string{}, nil + } + out := map[string]string{} + for _, part := range strings.Fields(raw) { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 || strings.TrimSpace(kv[0]) == "" { + return nil, fmt.Errorf("invalid configuration %q (expected key=value)", part) + } + out[kv[0]] = kv[1] + } + return out, nil +} + +func shellQuote(v string) string { + if v == "" { + return `""` + } + if strings.ContainsAny(v, " \t\"'") { + return `"` + strings.ReplaceAll(v, `"`, `\"`) + `"` + } + return v +} + +func truncate(v string, n int) string { + if len(v) <= n { + return v + } + return v[:n-1] + "…" +} diff --git a/tui/screens/pullrequests/model.go b/tui/screens/pullrequests/model.go new file mode 100644 index 000000000..b70195af6 --- /dev/null +++ b/tui/screens/pullrequests/model.go @@ -0,0 +1,622 @@ +// Package pullrequests implements the Console PR automations browser and actions. +package pullrequests + +import ( + "context" + "fmt" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + pullrequestsbridge "github.com/pluralsh/plural-cli/pkg/bridge/pullrequests" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeList mode = iota + modeDetail + modeFilter + modeCreateForm + modeTriggerForm + modeCLITip + modeReview + modeOperating + modeResult +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionFilter + keyActionConnectConsole + keyActionNextPage + keyActionPrevPage +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionRefresh: {"r"}, + keyActionFilter: {"/"}, + keyActionConnectConsole: {"c"}, + keyActionNextPage: {"n", "right", "]"}, + keyActionPrevPage: {"p", "left", "["}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + return keyActionNone +} + +type formField struct { + label string + key string +} + +type initMsg struct{} +type listedMsg struct { + page pullrequestsbridge.Page + err error + request uint64 +} +type detailMsg struct { + detail pullrequestsbridge.Detail + err error + request uint64 +} +type opDoneMsg struct { + err error + created pullrequestsbridge.CreatedPR + request uint64 + kind actionKind +} + +// Model owns Pull-requests-screen interaction state. +type Model struct { + ctx context.Context + loader pullrequestsbridge.Loader + theme theme.Theme + mode mode + loading bool + err error + needsAuth bool + request uint64 + + page pullrequestsbridge.Page + cursor int + filter string + filterInput textinput.Model + after *string + prevCursors []string + + detail pullrequestsbridge.Detail + detailID string + listCursor int + listAfter *string + listFilter string + listPrev []string + actionCursor int + + formInput textinput.Model + formFields []formField + formIndex int + formValues map[string]string + + pending pendingOp + opLog []string + result string + cliKind actionKind +} + +func New(ctx context.Context, loader pullrequestsbridge.Loader, t theme.Theme) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "filter PR automations" + input.CharLimit = 256 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + input.SetStyles(styles) + form := input + form.Placeholder = "" + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, formInput: form, mode: modeList} +} + +func (m Model) Init() tea.Cmd { + return func() tea.Msg { return initMsg{} } +} + +func (m *Model) beginList(after *string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + query := m.filter + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + page, err := loader.List(ctx, after, query) + return listedMsg{page: page, err: err, request: request} + } +} + +func (m *Model) beginDetail(id string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + detail, err := loader.Get(ctx, id) + return detailMsg{detail: detail, err: err, request: request} + } +} + +func (m *Model) beginPending() tea.Cmd { + m.mode = modeOperating + m.loading = true + m.err = nil + m.opLog = []string{"starting…"} + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + op := m.pending + return func() tea.Msg { + switch op.kind { + case actionCreate: + created, err := loader.CreatePR(ctx, *op.create) + return opDoneMsg{err: err, created: created, request: request, kind: op.kind} + case actionTrigger: + created, err := loader.TriggerPR(ctx, *op.trigger) + return opDoneMsg{err: err, created: created, request: request, kind: op.kind} + default: + return opDoneMsg{err: fmt.Errorf("unsupported action"), request: request, kind: op.kind} + } + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.mode = modeList + m.page = pullrequestsbridge.Page{} + m.cursor = 0 + m.after = nil + m.prevCursors = nil + m.err = nil + m.needsAuth = false + if m.loader == nil { + m.loading = false + return m, nil + } + return m, m.beginList(nil) + case listedMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.page = msg.page + m.cursor = clampCursor(m.cursor, len(m.page.Items)) + m.mode = modeList + } + return m, nil + case detailMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.detail = msg.detail + m.mode = modeDetail + m.actionCursor = 0 + } + return m, nil + case opDoneMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.mode = modeResult + if msg.err != nil { + m.result = "failed" + m.err = msg.err + m.opLog = []string{msg.err.Error()} + return m, nil + } + m.result = "ok" + m.err = nil + m.opLog = []string{ + "PR ID " + msg.created.ID, + "URL " + loCoalesce(msg.created.URL, "—"), + "Title " + loCoalesce(msg.created.Title, "—"), + "Status " + loCoalesce(msg.created.Status, "—"), + "Ref " + loCoalesce(msg.created.Ref, "—"), + } + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + switch m.mode { + case modeFilter: + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + case modeCreateForm, modeTriggerForm, modeCLITip: + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + text := key.Text + if text == "" && key.Code > 0 && key.Code < 128 { + text = string(rune(key.Code)) + } + + switch m.mode { + case modeFilter: + return m.updateFilter(action, key) + case modeCreateForm, modeTriggerForm: + return m.updateForm(action, key) + case modeCLITip: + return m.updateCLITip(action, key) + case modeReview: + return m.updateReview(action) + case modeResult: + return m.updateResult(action) + case modeOperating: + return m, nil + case modeDetail: + return m.updateDetail(action, text) + default: + return m.updateList(action) + } +} + +func (m Model) updateFilter(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.mode = modeList + m.filterInput.Blur() + return m, nil + case keyActionConfirm: + m.filter = strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + m.mode = modeList + m.cursor = 0 + m.after = nil + m.prevCursors = nil + return m, m.beginList(nil) + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd +} + +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + if action == keyActionBack { + return m, navigation.Navigate(navigation.Deployments) + } + if m.loading { + return m, nil + } + if m.needsAuth && action == keyActionConnectConsole { + return m, navigation.Navigate(navigation.Access) + } + switch action { + case keyActionMoveUp: + m.cursor = clampCursor(m.cursor-1, len(m.page.Items)) + case keyActionMoveDown: + m.cursor = clampCursor(m.cursor+1, len(m.page.Items)) + case keyActionConfirm: + if len(m.page.Items) == 0 { + return m, nil + } + m.listCursor = m.cursor + m.listAfter = m.after + m.listFilter = m.filter + m.listPrev = append([]string(nil), m.prevCursors...) + m.detailID = m.page.Items[m.cursor].ID + return m, m.beginDetail(m.detailID) + case keyActionRefresh: + return m, m.beginList(m.after) + case keyActionFilter: + m.mode = modeFilter + m.filterInput.SetValue(m.filter) + m.filterInput.Focus() + case keyActionNextPage: + if !m.page.HasNext || m.page.EndCursor == "" { + return m, nil + } + if m.after != nil { + m.prevCursors = append(m.prevCursors, *m.after) + } else { + m.prevCursors = append(m.prevCursors, "") + } + cursor := m.page.EndCursor + m.after = &cursor + m.cursor = 0 + return m, m.beginList(m.after) + case keyActionPrevPage: + if len(m.prevCursors) == 0 { + return m, nil + } + previous := m.prevCursors[len(m.prevCursors)-1] + m.prevCursors = m.prevCursors[:len(m.prevCursors)-1] + if previous == "" { + m.after = nil + } else { + m.after = &previous + } + m.cursor = 0 + return m, m.beginList(m.after) + } + return m, nil +} + +func (m Model) updateDetail(action keyAction, text string) (Model, tea.Cmd) { + if action == keyActionBack { + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.after = m.listAfter + m.filter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) + return m, nil + } + if m.loading { + return m, nil + } + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + actions := detailActions() + for i, a := range actions { + if text == a.shortcut { + m.actionCursor = i + return m.openAction(a) + } + } + switch action { + case keyActionMoveUp: + m.actionCursor = clampCursor(m.actionCursor-1, len(actions)) + case keyActionMoveDown: + m.actionCursor = clampCursor(m.actionCursor+1, len(actions)) + case keyActionConfirm: + return m.openAction(actions[m.actionCursor]) + } + return m, nil +} + +func (m Model) openAction(a detailAction) (Model, tea.Cmd) { + switch a.kind { + case actionCreate: + return m.beginCreateForm(), nil + case actionTrigger: + return m.beginTriggerForm(), nil + case actionTemplate, actionTest, actionContracts: + m.cliKind = a.kind + m.mode = modeCLITip + m.formInput.SetValue("./automation.yaml") + m.formInput.Placeholder = "path to file" + m.formInput.Focus() + m.err = nil + return m, nil + } + return m, nil +} + +func (m Model) beginCreateForm() Model { + m.mode = modeCreateForm + m.formFields = []formField{ + {label: "Branch", key: "branch"}, + {label: "Context JSON", key: "context"}, + } + m.formIndex = 0 + m.formValues = map[string]string{} + m.formInput.SetValue("") + m.formInput.Placeholder = "optional branch" + m.formInput.Focus() + m.err = nil + return m +} + +func (m Model) beginTriggerForm() Model { + m.mode = modeTriggerForm + m.formFields = []formField{ + {label: "Branch", key: "branch"}, + {label: "Configuration", key: "configuration"}, + } + m.formIndex = 0 + m.formValues = map[string]string{} + m.formInput.SetValue("") + m.formInput.Placeholder = "optional branch" + m.formInput.Focus() + m.err = nil + return m +} + +func (m Model) updateForm(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.formInput.Blur() + m.mode = modeDetail + m.err = nil + return m, nil + case keyActionConfirm: + m.saveFormField() + if m.formIndex < len(m.formFields)-1 { + m.formIndex++ + m.loadFormField() + return m, nil + } + return m.submitForm() + case keyActionMoveDown: + m.saveFormField() + if m.formIndex < len(m.formFields)-1 { + m.formIndex++ + m.loadFormField() + } + return m, nil + case keyActionMoveUp: + m.saveFormField() + if m.formIndex > 0 { + m.formIndex-- + m.loadFormField() + } + return m, nil + } + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd +} + +func (m *Model) saveFormField() { + if m.formValues == nil { + m.formValues = map[string]string{} + } + if m.formIndex >= 0 && m.formIndex < len(m.formFields) { + m.formValues[m.formFields[m.formIndex].key] = strings.TrimSpace(m.formInput.Value()) + } +} + +func (m *Model) loadFormField() { + if m.formIndex < 0 || m.formIndex >= len(m.formFields) { + return + } + field := m.formFields[m.formIndex] + m.formInput.SetValue(m.formValues[field.key]) + switch field.key { + case "context": + m.formInput.Placeholder = `optional JSON, e.g. {"cluster":"demo"}` + case "configuration": + m.formInput.Placeholder = "key=value pairs, e.g. cluster=demo region=us-east-1" + default: + m.formInput.Placeholder = field.label + } + m.formInput.Focus() +} + +func (m Model) submitForm() (Model, tea.Cmd) { + m.formInput.Blur() + switch m.mode { + case modeCreateForm: + input := pullrequestsbridge.CreatePRInput{ + AutomationID: m.detail.ID, + Branch: m.formValues["branch"], + Context: m.formValues["context"], + } + m.pending = m.createPlan(input) + m.mode = modeReview + m.err = nil + return m, nil + case modeTriggerForm: + cfg, err := parseConfiguration(m.formValues["configuration"]) + if err != nil { + m.err = err + m.formIndex = 1 + m.loadFormField() + return m, nil + } + input := pullrequestsbridge.TriggerPRInput{ + AutomationID: m.detail.ID, + Name: m.detail.Name, + Branch: m.formValues["branch"], + Configuration: cfg, + } + m.pending = m.triggerPlan(input) + m.mode = modeReview + m.err = nil + return m, nil + } + return m, nil +} + +func (m Model) updateCLITip(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.formInput.Blur() + m.mode = modeDetail + return m, nil + case keyActionConfirm: + m.formInput.Blur() + m.pending = cliTipPlan(m.cliKind, m.detail.Name, m.formInput.Value()) + m.mode = modeResult + m.result = "ok" + m.opLog = append([]string{}, m.pending.lines...) + m.opLog = append(m.opLog, "", "Equivalent CLI", " "+m.pending.cli) + return m, nil + } + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd +} + +func (m Model) updateReview(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.mode = modeDetail + return m, nil + case keyActionConfirm: + return m, m.beginPending() + } + return m, nil +} + +func (m Model) updateResult(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.mode = modeDetail + return m, nil + case keyActionConfirm: + if m.result == "failed" { + m.mode = modeReview + return m, nil + } + m.mode = modeDetail + return m, nil + } + return m, nil +} + +func clampCursor(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} diff --git a/tui/screens/pullrequests/model_test.go b/tui/screens/pullrequests/model_test.go new file mode 100644 index 000000000..faab1b69a --- /dev/null +++ b/tui/screens/pullrequests/model_test.go @@ -0,0 +1,292 @@ +package pullrequests + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/pkg/bridge" + pullrequestsbridge "github.com/pluralsh/plural-cli/pkg/bridge/pullrequests" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + page pullrequestsbridge.Page + detail pullrequestsbridge.Detail + created pullrequestsbridge.CreatedPR + err error + createErr error +} + +func (f *fakeLoader) List(context.Context, *string, string) (pullrequestsbridge.Page, error) { + return f.page, f.err +} +func (f *fakeLoader) Get(context.Context, string) (pullrequestsbridge.Detail, error) { + return f.detail, f.err +} +func (f *fakeLoader) CreatePR(context.Context, pullrequestsbridge.CreatePRInput) (pullrequestsbridge.CreatedPR, error) { + return f.created, f.createErr +} +func (f *fakeLoader) TriggerPR(context.Context, pullrequestsbridge.TriggerPRInput) (pullrequestsbridge.CreatedPR, error) { + return f.created, f.createErr +} + +func loadList(t *testing.T, model Model) Model { + t.Helper() + cmd := model.Init() + model, cmd = model.Update(cmd()) + if cmd == nil { + t.Fatal("expected list command") + } + model, _ = model.Update(cmd()) + return model +} + +func loadDetail(t *testing.T, loader *fakeLoader) Model { + t.Helper() + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail { + t.Fatalf("mode = %d", model.mode) + } + return model +} + +func TestOpenAutomationDetailAndBack(t *testing.T) { + loader := &fakeLoader{ + page: pullrequestsbridge.Page{Items: []pullrequestsbridge.Summary{ + {ID: "pra1", Name: "cluster-create", Title: "Create cluster", Addon: "cluster"}, + {ID: "pra2", Name: "service-bump", Title: "Bump service"}, + }}, + detail: pullrequestsbridge.Detail{ + Summary: pullrequestsbridge.Summary{ID: "pra1", Name: "cluster-create", Title: "Create cluster", Addon: "cluster", Identifier: "ops/cluster-create"}, + Message: "Opens a PR to provision a new cluster", + }, + } + model := loadDetail(t, loader) + if !strings.Contains(model.View(80, 24), "cluster-create") { + t.Fatalf("detail missing name:\n%s", model.View(80, 24)) + } + if !strings.Contains(model.View(80, 24), "Create") || !strings.Contains(model.View(80, 24), "Trigger") { + t.Fatalf("detail missing actions:\n%s", model.View(80, 24)) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeList { + t.Fatalf("mode after detail esc = %d", model.mode) + } + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Deployments}) { + t.Fatalf("expected deployments navigation") + } +} + +func TestCreatePRFlow(t *testing.T) { + loader := &fakeLoader{ + page: pullrequestsbridge.Page{Items: []pullrequestsbridge.Summary{ + {ID: "pra1", Name: "cluster-create", Title: "Create cluster", Addon: "cluster"}, + }}, + detail: pullrequestsbridge.Detail{ + Summary: pullrequestsbridge.Summary{ID: "pra1", Name: "cluster-create", Title: "Create cluster"}, + }, + created: pullrequestsbridge.CreatedPR{ID: "pr1", URL: "https://github.com/acme/fleet/pull/1", Title: "Create cluster", Status: "OPEN"}, + } + model := loadDetail(t, loader) + model, _ = model.Update(tea.KeyPressMsg{Code: 'c', Text: "c"}) + if model.mode != modeCreateForm { + t.Fatalf("mode = %d", model.mode) + } + model.formInput.SetValue("feat/cluster") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // next field + model.formInput.SetValue(`{"cluster":"demo"}`) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // review + if model.mode != modeReview || model.pending.create == nil { + t.Fatalf("review = mode=%d pending=%#v", model.mode, model.pending) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeResult || model.result != "ok" || !strings.Contains(strings.Join(model.opLog, "\n"), "pr1") { + t.Fatalf("result = mode=%d result=%s log=%v", model.mode, model.result, model.opLog) + } +} + +func TestTriggerPRFlow(t *testing.T) { + loader := &fakeLoader{ + page: pullrequestsbridge.Page{Items: []pullrequestsbridge.Summary{ + {ID: "pra1", Name: "cluster-create"}, + }}, + detail: pullrequestsbridge.Detail{Summary: pullrequestsbridge.Summary{ID: "pra1", Name: "cluster-create"}}, + created: pullrequestsbridge.CreatedPR{ID: "pr2", URL: "https://github.com/acme/fleet/pull/2"}, + } + model := loadDetail(t, loader) + model, _ = model.Update(tea.KeyPressMsg{Code: 't', Text: "t"}) + if model.mode != modeTriggerForm { + t.Fatalf("mode = %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // skip branch + model.formInput.SetValue("cluster=demo") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeReview || model.pending.trigger == nil || model.pending.trigger.Configuration["cluster"] != "demo" { + t.Fatalf("review = %#v", model.pending) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeResult || model.result != "ok" { + t.Fatalf("result = mode=%d result=%s", model.mode, model.result) + } +} + +func TestCLITipTemplate(t *testing.T) { + loader := &fakeLoader{ + page: pullrequestsbridge.Page{Items: []pullrequestsbridge.Summary{{ID: "pra1", Name: "cluster-create"}}}, + detail: pullrequestsbridge.Detail{Summary: pullrequestsbridge.Summary{ID: "pra1", Name: "cluster-create"}}, + } + model := loadDetail(t, loader) + model, _ = model.Update(tea.KeyPressMsg{Code: 'm', Text: "m"}) + if model.mode != modeCLITip { + t.Fatalf("mode = %d", model.mode) + } + model.formInput.SetValue("./pra.yaml") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeResult || !strings.Contains(model.pending.cli, "plural pr template") { + t.Fatalf("cli tip = %#v log=%v", model.pending, model.opLog) + } +} + +func TestNextPrevPage(t *testing.T) { + loader := &fakeLoader{ + page: pullrequestsbridge.Page{ + Items: []pullrequestsbridge.Summary{{ID: "pra1", Name: "a", Addon: "cluster"}}, + EndCursor: "pra1", + HasNext: true, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'n'}) + if cmd == nil { + t.Fatal("expected next-page list command") + } + loader.page = pullrequestsbridge.Page{Items: []pullrequestsbridge.Summary{{ID: "pra2", Name: "b"}}} + model, _ = model.Update(cmd()) + if model.after == nil || *model.after != "pra1" { + t.Fatalf("after=%v", model.after) + } +} + +func TestNoConsoleNavigatesToAccess(t *testing.T) { + loader := &fakeLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !model.needsAuth { + t.Fatal("expected needsAuth") + } + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c'}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Access}) { + t.Fatalf("expected access navigation") + } +} + +func TestPullRequestsGoldens(t *testing.T) { + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = pullrequestsbridge.Page{Items: []pullrequestsbridge.Summary{ + {ID: "pra1", Name: "cluster-create", Title: "Create cluster", Addon: "cluster", Identifier: "ops/cluster-create"}, + {ID: "pra2", Name: "service-bump", Title: "Bump service chart", Addon: "service"}, + {ID: "pra3", Name: "stack-plan", Title: "Stack plan PR"}, + }, HasNext: true, EndCursor: "pra3"} + + detail := list + detail.mode = modeDetail + detail.detail = pullrequestsbridge.Detail{ + Summary: pullrequestsbridge.Summary{ID: "pra1", Name: "cluster-create", Title: "Create cluster", Addon: "cluster", Identifier: "ops/cluster-create"}, + Message: "Opens a PR to provision a new cluster", + } + + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeView(tc.model.View(tc.width, tc.height)) + golden := filepath.Join("testdata", "pullrequests-"+tc.name+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + lines := strings.Split(got, "\n") + if len(lines) != tc.height { + t.Fatalf("height = %d, want %d", len(lines), tc.height) + } + for _, line := range lines { + if w := lipgloss.Width(line); w > tc.width { + t.Fatalf("line width %d > %d: %q", w, tc.width, line) + } + } + }) + } +} + +func TestWritePullRequestsGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = pullrequestsbridge.Page{Items: []pullrequestsbridge.Summary{ + {ID: "pra1", Name: "cluster-create", Title: "Create cluster", Addon: "cluster", Identifier: "ops/cluster-create"}, + {ID: "pra2", Name: "service-bump", Title: "Bump service chart", Addon: "service"}, + {ID: "pra3", Name: "stack-plan", Title: "Stack plan PR"}, + }, HasNext: true, EndCursor: "pra3"} + detail := list + detail.mode = modeDetail + detail.detail = pullrequestsbridge.Detail{ + Summary: pullrequestsbridge.Summary{ID: "pra1", Name: "cluster-create", Title: "Create cluster", Addon: "cluster", Identifier: "ops/cluster-create"}, + Message: "Opens a PR to provision a new cluster", + } + _ = os.MkdirAll("testdata", 0o755) + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + got := normalizeView(tc.model.View(tc.width, tc.height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "pullrequests-"+tc.name+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/pullrequests/testdata/pullrequests-detail-120.golden b/tui/screens/pullrequests/testdata/pullrequests-detail-120.golden new file mode 100644 index 000000000..9501ebf85 --- /dev/null +++ b/tui/screens/pullrequests/testdata/pullrequests-detail-120.golden @@ -0,0 +1,30 @@ + Plural Pull requests · cluster-create cluster + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ Summary ────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Name cluster-create │ + │ Title Create cluster │ + │ Addon cluster │ + │ Identifier ops/cluster-create │ + │ ID pra1 │ + │ Message Opens a PR to provision a new cluster │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ╭─ › Actions ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ › c Create open PR from automation │ + │ t Trigger name · configuration · branch │ + │ m Template local file · apply tree CLI │ + │ e Test local CRD CLI │ + │ o Contracts contract suite CLI │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + ↑/↓ actions · enter · c t m e o · r refresh · esc list diff --git a/tui/screens/pullrequests/testdata/pullrequests-detail-80.golden b/tui/screens/pullrequests/testdata/pullrequests-detail-80.golden new file mode 100644 index 000000000..87311fb3a --- /dev/null +++ b/tui/screens/pullrequests/testdata/pullrequests-detail-80.golden @@ -0,0 +1,24 @@ + Plural Pull requests · cluster-create cluster + ──────────────────────────────────────────────────────────────────────────── + + ╭─ Summary ────────────────────────────────────────────────────────────────╮ + │ Name cluster-create │ + │ Title Create cluster │ + │ Addon cluster │ + │ Identifier ops/cluster-create │ + │ ID pra1 │ + │ Message Opens a PR to provision a new cluster │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + ╭─ › Actions ──────────────────────────────────────────────────────────────╮ + │ › c Create open PR from automation │ + │ t Trigger name · configuration · branch │ + │ m Template local file · apply tree CLI │ + │ e Test local CRD CLI │ + │ o Contracts contract suite CLI │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + ↑/↓ · enter · letters · r · esc diff --git a/tui/screens/pullrequests/testdata/pullrequests-list-120.golden b/tui/screens/pullrequests/testdata/pullrequests-list-120.golden new file mode 100644 index 000000000..3cb4a3579 --- /dev/null +++ b/tui/screens/pullrequests/testdata/pullrequests-list-120.golden @@ -0,0 +1,30 @@ + Plural Pull requests 3 automations + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › PR automations ───────────────────────────────────────────────────────────────────────────────────────────────╮ + │ NAME ADDON TITLE │ + │ › cluster-create cluster Create cluster │ + │ service-bump service Bump service chart │ + │ stack-plan — Stack plan PR │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · enter open · / filter · n/p page · r refresh · esc back diff --git a/tui/screens/pullrequests/testdata/pullrequests-list-80.golden b/tui/screens/pullrequests/testdata/pullrequests-list-80.golden new file mode 100644 index 000000000..c0c1cf369 --- /dev/null +++ b/tui/screens/pullrequests/testdata/pullrequests-list-80.golden @@ -0,0 +1,24 @@ + Plural Pull requests 3 automations + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › PR automations ───────────────────────────────────────────────────────╮ + │ NAME ADDON TITLE │ + │ › cluster-create cluster Create cluster │ + │ service-bump service Bump service chart │ + │ stack-plan — Stack plan PR │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / · n/p page · esc back diff --git a/tui/screens/pullrequests/view.go b/tui/screens/pullrequests/view.go new file mode 100644 index 000000000..cb0ee2363 --- /dev/null +++ b/tui/screens/pullrequests/view.go @@ -0,0 +1,297 @@ +package pullrequests + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + title := "Pull requests" + if m.mode == modeDetail && m.detail.Name != "" { + title = "Pull requests · " + m.detail.Name + } + if m.mode == modeReview || m.mode == modeOperating || m.mode == modeResult { + title = m.pending.title + if title == "" { + title = "Pull requests" + } + } + body, help := m.bodyAndHelp(contentWidth) + return page.Render(m.theme, width, height, title, m.headerStatus(), body, help) +} + +func (m Model) headerStatus() string { + if m.loading || m.mode == modeOperating { + return m.theme.Warning.Render("◌ loading") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + if m.err != nil && m.mode != modeCreateForm && m.mode != modeTriggerForm { + return m.theme.Danger.Render("✗ load failed") + } + switch m.mode { + case modeDetail: + return m.theme.Success.Render(loCoalesce(m.detail.Addon, "automation")) + case modeList: + if m.filter != "" { + return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.page.Items))) + } + return m.theme.Success.Render(fmt.Sprintf("%d automations", len(m.page.Items))) + case modeReview: + return m.theme.Warning.Render("review") + case modeResult: + if m.result == "failed" { + return m.theme.Danger.Render("failed") + } + return m.theme.Success.Render("done") + default: + return m.theme.Muted.Render("pull requests") + } +} + +func (m Model) bodyAndHelp(width int) (string, string) { + if m.mode == modeFilter { + lines := []string{ + m.theme.Muted.Render("Filter by name, title, addon, identifier, or id."), + "", + m.filterInput.View(), + } + return page.Panel(m.theme, "Filter PR automations", lines, width, 6, true), "enter apply · esc cancel" + } + if m.needsAuth { + lines := []string{ + m.theme.Warning.Render("○ Console is not connected"), + m.theme.Muted.Render(" Connect a Console profile to browse PR automations."), + "", + m.theme.Body.Render("Press c to open Access."), + } + return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" + } + switch m.mode { + case modeReview: + lines := append([]string{}, m.pending.lines...) + lines = append(lines, "", m.theme.Muted.Render("Equivalent CLI"), " "+m.pending.cli) + return page.Panel(m.theme, "Plan (immutable)", lines, width, 12, true), "enter confirm · esc back" + case modeOperating: + lines := []string{m.theme.Warning.Render("● Running…"), ""} + lines = append(lines, m.opLog...) + return page.Panel(m.theme, "Operation", lines, width, 10, true), "ctrl+c quit" + case modeResult: + head := m.theme.Success.Render("✓ Success") + help := "enter detail · esc detail" + if m.result == "failed" { + head = m.theme.Danger.Render("✗ Failed") + help = "enter retry review · esc detail" + } else if m.pending.kind == actionTemplate || m.pending.kind == actionTest || m.pending.kind == actionContracts { + head = m.theme.Muted.Render("CLI equivalent") + help = "esc detail" + } + lines := []string{head, ""} + lines = append(lines, m.opLog...) + return page.Panel(m.theme, "Result", lines, width, 12, true), help + case modeCreateForm, modeTriggerForm: + return m.formView(width) + case modeCLITip: + kind := "Template" + switch m.cliKind { + case actionTest: + kind = "Test" + case actionContracts: + kind = "Contracts" + } + lines := []string{ + m.theme.Muted.Render(kind + " runs locally via the Plural CLI."), + "", + "File " + m.formInput.View(), + "", + m.theme.Muted.Render("Enter shows the CLI equivalent."), + } + return page.Panel(m.theme, kind+" · CLI", lines, width, 8, true), "enter · esc detail" + case modeDetail: + summary := page.Panel(m.theme, "Summary", m.detailLines(), width, 9, false) + actions := page.Panel(m.theme, "Actions", m.actionLines(width), width, 8, true) + help := "↑/↓ actions · enter · c t m e o · r refresh · esc list" + if width < 100 { + help = "↑/↓ · enter · letters · r · esc" + } + return summary + "\n\n" + actions, help + default: + help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc back" + if width < 100 { + help = "↑/↓ · enter · / · n/p page · esc back" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help + } +} + +func (m Model) formView(width int) (string, string) { + title := "Create pull request" + if m.mode == modeTriggerForm { + title = "Trigger PR automation" + } + lines := []string{ + "Automation " + m.detail.Name, + "", + } + for i, field := range m.formFields { + cursor := " " + if i == m.formIndex { + cursor = "› " + lines = append(lines, cursor+field.label) + lines = append(lines, " "+m.formInput.View()) + continue + } + val := loCoalesce(m.formValues[field.key], "—") + lines = append(lines, cursor+field.label+" "+m.theme.Muted.Render(truncate(val, max(8, width-20)))) + } + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + return page.Panel(m.theme, title, lines, width, 12, true), "↑/↓ fields · enter next/review · esc cancel" +} + +func (m Model) listTitle() string { + if m.filter != "" { + return "PR automations · filter “" + m.filter + "”" + } + return "PR automations" +} + +func (m Model) listLines(width int) []string { + if m.loading && len(m.page.Items) == 0 { + return []string{m.theme.Warning.Render("◌ Loading PR automations…")} + } + if m.err != nil { + return []string{ + m.theme.Danger.Render("✗ Unable to load PR automations"), + m.theme.Danger.Render("Error " + m.err.Error()), + m.theme.Muted.Render("Press r to retry."), + } + } + if len(m.page.Items) == 0 { + return []string{ + m.theme.Warning.Render("○ No PR automations found"), + m.theme.Muted.Render(" Adjust the filter or connect another Console."), + } + } + nameWidth := max(12, min(22, width/3)) + addonWidth := max(6, min(12, width/6)) + lines := []string{m.theme.Muted.Render(" " + pad("NAME", nameWidth) + " " + pad("ADDON", addonWidth) + " TITLE")} + start, end := visibleWindow(m.cursor, len(m.page.Items), 8) + for i := start; i < end; i++ { + item := m.page.Items[i] + cursor := " " + if i == m.cursor { + cursor = "› " + } + row := cursor + pad(item.Name, nameWidth) + " " + pad(loCoalesce(item.Addon, "—"), addonWidth) + " " + loCoalesce(item.Title, "—") + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + if start > 0 || end < len(m.page.Items) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" … %d–%d of %d", start+1, end, len(m.page.Items)))) + } + if m.page.HasNext || len(m.prevCursors) > 0 { + pager := "page" + if len(m.prevCursors) > 0 { + pager += " · p prev" + } + if m.page.HasNext { + pager += " · n next" + } + lines = append(lines, "", m.theme.Muted.Render(pager)) + } + return lines +} + +func (m Model) actionLines(width int) []string { + actions := detailActions() + lines := make([]string, 0, len(actions)) + for i, a := range actions { + cursor := " " + if i == m.actionCursor { + cursor = "› " + } + suffix := "" + if a.cliOnly { + suffix = " " + m.theme.Muted.Render("CLI") + } + row := cursor + a.shortcut + " " + pad(a.title, 12) + " " + a.blurb + suffix + lines = append(lines, ansi.Truncate(row, max(1, width-2), "…")) + } + return lines +} + +func visibleWindow(cursor, count, size int) (start, end int) { + if count <= 0 { + return 0, 0 + } + if size <= 0 { + size = count + } + if count <= size { + return 0, count + } + start = cursor - size/2 + if start < 0 { + start = 0 + } + end = start + size + if end > count { + end = count + start = end - size + } + return start, end +} + +func (m Model) detailLines() []string { + if m.loading { + return []string{m.theme.Warning.Render("◌ Loading PR automation detail…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load PR automation"), m.theme.Danger.Render(m.err.Error())} + } + lines := []string{ + m.labelValue("Name", m.detail.Name), + m.labelValue("Title", loCoalesce(m.detail.Title, "—")), + m.labelValue("Addon", loCoalesce(m.detail.Addon, "—")), + m.labelValue("Identifier", loCoalesce(m.detail.Identifier, "—")), + m.labelValue("ID", m.detail.ID), + } + if m.detail.Message != "" { + lines = append(lines, m.labelValue("Message", m.detail.Message)) + } + return lines +} + +func (m Model) labelValue(label, value string) string { + label = label + strings.Repeat(" ", max(1, 12-len(label))) + return label + " " + value +} + +func pad(value string, width int) string { + value = ansi.Truncate(value, width, "…") + if lipgloss.Width(value) >= width { + return value + } + return value + strings.Repeat(" ", width-lipgloss.Width(value)) +} + +func loCoalesce(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} diff --git a/tui/screens/stacks/actions.go b/tui/screens/stacks/actions.go new file mode 100644 index 000000000..05bafe400 --- /dev/null +++ b/tui/screens/stacks/actions.go @@ -0,0 +1,93 @@ +package stacks + +import ( + "strings" + + stacksbridge "github.com/pluralsh/plural-cli/pkg/bridge/stacks" +) + +type actionKind uint8 + +const ( + actionGenBackend actionKind = iota +) + +type detailAction struct { + kind actionKind + shortcut string + title string + blurb string +} + +func detailActions() []detailAction { + return []detailAction{ + {kind: actionGenBackend, shortcut: "g", title: "Gen-backend", blurb: "write _override.tf · terraform backend"}, + } +} + +type pendingOp struct { + kind actionKind + title string + cli string + lines []string + backend *stacksbridge.GenBackendInput +} + +func (m Model) genBackendPlan(input stacksbridge.GenBackendInput) pendingOp { + d := m.detail + dir := loCoalesce(strings.TrimSpace(input.Dir), ".") + cli := "plural stacks gen-backend" + if input.Address != "" { + cli += " --address " + shellQuote(input.Address) + } + if input.LockAddress != "" { + cli += " --lock-address " + shellQuote(input.LockAddress) + } + if input.UnlockAddress != "" { + cli += " --unlock-address " + shellQuote(input.UnlockAddress) + } + lines := []string{ + "Action Generate terraform backend override", + "Stack " + d.Name, + "ID " + d.ID, + "Dir " + dir, + "File _override.tf", + "", + "Writes _override.tf and appends it to .gitignore.", + "Uses Console state URLs + your deploy token.", + } + if input.Address != "" || input.LockAddress != "" || input.UnlockAddress != "" { + lines = append(lines, + "", + "Address "+loCoalesce(input.Address, "(from Console)"), + "Lock "+loCoalesce(input.LockAddress, "(from Console)"), + "Unlock "+loCoalesce(input.UnlockAddress, "(from Console)"), + ) + } + return pendingOp{ + kind: actionGenBackend, + title: "Gen-backend · " + d.Name, + cli: cli, + backend: &input, + lines: lines, + } +} + +func shellQuote(v string) string { + if v == "" { + return `""` + } + if strings.ContainsAny(v, " \t\"'") { + return `"` + strings.ReplaceAll(v, `"`, `\"`) + `"` + } + return v +} + +func formatResult(result stacksbridge.GenBackendResult) []string { + return []string{ + "Wrote " + result.FilePath, + "Directory " + result.Dir, + "", + "Added _override.tf to .gitignore in that directory.", + } +} diff --git a/tui/screens/stacks/model.go b/tui/screens/stacks/model.go index 0b82df6f4..72bac68f1 100644 --- a/tui/screens/stacks/model.go +++ b/tui/screens/stacks/model.go @@ -1,8 +1,9 @@ -// Package stacks implements the read-only Console infrastructure stacks browser. +// Package stacks implements the Console infrastructure stacks browser and actions. package stacks import ( "context" + "fmt" "strings" "charm.land/bubbles/v2/textinput" @@ -20,6 +21,10 @@ const ( modeList mode = iota modeDetail modeFilter + modeGenBackendForm + modeReview + modeOperating + modeResult ) type keyAction uint8 @@ -60,6 +65,11 @@ func actionForKeystroke(keystroke string) keyAction { return keyActionNone } +type formField struct { + label string + key string +} + type initMsg struct{} type listedMsg struct { page stacksbridge.Page @@ -71,6 +81,11 @@ type detailMsg struct { err error request uint64 } +type opDoneMsg struct { + err error + result stacksbridge.GenBackendResult + request uint64 +} // Model owns Stacks-screen interaction state. type Model struct { @@ -90,26 +105,38 @@ type Model struct { after *string prevCursors []string - detail stacksbridge.Detail - detailID string - listCursor int - listAfter *string - listFilter string - listPrev []string + detail stacksbridge.Detail + detailID string + listCursor int + listAfter *string + listFilter string + listPrev []string + actionCursor int + + formInput textinput.Model + formFields []formField + formIndex int + formValues map[string]string + + pending pendingOp + opLog []string + result string } func New(ctx context.Context, loader stacksbridge.Loader, t theme.Theme) Model { input := textinput.New() input.Prompt = "› " input.Placeholder = "filter stacks" - input.CharLimit = 128 + input.CharLimit = 256 styles := textinput.DefaultDarkStyles() styles.Focused.Text = t.Body styles.Focused.Prompt = t.Title styles.Focused.Placeholder = t.Muted styles.Blurred = styles.Focused input.SetStyles(styles) - return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, mode: modeList} + form := input + form.Placeholder = "" + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, formInput: form, mode: modeList} } func (m Model) Init() tea.Cmd { @@ -141,6 +168,25 @@ func (m *Model) beginDetail(id string) tea.Cmd { } } +func (m *Model) beginPending() tea.Cmd { + m.mode = modeOperating + m.loading = true + m.err = nil + m.opLog = []string{"starting…"} + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + op := m.pending + return func() tea.Msg { + if op.backend == nil { + return opDoneMsg{err: fmt.Errorf("missing gen-backend input"), request: request} + } + result, err := loader.GenBackend(ctx, *op.backend) + return opDoneMsg{err: err, result: result, request: request} + } +} + func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case initMsg: @@ -179,50 +225,63 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if msg.err == nil { m.detail = msg.detail m.mode = modeDetail + m.actionCursor = 0 } return m, nil + case opDoneMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.mode = modeResult + if msg.err != nil { + m.result = "failed" + m.err = msg.err + m.opLog = []string{msg.err.Error()} + return m, nil + } + m.result = "ok" + m.err = nil + m.opLog = formatResult(msg.result) + return m, nil case tea.KeyPressMsg: return m.updateKey(msg) } - if m.mode == modeFilter { + switch m.mode { + case modeFilter: var cmd tea.Cmd m.filterInput, cmd = m.filterInput.Update(msg) return m, cmd + case modeGenBackendForm: + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(msg) + return m, cmd } return m, nil } func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { action := actionForKeystroke(key.Keystroke()) - if m.mode == modeFilter { - switch action { - case keyActionBack: - m.mode = modeList - m.filterInput.Blur() - return m, nil - case keyActionConfirm: - m.filter = strings.TrimSpace(m.filterInput.Value()) - m.filterInput.Blur() - m.mode = modeList - m.cursor = 0 - m.after = nil - m.prevCursors = nil - return m, m.beginList(nil) - } - var cmd tea.Cmd - m.filterInput, cmd = m.filterInput.Update(key) - return m, cmd + text := key.Text + if text == "" && key.Code > 0 && key.Code < 128 { + text = string(rune(key.Code)) + } + + switch m.mode { + case modeFilter: + return m.updateFilter(action, key) + case modeGenBackendForm: + return m.updateForm(action, key) + case modeReview: + return m.updateReview(action) + case modeResult: + return m.updateResult(action) + case modeOperating: + return m, nil + case modeDetail: + return m.updateDetail(action, text) } if action == keyActionBack { - if m.mode == modeDetail { - m.mode = modeList - m.err = nil - m.cursor = m.listCursor - m.after = m.listAfter - m.filter = m.listFilter - m.prevCursors = append([]string(nil), m.listPrev...) - return m, nil - } return m, navigation.Navigate(navigation.Deployments) } if m.loading { @@ -231,13 +290,192 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { if m.needsAuth && action == keyActionConnectConsole { return m, navigation.Navigate(navigation.Access) } - if m.mode == modeDetail { - if action == keyActionRefresh && m.detailID != "" { - return m, m.beginDetail(m.detailID) + return m.updateList(action) +} + +func (m Model) updateFilter(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.mode = modeList + m.filterInput.Blur() + return m, nil + case keyActionConfirm: + m.filter = strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + m.mode = modeList + m.cursor = 0 + m.after = nil + m.prevCursors = nil + return m, m.beginList(nil) + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd +} + +func (m Model) updateDetail(action keyAction, text string) (Model, tea.Cmd) { + if action == keyActionBack { + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.after = m.listAfter + m.filter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) + return m, nil + } + if m.loading { + return m, nil + } + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + actions := detailActions() + for i, a := range actions { + if text == a.shortcut { + m.actionCursor = i + return m.openAction(a) + } + } + switch action { + case keyActionMoveUp: + m.actionCursor = clampCursor(m.actionCursor-1, len(actions)) + case keyActionMoveDown: + m.actionCursor = clampCursor(m.actionCursor+1, len(actions)) + case keyActionConfirm: + return m.openAction(actions[m.actionCursor]) + } + return m, nil +} + +func (m Model) openAction(a detailAction) (Model, tea.Cmd) { + if a.kind == actionGenBackend { + return m.beginGenBackendForm(), nil + } + return m, nil +} + +func (m Model) beginGenBackendForm() Model { + m.mode = modeGenBackendForm + m.formFields = []formField{ + {label: "Directory", key: "dir"}, + {label: "Address", key: "address"}, + {label: "Lock address", key: "lock"}, + {label: "Unlock address", key: "unlock"}, + } + m.formIndex = 0 + m.formValues = map[string]string{"dir": "."} + m.formInput.SetValue(".") + m.formInput.Placeholder = "path for _override.tf" + m.formInput.Focus() + m.err = nil + return m +} + +func (m Model) updateForm(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.formInput.Blur() + m.mode = modeDetail + m.err = nil + return m, nil + case keyActionConfirm: + m.saveFormField() + if m.formIndex < len(m.formFields)-1 { + m.formIndex++ + m.loadFormField() + return m, nil + } + return m.submitForm() + case keyActionMoveDown: + m.saveFormField() + if m.formIndex < len(m.formFields)-1 { + m.formIndex++ + m.loadFormField() + } + return m, nil + case keyActionMoveUp: + m.saveFormField() + if m.formIndex > 0 { + m.formIndex-- + m.loadFormField() } return m, nil } - return m.updateList(action) + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd +} + +func (m *Model) saveFormField() { + if m.formValues == nil { + m.formValues = map[string]string{} + } + if m.formIndex >= 0 && m.formIndex < len(m.formFields) { + m.formValues[m.formFields[m.formIndex].key] = strings.TrimSpace(m.formInput.Value()) + } +} + +func (m *Model) loadFormField() { + if m.formIndex < 0 || m.formIndex >= len(m.formFields) { + return + } + field := m.formFields[m.formIndex] + m.formInput.SetValue(m.formValues[field.key]) + switch field.key { + case "dir": + m.formInput.Placeholder = "defaults to ." + case "address": + m.formInput.Placeholder = "optional · from Console runs if empty" + case "lock": + m.formInput.Placeholder = "optional lock URL" + case "unlock": + m.formInput.Placeholder = "optional unlock URL" + default: + m.formInput.Placeholder = field.label + } + m.formInput.Focus() +} + +func (m Model) submitForm() (Model, tea.Cmd) { + m.formInput.Blur() + input := stacksbridge.GenBackendInput{ + StackID: m.detail.ID, + Dir: loCoalesce(m.formValues["dir"], "."), + Address: m.formValues["address"], + LockAddress: m.formValues["lock"], + UnlockAddress: m.formValues["unlock"], + } + m.pending = m.genBackendPlan(input) + m.mode = modeReview + m.err = nil + return m, nil +} + +func (m Model) updateReview(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.mode = modeDetail + return m, nil + case keyActionConfirm: + return m, m.beginPending() + } + return m, nil +} + +func (m Model) updateResult(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.mode = modeDetail + return m, nil + case keyActionConfirm: + if m.result == "failed" { + m.mode = modeReview + return m, nil + } + m.mode = modeDetail + return m, nil + } + return m, nil } func (m Model) updateList(action keyAction) (Model, tea.Cmd) { diff --git a/tui/screens/stacks/model_test.go b/tui/screens/stacks/model_test.go index f348439e8..cbd742444 100644 --- a/tui/screens/stacks/model_test.go +++ b/tui/screens/stacks/model_test.go @@ -22,7 +22,9 @@ import ( type fakeLoader struct { page stacksbridge.Page detail stacksbridge.Detail + result stacksbridge.GenBackendResult err error + genErr error } func (f *fakeLoader) List(context.Context, *string, string) (stacksbridge.Page, error) { @@ -31,6 +33,15 @@ func (f *fakeLoader) List(context.Context, *string, string) (stacksbridge.Page, func (f *fakeLoader) Get(context.Context, string) (stacksbridge.Detail, error) { return f.detail, f.err } +func (f *fakeLoader) GenBackend(_ context.Context, input stacksbridge.GenBackendInput) (stacksbridge.GenBackendResult, error) { + if f.genErr != nil { + return stacksbridge.GenBackendResult{}, f.genErr + } + if f.result.FilePath == "" { + return stacksbridge.GenBackendResult{FilePath: filepath.Join(input.Dir, "_override.tf"), Dir: input.Dir}, nil + } + return f.result, nil +} func loadList(t *testing.T, model Model) Model { t.Helper() @@ -43,6 +54,17 @@ func loadList(t *testing.T, model Model) Model { return model } +func loadDetail(t *testing.T, loader *fakeLoader) Model { + t.Helper() + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail { + t.Fatalf("mode = %d", model.mode) + } + return model +} + func TestOpenStackDetailAndBack(t *testing.T) { loader := &fakeLoader{ page: stacksbridge.Page{Items: []stacksbridge.Summary{ @@ -56,33 +78,57 @@ func TestOpenStackDetailAndBack(t *testing.T) { OutputNames: []string{"cluster_name", "token (secret)"}, }, } - model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) - if model.mode != modeList || len(model.page.Items) != 2 { - t.Fatalf("list state = mode=%d count=%d", model.mode, len(model.page.Items)) - } + model := loadDetail(t, loader) if !strings.Contains(model.View(80, 24), "gke-demo") { - t.Fatalf("list missing name:\n%s", model.View(80, 24)) + t.Fatalf("detail missing name:\n%s", model.View(80, 24)) } - - model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - model, _ = model.Update(cmd()) - if model.mode != modeDetail || model.detail.Name != "gke-demo" { - t.Fatalf("detail = %#v mode=%d", model.detail, model.mode) - } - if !strings.Contains(model.View(80, 24), "TF_VAR_cluster") || !strings.Contains(model.View(80, 24), "token (secret)") { - t.Fatalf("detail missing env/outputs:\n%s", model.View(80, 24)) + if !strings.Contains(model.View(80, 24), "Gen-backend") { + t.Fatalf("detail missing actions:\n%s", model.View(80, 24)) } model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) if model.mode != modeList { t.Fatalf("mode after detail esc = %d", model.mode) } - _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Deployments}) { t.Fatalf("expected deployments navigation") } } +func TestGenBackendFlow(t *testing.T) { + loader := &fakeLoader{ + page: stacksbridge.Page{Items: []stacksbridge.Summary{ + {ID: "s1", Name: "gke-demo", Type: "TERRAFORM"}, + }}, + detail: stacksbridge.Detail{ + Summary: stacksbridge.Summary{ID: "s1", Name: "gke-demo", Type: "TERRAFORM"}, + }, + result: stacksbridge.GenBackendResult{FilePath: "/tmp/stack/_override.tf", Dir: "/tmp/stack"}, + } + model := loadDetail(t, loader) + model, _ = model.Update(tea.KeyPressMsg{Code: 'g', Text: "g"}) + if model.mode != modeGenBackendForm { + t.Fatalf("mode = %d", model.mode) + } + model.formInput.SetValue("./terraform") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // next field + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // skip address + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // skip lock + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // review + if model.mode != modeReview || model.pending.backend == nil || model.pending.backend.Dir != "./terraform" { + t.Fatalf("review = mode=%d pending=%#v", model.mode, model.pending) + } + if !strings.Contains(model.pending.cli, "plural stacks gen-backend") { + t.Fatalf("cli = %q", model.pending.cli) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeResult || model.result != "ok" || !strings.Contains(strings.Join(model.opLog, "\n"), "_override.tf") { + t.Fatalf("result = mode=%d result=%s log=%v", model.mode, model.result, model.opLog) + } +} + func TestNextPrevPage(t *testing.T) { loader := &fakeLoader{ page: stacksbridge.Page{ diff --git a/tui/screens/stacks/testdata/stacks-detail-120.golden b/tui/screens/stacks/testdata/stacks-detail-120.golden index df493369e..da959a488 100644 --- a/tui/screens/stacks/testdata/stacks-detail-120.golden +++ b/tui/screens/stacks/testdata/stacks-detail-120.golden @@ -1,7 +1,7 @@ Plural Stacks · gke-demo TERRAFORM ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - ╭─ › Summary ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + ╭─ Summary ────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Name gke-demo │ │ Type TERRAFORM │ │ Project acme │ @@ -11,13 +11,14 @@ │ Git main · terraform │ │ Workdir gke-cluster │ │ State true │ - │ Version 1.8.2 │ - │ ID s1 │ - │ Env TF_VAR_cluster │ - │ Outputs cluster_name, token (secret) │ - │ │ + │ … │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭─ › Actions ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ › g Gen-backend write _override.tf · terraform backend │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -26,5 +27,4 @@ - - r refresh · esc list · ctrl+c quit + ↑/↓ actions · enter · g · r refresh · esc list diff --git a/tui/screens/stacks/testdata/stacks-detail-80.golden b/tui/screens/stacks/testdata/stacks-detail-80.golden index 7d39a82e8..c0af0ec87 100644 --- a/tui/screens/stacks/testdata/stacks-detail-80.golden +++ b/tui/screens/stacks/testdata/stacks-detail-80.golden @@ -1,7 +1,7 @@ Plural Stacks · gke-demo TERRAFORM ──────────────────────────────────────────────────────────────────────────── - ╭─ › Summary ──────────────────────────────────────────────────────────────╮ + ╭─ Summary ────────────────────────────────────────────────────────────────╮ │ Name gke-demo │ │ Type TERRAFORM │ │ Project acme │ @@ -11,14 +11,14 @@ │ Git main · terraform │ │ Workdir gke-cluster │ │ State true │ - │ Version 1.8.2 │ - │ ID s1 │ - │ Env TF_VAR_cluster │ - │ Outputs cluster_name, token (secret) │ - │ │ + │ … │ ╰──────────────────────────────────────────────────────────────────────────╯ + ╭─ › Actions ──────────────────────────────────────────────────────────────╮ + │ › g Gen-backend write _override.tf · terraform backend │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ - - r refresh · esc list · ctrl+c quit + ↑/↓ · enter · g · r · esc diff --git a/tui/screens/stacks/view.go b/tui/screens/stacks/view.go index 51842dad6..e7d4b83a4 100644 --- a/tui/screens/stacks/view.go +++ b/tui/screens/stacks/view.go @@ -20,18 +20,24 @@ func (m Model) View(width, height int) string { if m.mode == modeDetail && m.detail.Name != "" { title = "Stacks · " + m.detail.Name } + if m.mode == modeReview || m.mode == modeOperating || m.mode == modeResult { + title = m.pending.title + if title == "" { + title = "Stacks" + } + } body, help := m.bodyAndHelp(contentWidth) return page.Render(m.theme, width, height, title, m.headerStatus(), body, help) } func (m Model) headerStatus() string { - if m.loading { + if m.loading || m.mode == modeOperating { return m.theme.Warning.Render("◌ loading") } if m.needsAuth { return m.theme.Warning.Render("○ connect Console") } - if m.err != nil { + if m.err != nil && m.mode != modeGenBackendForm { return m.theme.Danger.Render("✗ load failed") } switch m.mode { @@ -42,6 +48,13 @@ func (m Model) headerStatus() string { return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.page.Items))) } return m.theme.Success.Render(fmt.Sprintf("%d stacks", len(m.page.Items))) + case modeReview: + return m.theme.Warning.Render("review") + case modeResult: + if m.result == "failed" { + return m.theme.Danger.Render("failed") + } + return m.theme.Success.Render("done") default: return m.theme.Muted.Render("stacks") } @@ -65,15 +78,64 @@ func (m Model) bodyAndHelp(width int) (string, string) { } return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" } - if m.mode == modeDetail { - help := "r refresh · esc list · ctrl+c quit" - return page.Panel(m.theme, "Summary", m.detailLines(), width, 16, true), help + switch m.mode { + case modeReview: + lines := append([]string{}, m.pending.lines...) + lines = append(lines, "", m.theme.Muted.Render("Equivalent CLI"), " "+m.pending.cli) + return page.Panel(m.theme, "Plan (immutable)", lines, width, 14, true), "enter confirm · esc back" + case modeOperating: + lines := []string{m.theme.Warning.Render("● Running…"), ""} + lines = append(lines, m.opLog...) + return page.Panel(m.theme, "Operation", lines, width, 10, true), "ctrl+c quit" + case modeResult: + head := m.theme.Success.Render("✓ Success") + help := "enter detail · esc detail" + if m.result == "failed" { + head = m.theme.Danger.Render("✗ Failed") + help = "enter retry review · esc detail" + } + lines := []string{head, ""} + lines = append(lines, m.opLog...) + return page.Panel(m.theme, "Result", lines, width, 12, true), help + case modeGenBackendForm: + return m.formView(width) + case modeDetail: + summary := page.Panel(m.theme, "Summary", m.detailLines(), width, 12, false) + actions := page.Panel(m.theme, "Actions", m.actionLines(width), width, 5, true) + help := "↑/↓ actions · enter · g · r refresh · esc list" + if width < 100 { + help = "↑/↓ · enter · g · r · esc" + } + return summary + "\n\n" + actions, help + default: + help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc back" + if width < 100 { + help = "↑/↓ · enter · / · n/p page · esc back" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help + } +} + +func (m Model) formView(width int) (string, string) { + lines := []string{ + "Stack " + m.detail.Name, + "", + } + for i, field := range m.formFields { + cursor := " " + if i == m.formIndex { + cursor = "› " + lines = append(lines, cursor+field.label) + lines = append(lines, " "+m.formInput.View()) + continue + } + val := loCoalesce(m.formValues[field.key], "—") + lines = append(lines, cursor+field.label+" "+m.theme.Muted.Render(truncate(val, max(8, width-20)))) } - help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc back" - if width < 100 { - help = "↑/↓ · enter · / · n/p page · esc back" + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) } - return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help + return page.Panel(m.theme, "Generate backend", lines, width, 14, true), "↑/↓ fields · enter next/review · esc cancel" } func (m Model) listTitle() string { @@ -130,6 +192,20 @@ func (m Model) listLines(width int) []string { return lines } +func (m Model) actionLines(width int) []string { + actions := detailActions() + lines := make([]string, 0, len(actions)) + for i, a := range actions { + cursor := " " + if i == m.actionCursor { + cursor = "› " + } + row := cursor + a.shortcut + " " + pad(a.title, 12) + " " + a.blurb + lines = append(lines, ansi.Truncate(row, max(1, width-2), "…")) + } + return lines +} + func visibleWindow(cursor, count, size int) (start, end int) { if count <= 0 { return 0, 0 @@ -210,6 +286,13 @@ func pad(value string, width int) string { return value + strings.Repeat(" ", width-lipgloss.Width(value)) } +func truncate(v string, n int) string { + if len(v) <= n { + return v + } + return v[:n-1] + "…" +} + func loCoalesce(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { From 65793f1ffe14d78b2a6200458aeb25b81dc2222c Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 31 Jul 2026 17:42:37 +0200 Subject: [PATCH 14/16] feat: introduce AI and workbenches integration - Add AI and workbenches as new sections in TUI and implement navigation logic - Integrate agents and workbenches bridges in TUI app model - Implement list, get, and job functionalities for console workbenches - Update unit tests to cover new AI routes and navigation capabilities --- .gitignore | 2 + cmd/command/tui/tui.go | 6 + pkg/bridge/agents/agents.go | 206 ++++++++++++++ pkg/bridge/agents/agents_test.go | 50 ++++ pkg/bridge/workbenches/workbenches.go | 205 ++++++++++++++ pkg/bridge/workbenches/workbenches_test.go | 39 +++ pkg/console/console.go | 13 + pkg/console/workbenches.go | 67 +++++ pkg/test/mocks/ConsoleClient.go | 183 +++++++++--- tui/app/model.go | 25 ++ tui/app/model_test.go | 14 + tui/app/view.go | 6 + tui/navigation/navigation.go | 3 + tui/screens/agents/model.go | 239 ++++++++++++++++ tui/screens/agents/model_test.go | 38 +++ tui/screens/agents/view.go | 110 ++++++++ tui/screens/ai/model.go | 106 +++++++ tui/screens/ai/model_test.go | 28 ++ tui/screens/ai/view.go | 45 +++ tui/screens/welcome/groups.go | 4 +- tui/screens/welcome/model_test.go | 34 ++- .../welcome/testdata/welcome-120.golden | 6 +- .../welcome/testdata/welcome-80.golden | 6 +- .../welcome/testdata/welcome-popup-80.golden | 6 +- tui/screens/welcome/view.go | 4 +- tui/screens/workbenches/model.go | 263 ++++++++++++++++++ tui/screens/workbenches/model_test.go | 51 ++++ tui/screens/workbenches/view.go | 87 ++++++ 28 files changed, 1801 insertions(+), 45 deletions(-) create mode 100644 pkg/bridge/agents/agents.go create mode 100644 pkg/bridge/agents/agents_test.go create mode 100644 pkg/bridge/workbenches/workbenches.go create mode 100644 pkg/bridge/workbenches/workbenches_test.go create mode 100644 tui/screens/agents/model.go create mode 100644 tui/screens/agents/model_test.go create mode 100644 tui/screens/agents/view.go create mode 100644 tui/screens/ai/model.go create mode 100644 tui/screens/ai/model_test.go create mode 100644 tui/screens/ai/view.go create mode 100644 tui/screens/workbenches/model.go create mode 100644 tui/screens/workbenches/model_test.go create mode 100644 tui/screens/workbenches/view.go diff --git a/.gitignore b/.gitignore index 0b42017d6..e945beb17 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,6 @@ vendor/ # Agents .codex/ +.claude/ AGENTS.md +CLAUDE.md \ No newline at end of file diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go index c3f2f7534..cb12fc4d2 100644 --- a/cmd/command/tui/tui.go +++ b/cmd/command/tui/tui.go @@ -8,6 +8,7 @@ import ( "github.com/pluralsh/plural-cli/pkg/bridge" accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + agentsbridge "github.com/pluralsh/plural-cli/pkg/bridge/agents" clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" notificationsbridge "github.com/pluralsh/plural-cli/pkg/bridge/notifications" pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" @@ -17,6 +18,7 @@ import ( servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" stacksbridge "github.com/pluralsh/plural-cli/pkg/bridge/stacks" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + workbenchesbridge "github.com/pluralsh/plural-cli/pkg/bridge/workbenches" "github.com/pluralsh/plural-cli/pkg/common" tuiapp "github.com/pluralsh/plural-cli/tui/app" ) @@ -35,6 +37,8 @@ func Command() cli.Command { providers := providersbridge.NewService(access) stacks := stacksbridge.NewService(access) pullrequests := pullrequestsbridge.NewService(access) + agents := agentsbridge.NewService(access) + workbenches := workbenchesbridge.NewService(access) return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{ Welcome: welcome, Access: access, @@ -46,6 +50,8 @@ func Command() cli.Command { Providers: providers, Stacks: stacks, PullRequests: pullrequests, + Agents: agents, + Workbenches: workbenches, }) }) } diff --git a/pkg/bridge/agents/agents.go b/pkg/bridge/agents/agents.go new file mode 100644 index 000000000..e64b808e7 --- /dev/null +++ b/pkg/bridge/agents/agents.go @@ -0,0 +1,206 @@ +// Package agents exposes resumable Console agent runs to presentation layers. +package agents + +import ( + "context" + "errors" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +const ( + defaultListLimit int64 = 50 + defaultPageSize = 10 +) + +var errNoConsole = errors.New("connect a Console profile before browsing agent runs") + +type Summary struct { + ID string + Repository string + Branch string + Provider string + Prompt string + PRRef string +} + +type Detail struct { + Summary + PullRequests []PullRequest +} + +type PullRequest struct { + ID string + Ref string + Status string + Title string + URL string +} + +type Page struct { + Items []Summary + EndCursor string + HasNext bool +} + +type Loader interface { + List(context.Context, *string, string) (Page, error) + Get(context.Context, string) (Detail, error) +} + +type ConsoleResolver interface { + ActiveConsole(context.Context) (url, token string, err error) +} + +type API interface { + ListAgentRuns(first int64) ([]*gqlclient.AgentRunMinimalFragment, error) + GetAgentRun(id string) (*gqlclient.AgentRunMinimalFragment, error) +} + +type ClientFactory func(token, url string) (API, error) + +type Service struct { + resolve ConsoleResolver + newClient ClientFactory + pageSize int +} + +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + pageSize: defaultPageSize, + } +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s.resolve == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + url, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return nil, err + } + return s.newClient(token, url) +} + +func (s *Service) List(ctx context.Context, after *string, query string) (Page, error) { + if err := ctx.Err(); err != nil { + return Page{}, err + } + client, err := s.client(ctx) + if err != nil { + return Page{}, err + } + runs, err := client.ListAgentRuns(defaultListLimit) + if err != nil { + return Page{}, err + } + items := make([]Summary, 0, len(runs)) + for _, run := range runs { + if !resumable(run) { + continue + } + summary := summaryFromRun(run) + if matches(summary, query) { + items = append(items, summary) + } + } + return pageItems(items, after, s.pageSize), nil +} + +func (s *Service) Get(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errors.New("agent run id is required")} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + run, err := client.GetAgentRun(id) + if err != nil { + return Detail{}, err + } + if !resumable(run) { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errors.New("agent run has no uploaded session")} + } + return detailFromRun(run), nil +} + +func resumable(run *gqlclient.AgentRunMinimalFragment) bool { + return run != nil && run.GetUpload() != nil && run.GetUpload().GetSession() != nil +} + +func summaryFromRun(run *gqlclient.AgentRunMinimalFragment) Summary { + summary := Summary{ID: run.GetID(), Repository: run.GetRepository(), Prompt: run.GetPrompt()} + if run.GetBranch() != nil { + summary.Branch = *run.GetBranch() + } + if run.GetRuntime() != nil && run.GetRuntime().GetType() != nil { + summary.Provider = string(*run.GetRuntime().GetType()) + } + for _, pr := range run.GetPullRequests() { + if pr != nil && pr.GetRef() != nil && strings.TrimSpace(*pr.GetRef()) != "" { + summary.PRRef = *pr.GetRef() + break + } + } + return summary +} + +func detailFromRun(run *gqlclient.AgentRunMinimalFragment) Detail { + detail := Detail{Summary: summaryFromRun(run)} + for _, pr := range run.GetPullRequests() { + if pr == nil || pr.GetRef() == nil || strings.TrimSpace(*pr.GetRef()) == "" { + continue + } + item := PullRequest{ID: pr.GetID(), Ref: *pr.GetRef(), URL: pr.GetURL()} + if pr.GetStatus() != nil { + item.Status = string(*pr.GetStatus()) + } + if pr.GetTitle() != nil { + item.Title = *pr.GetTitle() + } + detail.PullRequests = append(detail.PullRequests, item) + } + return detail +} + +func matches(item Summary, query string) bool { + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" { + return true + } + return strings.Contains(strings.ToLower(strings.Join([]string{item.ID, item.Repository, item.Branch, item.Provider, item.Prompt, item.PRRef}, " ")), query) +} + +func pageItems(items []Summary, after *string, pageSize int) Page { + if pageSize <= 0 { + pageSize = defaultPageSize + } + start := 0 + if after != nil { + for i, item := range items { + if item.ID == *after { + start = i + 1 + break + } + } + } + end := min(len(items), start+pageSize) + page := Page{Items: items[start:end], HasNext: end < len(items)} + if len(page.Items) > 0 { + page.EndCursor = page.Items[len(page.Items)-1].ID + } + return page +} diff --git a/pkg/bridge/agents/agents_test.go b/pkg/bridge/agents/agents_test.go new file mode 100644 index 000000000..f76d66b6a --- /dev/null +++ b/pkg/bridge/agents/agents_test.go @@ -0,0 +1,50 @@ +package agents + +import ( + "context" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" +) + +type fakeResolver struct{} + +func (fakeResolver) ActiveConsole(context.Context) (string, string, error) { + return "https://console.example.com", "token", nil +} + +type fakeAPI struct { + runs []*gqlclient.AgentRunMinimalFragment +} + +func (f fakeAPI) ListAgentRuns(int64) ([]*gqlclient.AgentRunMinimalFragment, error) { + return f.runs, nil +} +func (f fakeAPI) GetAgentRun(id string) (*gqlclient.AgentRunMinimalFragment, error) { + for _, run := range f.runs { + if run.ID == id { + return run, nil + } + } + return nil, nil +} + +func TestListOnlyReturnsResumableRuns(t *testing.T) { + session := "https://example.com/session.tgz" + provider := gqlclient.AgentRuntimeTypeCodex + branch := "main" + service := NewService(fakeResolver{}) + service.newClient = func(string, string) (API, error) { + return fakeAPI{runs: []*gqlclient.AgentRunMinimalFragment{ + {ID: "ready", Repository: "git@github.com:acme/repo.git", Branch: &branch, Prompt: "fix it", Runtime: &gqlclient.AgentRunMinimalFragment_Runtime{Type: provider}, Upload: &gqlclient.AgentRunMinimalFragment_Upload{Session: &session}}, + {ID: "missing", Repository: "repo"}, + }}, nil + } + page, err := service.List(t.Context(), nil, "acme") + if err != nil { + t.Fatal(err) + } + if len(page.Items) != 1 || page.Items[0].ID != "ready" { + t.Fatalf("unexpected page: %#v", page) + } +} diff --git a/pkg/bridge/workbenches/workbenches.go b/pkg/bridge/workbenches/workbenches.go new file mode 100644 index 000000000..efb4e67fa --- /dev/null +++ b/pkg/bridge/workbenches/workbenches.go @@ -0,0 +1,205 @@ +// Package workbenches exposes recent Console workbench jobs and queued prompts. +package workbenches + +import ( + "context" + "errors" + "strings" + "time" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +const defaultPageSize = 10 + +var errNoConsole = errors.New("connect a Console profile before browsing workbench jobs") + +type Summary struct { + ID string + WorkbenchID string + WorkbenchName string + Prompt string + Status string + InsertedAt string +} + +type Detail struct { + Summary + UpdatedAt string +} + +type Page struct { + Items []Summary + EndCursor string + HasNext bool +} + +type PromptResult struct { + ID string + Prompt string + DequeueAt string + WorkbenchID string +} + +type Loader interface { + List(context.Context, *string, string) (Page, error) + Get(context.Context, string) (Detail, error) + FollowUp(context.Context, string, string, time.Duration) (PromptResult, error) +} + +type ConsoleResolver interface { + ActiveConsole(context.Context) (url, token string, err error) +} + +type API interface { + ListWorkbenches(after *string, first *int64, query *string) (*gqlclient.ListWorkbenches_Workbenches, error) + ListWorkbenchJobs(workbenchID string, page, perPage int) ([]console.WorkbenchJob, error) + CreateQueuedPrompt(jobID, prompt string, dequeueAt time.Time) (*gqlclient.QueuedPromptFragment, error) +} + +type ClientFactory func(token, url string) (API, error) + +type Service struct { + resolve ConsoleResolver + newClient ClientFactory +} + +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + } +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s.resolve == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + url, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return nil, err + } + return s.newClient(token, url) +} + +func (s *Service) List(ctx context.Context, after *string, query string) (Page, error) { + if err := ctx.Err(); err != nil { + return Page{}, err + } + client, err := s.client(ctx) + if err != nil { + return Page{}, err + } + + first := int64(20) + workbenches, err := client.ListWorkbenches(nil, &first, nil) + if err != nil { + return Page{}, err + } + items := make([]Summary, 0) + if workbenches != nil { + for _, edge := range workbenches.GetEdges() { + if edge == nil || edge.GetNode() == nil { + continue + } + workbench := edge.GetNode() + jobs, err := client.ListWorkbenchJobs(workbench.GetID(), 1, defaultPageSize) + if err != nil { + return Page{}, err + } + for _, job := range jobs { + summary := summaryFromJob(job, workbench.GetName()) + if matches(summary, query) { + items = append(items, summary) + } + } + } + } + return pageItems(items, after, defaultPageSize), nil +} + +func (s *Service) Get(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errors.New("workbench job id is required")} + } + page, err := s.List(ctx, nil, id) + if err != nil { + return Detail{}, err + } + for _, item := range page.Items { + if item.ID == id { + return Detail{Summary: item}, nil + } + } + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errors.New("workbench job was not found")} +} + +func (s *Service) FollowUp(ctx context.Context, jobID, prompt string, deferBy time.Duration) (PromptResult, error) { + if err := ctx.Err(); err != nil { + return PromptResult{}, err + } + jobID = strings.TrimSpace(jobID) + prompt = strings.TrimSpace(prompt) + if jobID == "" { + return PromptResult{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errors.New("workbench job id is required")} + } + if prompt == "" { + return PromptResult{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errors.New("prompt cannot be empty")} + } + client, err := s.client(ctx) + if err != nil { + return PromptResult{}, err + } + dequeueAt := time.Now().Add(deferBy) + queued, err := client.CreateQueuedPrompt(jobID, prompt, dequeueAt) + if err != nil { + return PromptResult{}, err + } + return PromptResult{ID: queued.GetID(), Prompt: prompt, DequeueAt: dequeueAt.Format(time.RFC3339Nano), WorkbenchID: jobID}, nil +} + +func summaryFromJob(job console.WorkbenchJob, workbenchName string) Summary { + return Summary{ + ID: job.ID, + WorkbenchID: job.WorkbenchID, + WorkbenchName: workbenchName, + Prompt: job.Prompt, + Status: job.Status, + InsertedAt: job.InsertedAt, + } +} + +func matches(item Summary, query string) bool { + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" { + return true + } + return strings.Contains(strings.ToLower(strings.Join([]string{item.ID, item.WorkbenchID, item.WorkbenchName, item.Prompt, item.Status}, " ")), query) +} + +func pageItems(items []Summary, after *string, pageSize int) Page { + start := 0 + if after != nil { + for i, item := range items { + if item.ID == *after { + start = i + 1 + break + } + } + } + end := min(len(items), start+pageSize) + page := Page{Items: items[start:end], HasNext: end < len(items)} + if len(page.Items) > 0 { + page.EndCursor = page.Items[len(page.Items)-1].ID + } + return page +} diff --git a/pkg/bridge/workbenches/workbenches_test.go b/pkg/bridge/workbenches/workbenches_test.go new file mode 100644 index 000000000..07b2a73dc --- /dev/null +++ b/pkg/bridge/workbenches/workbenches_test.go @@ -0,0 +1,39 @@ +package workbenches + +import ( + "context" + "testing" + "time" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/console" +) + +type fakeResolver struct{} + +func (fakeResolver) ActiveConsole(context.Context) (string, string, error) { + return "https://console.example.com", "token", nil +} + +type fakeAPI struct{} + +func (fakeAPI) ListWorkbenches(*string, *int64, *string) (*gqlclient.ListWorkbenches_Workbenches, error) { + return nil, nil +} +func (fakeAPI) ListWorkbenchJobs(string, int, int) ([]console.WorkbenchJob, error) { return nil, nil } +func (fakeAPI) CreateQueuedPrompt(string, string, time.Time) (*gqlclient.QueuedPromptFragment, error) { + return &gqlclient.QueuedPromptFragment{ID: "prompt-1"}, nil +} + +func TestFollowUpQueuesPromptForSelectedJob(t *testing.T) { + service := NewService(fakeResolver{}) + service.newClient = func(string, string) (API, error) { return fakeAPI{}, nil } + result, err := service.FollowUp(t.Context(), "job-1", "verify the fix", 0) + if err != nil { + t.Fatal(err) + } + if result.ID != "prompt-1" || result.WorkbenchID != "job-1" { + t.Fatalf("unexpected result: %#v", result) + } +} diff --git a/pkg/console/console.go b/pkg/console/console.go index b5512f1c4..ca1ea0636 100644 --- a/pkg/console/console.go +++ b/pkg/console/console.go @@ -18,6 +18,15 @@ type consoleClient struct { token string } +type WorkbenchJob struct { + ID string `json:"id"` + Prompt string `json:"prompt"` + Status string `json:"status"` + WorkbenchID string `json:"workbench_id"` + InsertedAt string `json:"inserted_at"` + UpdatedAt string `json:"updated_at"` +} + type ConsoleClient interface { Url() string ExtUrl() string @@ -67,6 +76,10 @@ type ConsoleClient interface { CreatePullRequest(id string, branch, context *string) (*consoleclient.PullRequestFragment, error) CreateWorkbenchPRFollowup(url, prompt string) (string, error) EnqueueWorkbenchPRFollowup(url, prompt string, deferBy time.Duration) (*consoleclient.EnqueueWorkbenchPrFollowup_EnqueueWorkbenchPrFollowup, error) + ListWorkbenches(after *string, first *int64, query *string) (*consoleclient.ListWorkbenches_Workbenches, error) + GetWorkbench(id string) (*consoleclient.WorkbenchFragment, error) + ListWorkbenchJobs(workbenchID string, page, perPage int) ([]WorkbenchJob, error) + CreateQueuedPrompt(jobID, prompt string, dequeueAt time.Time) (*consoleclient.QueuedPromptFragment, error) GetPrAutomationByName(name string) (*consoleclient.PrAutomationFragment, error) ListPrAutomations() (*consoleclient.ListPrAutomations, error) GetPrAutomation(id string) (*consoleclient.PrAutomationFragment, error) diff --git a/pkg/console/workbenches.go b/pkg/console/workbenches.go index f45744648..7fa0cf102 100644 --- a/pkg/console/workbenches.go +++ b/pkg/console/workbenches.go @@ -1,7 +1,10 @@ package console import ( + "encoding/json" "fmt" + "net/http" + "net/url" "time" consoleclient "github.com/pluralsh/console/go/client" @@ -38,3 +41,67 @@ func (c *consoleClient) EnqueueWorkbenchPRFollowup(url, prompt string, deferBy t return fragment, nil } + +func (c *consoleClient) ListWorkbenches(after *string, first *int64, query *string) (*consoleclient.ListWorkbenches_Workbenches, error) { + result, err := c.client.ListWorkbenches(c.ctx, after, first, nil, nil, query) + if err != nil { + return nil, api.GetErrorResponse(err, "ListWorkbenches") + } + return result.GetWorkbenches(), nil +} + +func (c *consoleClient) GetWorkbench(id string) (*consoleclient.WorkbenchFragment, error) { + result, err := c.client.GetWorkbench(c.ctx, &id, nil) + if err != nil { + return nil, api.GetErrorResponse(err, "GetWorkbench") + } + return result.GetWorkbench(), nil +} + +func (c *consoleClient) ListWorkbenchJobs(workbenchID string, page, perPage int) ([]WorkbenchJob, error) { + endpoint, err := url.Parse(c.url) + if err != nil { + return nil, err + } + endpoint.Path = fmt.Sprintf("/v1/api/ai/workbenches/%s/jobs", workbenchID) + query := endpoint.Query() + query.Set("page", fmt.Sprintf("%d", page)) + query.Set("per_page", fmt.Sprintf("%d", perPage)) + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Token "+c.token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("list workbench jobs: %s", resp.Status) + } + var result struct { + Data []WorkbenchJob `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Data, nil +} + +func (c *consoleClient) CreateQueuedPrompt(jobID, prompt string, dequeueAt time.Time) (*consoleclient.QueuedPromptFragment, error) { + result, err := c.client.CreateQueuedPrompt(c.ctx, jobID, consoleclient.QueuedPromptAttributes{ + Prompt: prompt, + DequeableAt: dequeueAt.Format(time.RFC3339Nano), + }) + if err != nil { + return nil, api.GetErrorResponse(err, "CreateQueuedPrompt") + } + fragment := result.GetCreateQueuedPrompt() + if fragment == nil { + return nil, fmt.Errorf("returned object [CreateQueuedPrompt] is nil") + } + return fragment, nil +} diff --git a/pkg/test/mocks/ConsoleClient.go b/pkg/test/mocks/ConsoleClient.go index aca678aec..eb246a9f3 100644 --- a/pkg/test/mocks/ConsoleClient.go +++ b/pkg/test/mocks/ConsoleClient.go @@ -1,9 +1,10 @@ -// Code generated by mockery v2.53.6. DO NOT EDIT. +// Code generated by mockery v2.45.1. DO NOT EDIT. package mocks import ( client "github.com/pluralsh/console/go/client" + console "github.com/pluralsh/plural-cli/pkg/console" mock "github.com/stretchr/testify/mock" @@ -341,6 +342,36 @@ func (_m *ConsoleClient) CreatePullRequest(id string, branch *string, context *s return r0, r1 } +// CreateQueuedPrompt provides a mock function with given fields: jobID, prompt, dequeueAt +func (_m *ConsoleClient) CreateQueuedPrompt(jobID string, prompt string, dequeueAt time.Time) (*client.QueuedPromptFragment, error) { + ret := _m.Called(jobID, prompt, dequeueAt) + + if len(ret) == 0 { + panic("no return value specified for CreateQueuedPrompt") + } + + var r0 *client.QueuedPromptFragment + var r1 error + if rf, ok := ret.Get(0).(func(string, string, time.Time) (*client.QueuedPromptFragment, error)); ok { + return rf(jobID, prompt, dequeueAt) + } + if rf, ok := ret.Get(0).(func(string, string, time.Time) *client.QueuedPromptFragment); ok { + r0 = rf(jobID, prompt, dequeueAt) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.QueuedPromptFragment) + } + } + + if rf, ok := ret.Get(1).(func(string, string, time.Time) error); ok { + r1 = rf(jobID, prompt, dequeueAt) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // CreateRepository provides a mock function with given fields: url, privateKey, passphrase, username, password func (_m *ConsoleClient) CreateRepository(url string, privateKey *string, passphrase *string, username *string, password *string) (*client.CreateGitRepository, error) { ret := _m.Called(url, privateKey, passphrase, username, password) @@ -525,7 +556,7 @@ func (_m *ConsoleClient) EnqueueWorkbenchPRFollowup(url string, prompt string, d return r0, r1 } -// ExtUrl provides a mock function with no fields +// ExtUrl provides a mock function with given fields: func (_m *ConsoleClient) ExtUrl() string { ret := _m.Called() @@ -661,7 +692,7 @@ func (_m *ConsoleClient) GetDeployToken(clusterId *string, clusterName *string) return r0, r1 } -// GetGlobalSettings provides a mock function with no fields +// GetGlobalSettings provides a mock function with given fields: func (_m *ConsoleClient) GetGlobalSettings() (*client.DeploymentSettingsFragment, error) { ret := _m.Called() @@ -691,7 +722,7 @@ func (_m *ConsoleClient) GetGlobalSettings() (*client.DeploymentSettingsFragment return r0, r1 } -// GetGlobalSettingsMinimal provides a mock function with no fields +// GetGlobalSettingsMinimal provides a mock function with given fields: func (_m *ConsoleClient) GetGlobalSettingsMinimal() (*client.DeploymentSettingsFragment, error) { ret := _m.Called() @@ -1051,6 +1082,36 @@ func (_m *ConsoleClient) GetUser(email string) (*client.UserFragment, error) { return r0, r1 } +// GetWorkbench provides a mock function with given fields: id +func (_m *ConsoleClient) GetWorkbench(id string) (*client.WorkbenchFragment, error) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for GetWorkbench") + } + + var r0 *client.WorkbenchFragment + var r1 error + if rf, ok := ret.Get(0).(func(string) (*client.WorkbenchFragment, error)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) *client.WorkbenchFragment); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.WorkbenchFragment) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // IsClusterRegistrationComplete provides a mock function with given fields: machineID func (_m *ConsoleClient) IsClusterRegistrationComplete(machineID string) (bool, *client.ClusterRegistrationFragment) { ret := _m.Called(machineID) @@ -1171,7 +1232,7 @@ func (_m *ConsoleClient) ListClusterServices(clusterId *string, handle *string) return r0, r1 } -// ListClusters provides a mock function with no fields +// ListClusters provides a mock function with given fields: func (_m *ConsoleClient) ListClusters() (*client.ListClusters, error) { ret := _m.Called() @@ -1231,24 +1292,24 @@ func (_m *ConsoleClient) ListNotificationSinks(after *string, first *int64) (*cl return r0, r1 } -// ListProviders provides a mock function with no fields -func (_m *ConsoleClient) ListProviders() (*client.ListProviders, error) { +// ListPipelines provides a mock function with given fields: +func (_m *ConsoleClient) ListPipelines() (*client.GetPipelines, error) { ret := _m.Called() if len(ret) == 0 { - panic("no return value specified for ListProviders") + panic("no return value specified for ListPipelines") } - var r0 *client.ListProviders + var r0 *client.GetPipelines var r1 error - if rf, ok := ret.Get(0).(func() (*client.ListProviders, error)); ok { + if rf, ok := ret.Get(0).(func() (*client.GetPipelines, error)); ok { return rf() } - if rf, ok := ret.Get(0).(func() *client.ListProviders); ok { + if rf, ok := ret.Get(0).(func() *client.GetPipelines); ok { r0 = rf() } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*client.ListProviders) + r0 = ret.Get(0).(*client.GetPipelines) } } @@ -1261,24 +1322,24 @@ func (_m *ConsoleClient) ListProviders() (*client.ListProviders, error) { return r0, r1 } -// ListPipelines provides a mock function with no fields -func (_m *ConsoleClient) ListPipelines() (*client.GetPipelines, error) { +// ListPrAutomations provides a mock function with given fields: +func (_m *ConsoleClient) ListPrAutomations() (*client.ListPrAutomations, error) { ret := _m.Called() if len(ret) == 0 { - panic("no return value specified for ListPipelines") + panic("no return value specified for ListPrAutomations") } - var r0 *client.GetPipelines + var r0 *client.ListPrAutomations var r1 error - if rf, ok := ret.Get(0).(func() (*client.GetPipelines, error)); ok { + if rf, ok := ret.Get(0).(func() (*client.ListPrAutomations, error)); ok { return rf() } - if rf, ok := ret.Get(0).(func() *client.GetPipelines); ok { + if rf, ok := ret.Get(0).(func() *client.ListPrAutomations); ok { r0 = rf() } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*client.GetPipelines) + r0 = ret.Get(0).(*client.ListPrAutomations) } } @@ -1291,24 +1352,24 @@ func (_m *ConsoleClient) ListPipelines() (*client.GetPipelines, error) { return r0, r1 } -// ListPrAutomations provides a mock function with no fields -func (_m *ConsoleClient) ListPrAutomations() (*client.ListPrAutomations, error) { +// ListProviders provides a mock function with given fields: +func (_m *ConsoleClient) ListProviders() (*client.ListProviders, error) { ret := _m.Called() if len(ret) == 0 { - panic("no return value specified for ListPrAutomations") + panic("no return value specified for ListProviders") } - var r0 *client.ListPrAutomations + var r0 *client.ListProviders var r1 error - if rf, ok := ret.Get(0).(func() (*client.ListPrAutomations, error)); ok { + if rf, ok := ret.Get(0).(func() (*client.ListProviders, error)); ok { return rf() } - if rf, ok := ret.Get(0).(func() *client.ListPrAutomations); ok { + if rf, ok := ret.Get(0).(func() *client.ListProviders); ok { r0 = rf() } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*client.ListPrAutomations) + r0 = ret.Get(0).(*client.ListProviders) } } @@ -1321,7 +1382,7 @@ func (_m *ConsoleClient) ListPrAutomations() (*client.ListPrAutomations, error) return r0, r1 } -// ListRepositories provides a mock function with no fields +// ListRepositories provides a mock function with given fields: func (_m *ConsoleClient) ListRepositories() (*client.ListGitRepositories, error) { ret := _m.Called() @@ -1381,7 +1442,7 @@ func (_m *ConsoleClient) ListStackRuns(stackID string) (*client.ListStackRuns, e return r0, r1 } -// ListStacks provides a mock function with no fields +// ListStacks provides a mock function with given fields: func (_m *ConsoleClient) ListStacks() (*client.ListInfrastructureStacks, error) { ret := _m.Called() @@ -1411,7 +1472,67 @@ func (_m *ConsoleClient) ListStacks() (*client.ListInfrastructureStacks, error) return r0, r1 } -// MyCluster provides a mock function with no fields +// ListWorkbenchJobs provides a mock function with given fields: workbenchID, page, perPage +func (_m *ConsoleClient) ListWorkbenchJobs(workbenchID string, page int, perPage int) ([]console.WorkbenchJob, error) { + ret := _m.Called(workbenchID, page, perPage) + + if len(ret) == 0 { + panic("no return value specified for ListWorkbenchJobs") + } + + var r0 []console.WorkbenchJob + var r1 error + if rf, ok := ret.Get(0).(func(string, int, int) ([]console.WorkbenchJob, error)); ok { + return rf(workbenchID, page, perPage) + } + if rf, ok := ret.Get(0).(func(string, int, int) []console.WorkbenchJob); ok { + r0 = rf(workbenchID, page, perPage) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]console.WorkbenchJob) + } + } + + if rf, ok := ret.Get(1).(func(string, int, int) error); ok { + r1 = rf(workbenchID, page, perPage) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListWorkbenches provides a mock function with given fields: after, first, query +func (_m *ConsoleClient) ListWorkbenches(after *string, first *int64, query *string) (*client.ListWorkbenches_Workbenches, error) { + ret := _m.Called(after, first, query) + + if len(ret) == 0 { + panic("no return value specified for ListWorkbenches") + } + + var r0 *client.ListWorkbenches_Workbenches + var r1 error + if rf, ok := ret.Get(0).(func(*string, *int64, *string) (*client.ListWorkbenches_Workbenches, error)); ok { + return rf(after, first, query) + } + if rf, ok := ret.Get(0).(func(*string, *int64, *string) *client.ListWorkbenches_Workbenches); ok { + r0 = rf(after, first, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.ListWorkbenches_Workbenches) + } + } + + if rf, ok := ret.Get(1).(func(*string, *int64, *string) error); ok { + r1 = rf(after, first, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MyCluster provides a mock function with given fields: func (_m *ConsoleClient) MyCluster() (*client.MyCluster, error) { ret := _m.Called() @@ -1501,7 +1622,7 @@ func (_m *ConsoleClient) SaveServiceContext(name string, attributes client.Servi return r0, r1 } -// Token provides a mock function with no fields +// Token provides a mock function with given fields: func (_m *ConsoleClient) Token() string { ret := _m.Called() @@ -1639,7 +1760,7 @@ func (_m *ConsoleClient) UpdateRepository(id string, attrs client.GitAttributes) return r0, r1 } -// Url provides a mock function with no fields +// Url provides a mock function with given fields: func (_m *ConsoleClient) Url() string { ret := _m.Called() diff --git a/tui/app/model.go b/tui/app/model.go index 2e9d88a02..75e77b39d 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -8,6 +8,7 @@ import ( tea "charm.land/bubbletea/v2" accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + agentsbridge "github.com/pluralsh/plural-cli/pkg/bridge/agents" clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" notificationsbridge "github.com/pluralsh/plural-cli/pkg/bridge/notifications" pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" @@ -17,8 +18,11 @@ import ( servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" stacksbridge "github.com/pluralsh/plural-cli/pkg/bridge/stacks" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + workbenchesbridge "github.com/pluralsh/plural-cli/pkg/bridge/workbenches" "github.com/pluralsh/plural-cli/tui/navigation" accessscreen "github.com/pluralsh/plural-cli/tui/screens/access" + agentsscreen "github.com/pluralsh/plural-cli/tui/screens/agents" + aiscreen "github.com/pluralsh/plural-cli/tui/screens/ai" clustersscreen "github.com/pluralsh/plural-cli/tui/screens/clusters" deploymentsscreen "github.com/pluralsh/plural-cli/tui/screens/deployments" diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" @@ -30,6 +34,7 @@ import ( servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" stacksscreen "github.com/pluralsh/plural-cli/tui/screens/stacks" welcomescreen "github.com/pluralsh/plural-cli/tui/screens/welcome" + workbenchesscreen "github.com/pluralsh/plural-cli/tui/screens/workbenches" "github.com/pluralsh/plural-cli/tui/theme" ) @@ -45,6 +50,8 @@ type Dependencies struct { Providers providersbridge.Loader Stacks stacksbridge.Loader PullRequests pullrequestsbridge.Loader + Agents agentsbridge.Loader + Workbenches workbenchesbridge.Loader } // Model is the root TUI model. It owns global input and delegates screen state @@ -68,6 +75,9 @@ type Model struct { providers providersscreen.Model stacks stacksscreen.Model pullrequests pullrequestsscreen.Model + ai aiscreen.Model + agents agentsscreen.Model + workbenches workbenchesscreen.Model route navigation.Route } @@ -87,6 +97,9 @@ func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { providers: providersscreen.New(ctx, dependencies.Providers, t), stacks: stacksscreen.New(ctx, dependencies.Stacks, t), pullrequests: pullrequestsscreen.New(ctx, dependencies.PullRequests, t), + ai: aiscreen.New(t), + agents: agentsscreen.New(ctx, dependencies.Agents, t), + workbenches: workbenchesscreen.New(ctx, dependencies.Workbenches, t), route: navigation.Welcome, quit: key.NewBinding( key.WithKeys("ctrl+c"), @@ -125,6 +138,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.stacks.Init() case navigation.PullRequests: return m, m.pullrequests.Init() + case navigation.AI: + return m, m.ai.Init() + case navigation.Agents: + return m, m.agents.Init() + case navigation.Workbenches: + return m, m.workbenches.Init() default: return m, m.welcome.Init() } @@ -166,6 +185,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.stacks, cmd = m.stacks.Update(msg) case navigation.PullRequests: m.pullrequests, cmd = m.pullrequests.Update(msg) + case navigation.AI: + m.ai, cmd = m.ai.Update(msg) + case navigation.Agents: + m.agents, cmd = m.agents.Update(msg) + case navigation.Workbenches: + m.workbenches, cmd = m.workbenches.Update(msg) default: m.welcome, cmd = m.welcome.Update(msg) } diff --git a/tui/app/model_test.go b/tui/app/model_test.go index b01d3f9c6..4bf2529cb 100644 --- a/tui/app/model_test.go +++ b/tui/app/model_test.go @@ -70,12 +70,26 @@ func TestModelRoutesScreensWithoutRebuildingShell(t *testing.T) { if routed.route != navigation.Pipelines || !strings.Contains(routed.View().Content, "Pipelines") { t.Fatalf("pipelines route/view = %q\n%s", routed.route, routed.View().Content) } + updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.AI}) + routed = updated.(Model) + if routed.route != navigation.AI || !strings.Contains(routed.View().Content, "AI workspaces") { + t.Fatalf("ai route/view = %q\n%s", routed.route, routed.View().Content) + } updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Welcome}) if got := updated.(Model).route; got != navigation.Welcome { t.Fatalf("route = %q", got) } } +func TestModelRoutesAIDedicatedScreen(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), Dependencies{}) + updated, _ := model.Update(navigation.NavigateMsg{Route: navigation.AI}) + routed := updated.(Model) + if routed.route != navigation.AI || !strings.Contains(routed.View().Content, "AI workspaces") { + t.Fatalf("ai route/view = %q\n%s", routed.route, routed.View().Content) + } +} + func TestRunRejectsMissingTerminal(t *testing.T) { if err := Run(t.Context(), nil, nil, Dependencies{}); !errors.Is(err, ErrNoTerminal) { t.Fatalf("Run() error = %v", err) diff --git a/tui/app/view.go b/tui/app/view.go index aa1f2cae3..8798d6baf 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -33,6 +33,12 @@ func (m Model) View() tea.View { content = m.stacks.View(m.width, m.height) case navigation.PullRequests: content = m.pullrequests.View(m.width, m.height) + case navigation.AI: + content = m.ai.View(m.width, m.height) + case navigation.Agents: + content = m.agents.View(m.width, m.height) + case navigation.Workbenches: + content = m.workbenches.View(m.width, m.height) } view := tea.NewView(content) view.AltScreen = true diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index 47c63ed2a..c391a0f94 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -12,6 +12,9 @@ const ( Welcome Route = "welcome" Access Route = "access" Diagnostics Route = "diagnostics" + AI Route = "ai" + Agents Route = "agents" + Workbenches Route = "workbenches" Deployments Route = "deployments" Services Route = "services" Clusters Route = "clusters" diff --git a/tui/screens/agents/model.go b/tui/screens/agents/model.go new file mode 100644 index 000000000..684960b98 --- /dev/null +++ b/tui/screens/agents/model.go @@ -0,0 +1,239 @@ +// Package agents implements the interactive agent-run browser. +package agents + +import ( + "context" + "os" + "os/exec" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + agentsbridge "github.com/pluralsh/plural-cli/pkg/bridge/agents" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeList mode = iota + modeDetail + modeFilter + modeRepoPath + modeResult +) + +type initMsg struct{} +type listedMsg struct { + page agentsbridge.Page + err error + request uint64 +} +type detailMsg struct { + detail agentsbridge.Detail + err error + request uint64 +} +type resumedMsg struct{ err error } + +type Model struct { + ctx context.Context + loader agentsbridge.Loader + theme theme.Theme + mode mode + loading bool + err error + needsAuth bool + request uint64 + page agentsbridge.Page + cursor int + filter string + input textinput.Model + detail agentsbridge.Detail + result string +} + +func New(ctx context.Context, loader agentsbridge.Loader, t theme.Theme) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "filter agent runs" + input.CharLimit = 256 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text, styles.Focused.Prompt, styles.Focused.Placeholder = t.Body, t.Title, t.Muted + styles.Blurred = styles.Focused + input.SetStyles(styles) + return Model{ctx: ctx, loader: loader, theme: t, input: input} +} + +func (m Model) Init() tea.Cmd { return func() tea.Msg { return initMsg{} } } + +func (m *Model) beginList() tea.Cmd { + m.loading = true + m.request++ + request, loader, ctx, query := m.request, m.loader, m.ctx, m.filter + return func() tea.Msg { + page, err := loader.List(ctx, nil, query) + return listedMsg{page: page, err: err, request: request} + } +} + +func (m *Model) beginDetail(id string) tea.Cmd { + m.loading = true + m.request++ + request, loader, ctx := m.request, m.loader, m.ctx + return func() tea.Msg { + detail, err := loader.Get(ctx, id) + return detailMsg{detail: detail, err: err, request: request} + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.mode, m.err, m.needsAuth = modeList, nil, false + if m.loader == nil { + return m, nil + } + return m, m.beginList() + case listedMsg: + if msg.request != m.request { + return m, nil + } + m.loading, m.err = false, msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.page, m.cursor, m.mode = msg.page, clamp(m.cursor, len(msg.page.Items)), modeList + } + return m, nil + case detailMsg: + if msg.request != m.request { + return m, nil + } + m.loading, m.err = false, msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.detail, m.mode = msg.detail, modeDetail + } + return m, nil + case resumedMsg: + m.loading = false + m.err = msg.err + if msg.err == nil { + m.result = "Agent session finished and the TUI resumed." + } + m.mode = modeResult + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeFilter || m.mode == modeRepoPath { + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + stroke := key.Keystroke() + if m.mode == modeFilter { + switch stroke { + case "esc": + m.mode = modeList + m.input.Blur() + return m, nil + case "enter": + m.filter = strings.TrimSpace(m.input.Value()) + m.input.Blur() + return m, m.beginList() + } + var cmd tea.Cmd + m.input, cmd = m.input.Update(key) + return m, cmd + } + if m.mode == modeRepoPath { + switch stroke { + case "esc": + m.mode = modeDetail + m.input.Blur() + return m, nil + case "enter": + path := strings.TrimSpace(m.input.Value()) + if path == "" { + m.err = &bridge.Error{Code: bridge.ErrorInvalid, Err: context.Canceled} + return m, nil + } + m.input.Blur() + m.loading = true + m.result = "Launching agent resume for " + m.detail.ID + " in " + path + command := exec.Command("plural", "agents", "resume", m.detail.ID) + command.Dir = path + command.Env = os.Environ() + return m, tea.ExecProcess(command, func(err error) tea.Msg { return resumedMsg{err: err} }) + } + var cmd tea.Cmd + m.input, cmd = m.input.Update(key) + return m, cmd + } + if m.mode == modeResult { + if stroke == "esc" || stroke == "enter" { + m.mode = modeDetail + } + return m, nil + } + if m.mode == modeDetail { + switch stroke { + case "esc": + m.mode = modeList + case "r": + m.mode = modeRepoPath + m.input.SetValue(".") + m.input.Placeholder = "existing local clone" + m.input.Focus() + } + return m, nil + } + if stroke == "esc" { + return m, navigation.Navigate(navigation.AI) + } + if m.loading { + return m, nil + } + if m.needsAuth && stroke == "c" { + return m, navigation.Navigate(navigation.Access) + } + switch stroke { + case "up", "k": + m.cursor = clamp(m.cursor-1, len(m.page.Items)) + case "down", "j": + m.cursor = clamp(m.cursor+1, len(m.page.Items)) + case "enter": + if len(m.page.Items) > 0 { + return m, m.beginDetail(m.page.Items[m.cursor].ID) + } + case "/": + m.mode = modeFilter + m.input.Placeholder = "filter agent runs" + m.input.SetValue(m.filter) + m.input.Focus() + case "r": + return m, m.beginList() + } + return m, nil +} + +func clamp(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} diff --git a/tui/screens/agents/model_test.go b/tui/screens/agents/model_test.go new file mode 100644 index 000000000..28933a76b --- /dev/null +++ b/tui/screens/agents/model_test.go @@ -0,0 +1,38 @@ +package agents + +import ( + "context" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + + agentsbridge "github.com/pluralsh/plural-cli/pkg/bridge/agents" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + page agentsbridge.Page + detail agentsbridge.Detail +} + +func (f fakeLoader) List(context.Context, *string, string) (agentsbridge.Page, error) { + return f.page, nil +} +func (f fakeLoader) Get(context.Context, string) (agentsbridge.Detail, error) { return f.detail, nil } + +func TestSelectRunOpensInteractiveDetail(t *testing.T) { + loader := fakeLoader{page: agentsbridge.Page{Items: []agentsbridge.Summary{{ID: "run-1", Repository: "acme/repo", Provider: "codex"}}}, detail: agentsbridge.Detail{Summary: agentsbridge.Summary{ID: "run-1", Repository: "acme/repo", Provider: "codex"}}} + model := New(t.Context(), loader, theme.New(colorprofile.ASCII)) + model, cmd := model.Update(model.Init()()) + model, _ = model.Update(cmd()) + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail || model.detail.ID != "run-1" { + t.Fatalf("unexpected detail state: %#v", model) + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'r'}) + if model.mode != modeRepoPath { + t.Fatalf("expected repo path step, got %d", model.mode) + } +} diff --git a/tui/screens/agents/view.go b/tui/screens/agents/view.go new file mode 100644 index 000000000..1ff83ffab --- /dev/null +++ b/tui/screens/agents/view.go @@ -0,0 +1,110 @@ +package agents + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + body, help := m.bodyAndHelp(page.ContentWidth(width)) + title := "Agent runs" + if m.mode != modeList && m.detail.ID != "" { + title += " · " + m.detail.ID + } + return page.Render(m.theme, width, height, title, m.status(), body, help) +} + +func (m Model) status() string { + if m.loading { + return m.theme.Warning.Render("◌ loading") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + if m.err != nil { + return m.theme.Danger.Render("✗ failed") + } + return m.theme.Success.Render(fmt.Sprintf("%d resumable", len(m.page.Items))) +} + +func (m Model) bodyAndHelp(width int) (string, string) { + if m.mode == modeFilter { + return page.Panel(m.theme, "Filter agent runs", []string{m.input.View()}, width, 5, true), "enter apply · esc cancel" + } + if m.needsAuth { + return page.Panel(m.theme, "Console required", []string{"Connect a Console profile to browse agent runs.", "", "Press c to open Access."}, width, 7, true), "c connect · esc AI hub" + } + if m.mode == modeRepoPath { + return page.Panel(m.theme, "Choose local clone", []string{"Existing clone for " + m.detail.Repository, "", m.input.View()}, width, 7, true), "enter continue · esc cancel" + } + if m.mode == modeResult { + lines := []string{m.theme.Success.Render("✓ Resume complete"), "", m.result} + if m.err != nil { + lines = []string{m.theme.Danger.Render("✗ Resume failed"), "", m.err.Error()} + } + return page.Panel(m.theme, "Agent resume", lines, width, 9, true), "enter/esc detail" + } + if m.mode == modeDetail { + lines := []string{ + "Repository " + value(m.detail.Repository), + "Branch " + value(m.detail.Branch), + "Provider " + value(m.detail.Provider), + "PR ref " + value(m.detail.PRRef), + "Prompt " + value(m.detail.Prompt), + "Run ID " + m.detail.ID, + } + if len(m.detail.PullRequests) > 1 { + lines = append(lines, "", fmt.Sprintf("%d pull request branches available", len(m.detail.PullRequests))) + } + return page.Panel(m.theme, "Run detail", lines, width, 12, true), "r resume interactively · esc list" + } + if m.loading && len(m.page.Items) == 0 { + return page.Panel(m.theme, "Resumable runs", []string{"◌ Loading agent runs…"}, width, 14, true), "esc AI hub" + } + if m.err != nil { + return page.Panel(m.theme, "Resumable runs", []string{"Unable to load agent runs", m.err.Error()}, width, 14, true), "r retry · esc AI hub" + } + lines := []string{m.theme.Muted.Render(" REPOSITORY PROVIDER BRANCH / PR PROMPT")} + for i, item := range m.page.Items { + cursor := " " + if i == m.cursor { + cursor = "› " + } + row := fmt.Sprintf("%s%-20s %-12s %-30s %s", cursor, repoName(item.Repository), value(item.Provider), value(first(item.PRRef, item.Branch)), value(item.Prompt)) + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + if len(m.page.Items) == 0 { + lines = append(lines, "No resumable agent runs found.") + } + return page.Panel(m.theme, "Resumable agent runs", lines, width, 14, true), "↑/↓ select · enter detail · / filter · r refresh · esc AI hub" +} + +func value(v string) string { + if strings.TrimSpace(v) == "" { + return "—" + } + return v +} +func first(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} +func repoName(v string) string { + v = strings.TrimSuffix(strings.TrimSpace(v), ".git") + if i := strings.LastIndexAny(v, "/:"); i >= 0 { + return v[i+1:] + } + return v +} diff --git a/tui/screens/ai/model.go b/tui/screens/ai/model.go new file mode 100644 index 000000000..a3943a07c --- /dev/null +++ b/tui/screens/ai/model.go @@ -0,0 +1,106 @@ +package ai + +import ( + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + return keyActionNone +} + +type item struct { + number string + shortcut string + title string + blurb string + command string + usage string +} + +var items = []item{ + {number: "1", shortcut: "a", title: "Agents", blurb: "list and resume runs", command: "plural agents resume [run-id]", usage: "Resume or inspect an agent run from the console-backed TUI flow."}, + {number: "2", shortcut: "w", title: "Workbenches", blurb: "PR follow-up prompts", command: "plural workbenches pr-followup --prompt ...", usage: "Send a follow-up prompt to a workbench-backed pull request."}, +} + +// Model owns the AI hub navigation state. +type Model struct { + theme theme.Theme + cursor int +} + +func New(t theme.Theme) Model { return Model{theme: t} } + +func (m Model) Init() tea.Cmd { return nil } + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + return m.updateKey(msg) + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + text := key.Text + if text == "" && key.Code > 0 && key.Code < 128 { + text = string(key.Code) + } + for i, item := range items { + if text == item.number || text == item.shortcut { + m.cursor = i + return m.open() + } + } + + switch actionForKeystroke(key.Keystroke()) { + case keyActionMoveUp: + if m.cursor > 0 { + m.cursor-- + } + case keyActionMoveDown: + if m.cursor < len(items)-1 { + m.cursor++ + } + case keyActionConfirm: + return m.open() + case keyActionBack: + return m, navigation.Navigate(navigation.Welcome) + } + return m, nil +} + +func (m Model) open() (Model, tea.Cmd) { + if m.cursor == 0 { + return m, navigation.Navigate(navigation.Agents) + } + return m, navigation.Navigate(navigation.Workbenches) +} + +func (m Model) Snapshot() item { return items[m.cursor] } diff --git a/tui/screens/ai/model_test.go b/tui/screens/ai/model_test.go new file mode 100644 index 000000000..ec0435882 --- /dev/null +++ b/tui/screens/ai/model_test.go @@ -0,0 +1,28 @@ +package ai + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestAIHubRoutesToInteractiveScreens(t *testing.T) { + model := New(theme.New(colorprofile.ASCII)) + if got := normalizeView(model.View(80, 24)); !strings.Contains(got, "AI workspaces") || !strings.Contains(got, "Agents") { + t.Fatalf("hub view missing entries:\n%s", got) + } + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Agents}) { + t.Fatal("agents selection did not navigate to the interactive screen") + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Workbenches}) { + t.Fatal("workbenches selection did not navigate to the interactive screen") + } +} diff --git a/tui/screens/ai/view.go b/tui/screens/ai/view.go new file mode 100644 index 000000000..52900b24f --- /dev/null +++ b/tui/screens/ai/view.go @@ -0,0 +1,45 @@ +package ai + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + body, help := m.bodyAndHelp(contentWidth) + return page.Render(m.theme, width, height, "AI", m.headerStatus(), body, help) +} + +func (m Model) headerStatus() string { + return m.theme.Success.Render(fmt.Sprintf("%d commands", len(items))) +} + +func (m Model) bodyAndHelp(width int) (string, string) { + lines := []string{m.theme.Muted.Render("Choose an AI workspace to open its interactive screen."), ""} + for i, item := range items { + cursor := " " + if i == m.cursor { + cursor = "› " + } + lines = append(lines, cursor+fmt.Sprintf("%s %-12s %s", item.number, item.title, item.blurb)) + } + lines = append(lines, "", m.theme.Muted.Render("Agents browses resumable runs; Workbenches queues follow-up prompts.")) + return page.Panel(m.theme, "AI workspaces", lines, width, 8, true), "↑/↓ select · enter open · 1-2 shortcut · esc back" +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/welcome/groups.go b/tui/screens/welcome/groups.go index 0bfd6e2b6..ed01a5e3a 100644 --- a/tui/screens/welcome/groups.go +++ b/tui/screens/welcome/groups.go @@ -8,6 +8,7 @@ const ( groupDeployments groupID = iota groupAccess groupDiagnose + groupAI groupHelp ) @@ -25,6 +26,7 @@ func welcomeGroups() []group { {id: groupDeployments, number: "1", shortcut: "d", title: "CD / Deployments", blurb: "clusters · services · repos", route: navigation.Deployments}, {id: groupAccess, number: "2", shortcut: "a", title: "Access", blurb: "login · profiles · Console", route: navigation.Access}, {id: groupDiagnose, number: "3", shortcut: "g", title: "Diagnose", blurb: "local context · checks", route: navigation.Diagnostics}, - {id: groupHelp, number: "4", shortcut: "?", title: "Help", blurb: "shortcuts · about"}, + {id: groupAI, number: "4", shortcut: "i", title: "AI", blurb: "agents · workbenches", route: navigation.AI}, + {id: groupHelp, number: "5", shortcut: "?", title: "Help", blurb: "shortcuts · about"}, } } diff --git a/tui/screens/welcome/model_test.go b/tui/screens/welcome/model_test.go index 5389fb1e6..fb194faf5 100644 --- a/tui/screens/welcome/model_test.go +++ b/tui/screens/welcome/model_test.go @@ -148,6 +148,28 @@ func TestWelcomeOpensAccessFromNumber(t *testing.T) { } } +func TestWelcomeOpensAIFromShortcut(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + _, cmd := model.Update(tea.KeyPressMsg{Code: 'i', Text: "i"}) + if cmd == nil { + t.Fatal("selecting AI did not emit navigation") + } + if got := cmd().(navigation.NavigateMsg).Route; got != navigation.AI { + t.Fatalf("route = %q, want ai", got) + } +} + +func TestWelcomeOpensAIFromNumber(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + _, cmd := model.Update(tea.KeyPressMsg{Code: '4', Text: "4"}) + if cmd == nil { + t.Fatal("selecting AI did not emit navigation") + } + if got := cmd().(navigation.NavigateMsg).Route; got != navigation.AI { + t.Fatalf("route = %q, want ai", got) + } +} + func TestWelcomeArrowAndEnterOpensDiagnose(t *testing.T) { model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) @@ -182,8 +204,16 @@ func TestGroupPickerIsAnchoredAtBottom(t *testing.T) { if len(lines) != 24 { t.Fatalf("view height = %d, want 24", len(lines)) } - if !strings.Contains(lines[len(lines)-9], "Choose an area") { - t.Fatalf("group picker is not anchored above help:\n%s", strings.Join(lines, "\n")) + want := "Choose an area" + found := false + for _, line := range lines { + if strings.Contains(line, want) { + found = true + break + } + } + if !found { + t.Fatalf("group picker missing title:\n%s", strings.Join(lines, "\n")) } if !strings.Contains(lines[len(lines)-1], "ctrl+c quit") { t.Fatalf("keymap is not at the bottom: %q", lines[len(lines)-1]) diff --git a/tui/screens/welcome/testdata/welcome-120.golden b/tui/screens/welcome/testdata/welcome-120.golden index 8fcaacc42..daa241328 100644 --- a/tui/screens/welcome/testdata/welcome-120.golden +++ b/tui/screens/welcome/testdata/welcome-120.golden @@ -18,13 +18,13 @@ - ╭─ Choose an area ─────────────────────────────────────────────────────────────────────────────────────────────────╮ │ › 1 d CD / Deployments clusters · services · repos │ │ 2 a Access login · profiles · Console │ │ 3 g Diagnose local context · checks │ - │ 4 ? Help shortcuts · about │ + │ 4 i AI agents · workbenches │ + │ 5 ? Help shortcuts · about │ │ │ │ Setup · Agents · Develop — later │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - 1–4 open · letter shortcut · ↑/↓ · enter · ctrl+c quit + 1–5 open · letter shortcut · ↑/↓ · enter · ctrl+c quit diff --git a/tui/screens/welcome/testdata/welcome-80.golden b/tui/screens/welcome/testdata/welcome-80.golden index b08356e67..7250e9e4a 100644 --- a/tui/screens/welcome/testdata/welcome-80.golden +++ b/tui/screens/welcome/testdata/welcome-80.golden @@ -12,13 +12,13 @@ - ╭─ Choose an area ─────────────────────────────────────────────────────────╮ │ › 1 d CD / Deployments clusters · services · repos │ │ 2 a Access login · profiles · Console │ │ 3 g Diagnose local context · checks │ - │ 4 ? Help shortcuts · about │ + │ 4 i AI agents · workbenches │ + │ 5 ? Help shortcuts · about │ │ │ │ Setup · Agents · Develop — later │ ╰──────────────────────────────────────────────────────────────────────────╯ - 1–4 open · letter shortcut · ↑/↓ · enter · ctrl+c quit + 1–5 open · letter shortcut · ↑/↓ · enter · ctrl+c quit diff --git a/tui/screens/welcome/testdata/welcome-popup-80.golden b/tui/screens/welcome/testdata/welcome-popup-80.golden index 6176a4afa..edf2ed630 100644 --- a/tui/screens/welcome/testdata/welcome-popup-80.golden +++ b/tui/screens/welcome/testdata/welcome-popup-80.golden @@ -12,13 +12,13 @@ - ╭─ Choose an area ─────────────────────────────────────────────────────────╮ │ 1 d CD / Deployments clusters · services · repos │ │ › 2 a Access login · profiles · Console │ │ 3 g Diagnose local context · checks │ - │ 4 ? Help shortcuts · about │ + │ 4 i AI agents · workbenches │ + │ 5 ? Help shortcuts · about │ │ │ │ Setup · Agents · Develop — later │ ╰──────────────────────────────────────────────────────────────────────────╯ - 1–4 open · letter shortcut · ↑/↓ · enter · ctrl+c quit + 1–5 open · letter shortcut · ↑/↓ · enter · ctrl+c quit diff --git a/tui/screens/welcome/view.go b/tui/screens/welcome/view.go index 2b71e8d22..411675b13 100644 --- a/tui/screens/welcome/view.go +++ b/tui/screens/welcome/view.go @@ -53,7 +53,7 @@ func (m Model) renderGroups(width int) string { var body []string if m.helpOpen { body = []string{ - m.theme.Body.Render("1–4 / letter opens an area"), + m.theme.Body.Render("1–5 / letter opens an area"), m.theme.Muted.Render("↑/↓ move · enter confirm · esc close help"), m.theme.Muted.Render("ctrl+c quit"), "", @@ -86,7 +86,7 @@ func (m Model) renderGroups(width int) string { } rows = append(rows, bottom) - help := m.theme.Muted.Render(ansi.Truncate("1–4 open · letter shortcut · ↑/↓ · enter · ctrl+c quit", max(1, width-2), "…")) + help := m.theme.Muted.Render(ansi.Truncate("1–5 open · letter shortcut · ↑/↓ · enter · ctrl+c quit", max(1, width-2), "…")) if m.helpOpen { help = m.theme.Muted.Render(ansi.Truncate("any key closes help · ctrl+c quit", max(1, width-2), "…")) } diff --git a/tui/screens/workbenches/model.go b/tui/screens/workbenches/model.go new file mode 100644 index 000000000..e19773d17 --- /dev/null +++ b/tui/screens/workbenches/model.go @@ -0,0 +1,263 @@ +// Package workbenches implements interactive workbench job follow-ups. +package workbenches + +import ( + "context" + "strings" + "time" + + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + workbenchesbridge "github.com/pluralsh/plural-cli/pkg/bridge/workbenches" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeList mode = iota + modeDetail + modeFilter + modePrompt + modeReview + modeOperating + modeResult +) + +type initMsg struct{} +type listedMsg struct { + page workbenchesbridge.Page + err error + request uint64 +} +type detailMsg struct { + detail workbenchesbridge.Detail + err error + request uint64 +} +type followedMsg struct { + result workbenchesbridge.PromptResult + err error + request uint64 +} + +type Model struct { + ctx context.Context + loader workbenchesbridge.Loader + theme theme.Theme + mode mode + loading bool + err error + needsAuth bool + request uint64 + page workbenchesbridge.Page + cursor int + filter string + filterInput textinput.Model + prompt textarea.Model + detail workbenchesbridge.Detail + result workbenchesbridge.PromptResult +} + +func New(ctx context.Context, loader workbenchesbridge.Loader, t theme.Theme) Model { + filter := textinput.New() + filter.Prompt = "› " + filter.Placeholder = "filter workbench jobs" + styles := textinput.DefaultDarkStyles() + styles.Focused.Text, styles.Focused.Prompt, styles.Focused.Placeholder = t.Body, t.Title, t.Muted + styles.Blurred = styles.Focused + filter.SetStyles(styles) + prompt := textarea.New() + prompt.Prompt = "│ " + prompt.Placeholder = "Follow-up prompt" + prompt.SetWidth(70) + prompt.SetHeight(5) + return Model{ctx: ctx, loader: loader, theme: t, filterInput: filter, prompt: prompt} +} + +func (m Model) Init() tea.Cmd { return func() tea.Msg { return initMsg{} } } +func (m *Model) beginList() tea.Cmd { + m.loading = true + m.request++ + r, l, c, q := m.request, m.loader, m.ctx, m.filter + return func() tea.Msg { p, e := l.List(c, nil, q); return listedMsg{p, e, r} } +} +func (m *Model) beginDetail(id string) tea.Cmd { + m.loading = true + m.request++ + r, l, c := m.request, m.loader, m.ctx + return func() tea.Msg { d, e := l.Get(c, id); return detailMsg{d, e, r} } +} +func (m *Model) beginFollowup() tea.Cmd { + m.mode = modeOperating + m.loading = true + m.request++ + r, l, c, id, p := m.request, m.loader, m.ctx, m.detail.ID, strings.TrimSpace(m.prompt.Value()) + return func() tea.Msg { result, e := l.FollowUp(c, id, p, 0); return followedMsg{result, e, r} } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.mode, m.err, m.needsAuth = modeList, nil, false + if m.loader == nil { + return m, nil + } + return m, m.beginList() + case listedMsg: + if msg.request != m.request { + return m, nil + } + m.loading, m.err = false, msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.page = msg.page + m.cursor = clamp(m.cursor, len(msg.page.Items)) + m.mode = modeList + } + return m, nil + case detailMsg: + if msg.request != m.request { + return m, nil + } + m.loading, m.err = false, msg.err + if msg.err == nil { + m.detail = msg.detail + m.mode = modeDetail + } + return m, nil + case followedMsg: + if msg.request != m.request { + return m, nil + } + m.loading, m.err = false, msg.err + m.result = msg.result + m.mode = modeResult + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeFilter { + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + } + if m.mode == modePrompt { + var cmd tea.Cmd + m.prompt, cmd = m.prompt.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + s := key.Keystroke() + if m.mode == modeFilter { + switch s { + case "esc": + m.mode = modeList + m.filterInput.Blur() + return m, nil + case "enter": + m.filter = strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + return m, m.beginList() + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd + } + if m.mode == modePrompt { + switch s { + case "esc": + m.mode = modeDetail + m.prompt.Blur() + return m, nil + case "ctrl+s": + if strings.TrimSpace(m.prompt.Value()) != "" { + m.mode = modeReview + m.prompt.Blur() + } + return m, nil + } + var cmd tea.Cmd + m.prompt, cmd = m.prompt.Update(key) + return m, cmd + } + if m.mode == modeReview { + if s == "esc" { + m.mode = modePrompt + m.prompt.Focus() + return m, nil + } + if s == "enter" { + return m, m.beginFollowup() + } + return m, nil + } + if m.mode == modeOperating { + return m, nil + } + if m.mode == modeResult { + if s == "esc" || s == "enter" { + m.mode = modeDetail + } + return m, nil + } + if m.mode == modeDetail { + switch s { + case "esc": + m.mode = modeList + case "f": + m.mode = modePrompt + m.prompt.Reset() + m.prompt.Focus() + } + return m, nil + } + if s == "esc" { + return m, navigation.Navigate(navigation.AI) + } + if m.loading { + return m, nil + } + if m.needsAuth && s == "c" { + return m, navigation.Navigate(navigation.Access) + } + switch s { + case "up", "k": + m.cursor = clamp(m.cursor-1, len(m.page.Items)) + case "down", "j": + m.cursor = clamp(m.cursor+1, len(m.page.Items)) + case "enter": + if len(m.page.Items) > 0 { + return m, m.beginDetail(m.page.Items[m.cursor].ID) + } + case "/": + m.mode = modeFilter + m.filterInput.SetValue(m.filter) + m.filterInput.Focus() + case "r": + return m, m.beginList() + } + return m, nil +} + +func clamp(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} + +var _ = time.Second diff --git a/tui/screens/workbenches/model_test.go b/tui/screens/workbenches/model_test.go new file mode 100644 index 000000000..12161f538 --- /dev/null +++ b/tui/screens/workbenches/model_test.go @@ -0,0 +1,51 @@ +package workbenches + +import ( + "context" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + + workbenchesbridge "github.com/pluralsh/plural-cli/pkg/bridge/workbenches" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + page workbenchesbridge.Page + detail workbenchesbridge.Detail +} + +func (f fakeLoader) List(context.Context, *string, string) (workbenchesbridge.Page, error) { + return f.page, nil +} +func (f fakeLoader) Get(context.Context, string) (workbenchesbridge.Detail, error) { + return f.detail, nil +} +func (f fakeLoader) FollowUp(context.Context, string, string, time.Duration) (workbenchesbridge.PromptResult, error) { + return workbenchesbridge.PromptResult{ID: "prompt-1", WorkbenchID: "job-1"}, nil +} + +func TestFollowUpFlowIsInteractive(t *testing.T) { + loader := fakeLoader{page: workbenchesbridge.Page{Items: []workbenchesbridge.Summary{{ID: "job-1", WorkbenchName: "triage", Prompt: "investigate"}}}, detail: workbenchesbridge.Detail{Summary: workbenchesbridge.Summary{ID: "job-1", WorkbenchName: "triage"}}} + model := New(t.Context(), loader, theme.New(colorprofile.ASCII)) + model, cmd := model.Update(model.Init()()) + model, _ = model.Update(cmd()) + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + model, _ = model.Update(tea.KeyPressMsg{Code: 'f'}) + if model.mode != modePrompt { + t.Fatalf("expected prompt mode, got %d", model.mode) + } + model.prompt.SetValue("verify the fix") + model, _ = model.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + if model.mode != modeReview { + t.Fatalf("expected review mode, got %d", model.mode) + } + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeResult || model.result.ID != "prompt-1" { + t.Fatalf("unexpected result: %#v", model) + } +} diff --git a/tui/screens/workbenches/view.go b/tui/screens/workbenches/view.go new file mode 100644 index 000000000..0d0ab7cf5 --- /dev/null +++ b/tui/screens/workbenches/view.go @@ -0,0 +1,87 @@ +package workbenches + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + body, help := m.bodyAndHelp(page.ContentWidth(width)) + title := "Workbenches" + if m.detail.ID != "" && m.mode != modeList { + title += " · " + m.detail.WorkbenchName + } + return page.Render(m.theme, width, height, title, m.status(), body, help) +} +func (m Model) status() string { + if m.loading { + return m.theme.Warning.Render("◌ working") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + if m.err != nil { + return m.theme.Danger.Render("✗ failed") + } + return m.theme.Success.Render(fmt.Sprintf("%d jobs", len(m.page.Items))) +} +func (m Model) bodyAndHelp(width int) (string, string) { + if m.mode == modeFilter { + return page.Panel(m.theme, "Filter workbench jobs", []string{m.filterInput.View()}, width, 5, true), "enter apply · esc cancel" + } + if m.needsAuth { + return page.Panel(m.theme, "Console required", []string{"Connect a Console profile to browse workbench jobs.", "", "Press c to open Access."}, width, 7, true), "c connect · esc AI hub" + } + if m.mode == modePrompt { + return page.Panel(m.theme, "Follow-up prompt", []string{"Job " + m.detail.ID, "Workbench " + m.detail.WorkbenchName, "", m.prompt.View()}, width, 11, true), "ctrl+s review · esc cancel" + } + if m.mode == modeReview { + return page.Panel(m.theme, "Review follow-up", []string{"Workbench " + m.detail.WorkbenchName, "Job " + m.detail.ID, "", "Prompt", m.prompt.Value(), "", m.theme.Muted.Render("This queues the prompt for the selected running or settled job.")}, width, 12, true), "enter queue · esc edit" + } + if m.mode == modeOperating { + return page.Panel(m.theme, "Queue follow-up", []string{m.theme.Warning.Render("◌ Queueing prompt…")}, width, 7, true), "ctrl+c quit" + } + if m.mode == modeResult { + lines := []string{m.theme.Success.Render("✓ Prompt queued"), "", "Prompt ID " + m.result.ID, "Job ID " + m.result.WorkbenchID, "Dequeues " + m.result.DequeueAt} + if m.err != nil { + lines = []string{m.theme.Danger.Render("✗ Follow-up failed"), "", m.err.Error()} + } + return page.Panel(m.theme, "Result", lines, width, 10, true), "enter/esc detail" + } + if m.mode == modeDetail { + return page.Panel(m.theme, "Job detail", []string{"Workbench " + value(m.detail.WorkbenchName), "Status " + value(m.detail.Status), "Prompt " + value(m.detail.Prompt), "Job ID " + m.detail.ID, "Started " + value(m.detail.InsertedAt)}, width, 11, true), "f follow up · esc list" + } + if m.loading && len(m.page.Items) == 0 { + return page.Panel(m.theme, "Recent workbench jobs", []string{"◌ Loading workbench jobs…"}, width, 14, true), "esc AI hub" + } + if m.err != nil { + return page.Panel(m.theme, "Recent workbench jobs", []string{"Unable to load workbench jobs", m.err.Error()}, width, 14, true), "r retry · esc AI hub" + } + lines := []string{m.theme.Muted.Render(" WORKBENCH STATUS PROMPT")} + for i, item := range m.page.Items { + cursor := " " + if i == m.cursor { + cursor = "› " + } + row := fmt.Sprintf("%s%-19s %-12s %s", cursor, value(item.WorkbenchName), value(item.Status), value(item.Prompt)) + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + if len(m.page.Items) == 0 { + lines = append(lines, "No workbench jobs found.") + } + return page.Panel(m.theme, "Recent workbench jobs", lines, width, 14, true), "↑/↓ select · enter detail · / filter · r refresh · esc AI hub" +} +func value(v string) string { + if strings.TrimSpace(v) == "" { + return "—" + } + return v +} From abbcd770eaa3a0f55ce2b6b9553240b410c5a95c Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Mon, 3 Aug 2026 16:41:02 +0200 Subject: [PATCH 15/16] start plural up --- pkg/bridge/up/git.go | 15 + pkg/bridge/up/probe.go | 72 + pkg/bridge/up/scm.go | 20 + pkg/bridge/up/up.go | 113 ++ pkg/bridge/up/up_test.go | 73 + pkg/bridge/up/validate.go | 43 + pkg/provider/aws.go | 12 + pkg/provider/azure_survey.go | 44 +- pkg/provider/setup.go | 80 ++ pkg/provider/setup_aws.go | 62 + pkg/provider/setup_azure.go | 82 ++ pkg/provider/setup_byok.go | 30 + pkg/provider/setup_gcp.go | 87 ++ pkg/provider/setup_test.go | 54 + tui/app/model.go | 7 + tui/app/model_test.go | 5 + tui/app/view.go | 2 + tui/navigation/navigation.go | 1 + tui/screens/up/model.go | 1243 +++++++++++++++++ tui/screens/up/model_test.go | 526 +++++++ tui/screens/up/testdata/up-clitip-120.golden | 30 + tui/screens/up/testdata/up-clitip-80.golden | 24 + tui/screens/up/testdata/up-flow-120.golden | 30 + tui/screens/up/testdata/up-flow-80.golden | 24 + tui/screens/up/testdata/up-form-120.golden | 30 + tui/screens/up/testdata/up-form-80.golden | 24 + tui/screens/up/testdata/up-git-120.golden | 30 + tui/screens/up/testdata/up-git-80.golden | 28 + tui/screens/up/testdata/up-ignore-120.golden | 30 + tui/screens/up/testdata/up-ignore-80.golden | 24 + .../up/testdata/up-provider-120.golden | 30 + tui/screens/up/testdata/up-provider-80.golden | 24 + tui/screens/up/testdata/up-scm-120.golden | 30 + tui/screens/up/testdata/up-scm-80.golden | 24 + .../up/testdata/up-selected-120.golden | 30 + tui/screens/up/testdata/up-selected-80.golden | 24 + tui/screens/up/view.go | 501 +++++++ tui/screens/welcome/groups.go | 14 +- tui/screens/welcome/model_test.go | 23 +- .../welcome/testdata/welcome-120.golden | 14 +- .../welcome/testdata/welcome-80.golden | 14 +- .../welcome/testdata/welcome-popup-80.golden | 14 +- tui/screens/welcome/view.go | 4 +- 43 files changed, 3546 insertions(+), 45 deletions(-) create mode 100644 pkg/bridge/up/git.go create mode 100644 pkg/bridge/up/probe.go create mode 100644 pkg/bridge/up/scm.go create mode 100644 pkg/bridge/up/up.go create mode 100644 pkg/bridge/up/up_test.go create mode 100644 pkg/bridge/up/validate.go create mode 100644 pkg/provider/setup.go create mode 100644 pkg/provider/setup_aws.go create mode 100644 pkg/provider/setup_azure.go create mode 100644 pkg/provider/setup_byok.go create mode 100644 pkg/provider/setup_gcp.go create mode 100644 pkg/provider/setup_test.go create mode 100644 tui/screens/up/model.go create mode 100644 tui/screens/up/model_test.go create mode 100644 tui/screens/up/testdata/up-clitip-120.golden create mode 100644 tui/screens/up/testdata/up-clitip-80.golden create mode 100644 tui/screens/up/testdata/up-flow-120.golden create mode 100644 tui/screens/up/testdata/up-flow-80.golden create mode 100644 tui/screens/up/testdata/up-form-120.golden create mode 100644 tui/screens/up/testdata/up-form-80.golden create mode 100644 tui/screens/up/testdata/up-git-120.golden create mode 100644 tui/screens/up/testdata/up-git-80.golden create mode 100644 tui/screens/up/testdata/up-ignore-120.golden create mode 100644 tui/screens/up/testdata/up-ignore-80.golden create mode 100644 tui/screens/up/testdata/up-provider-120.golden create mode 100644 tui/screens/up/testdata/up-provider-80.golden create mode 100644 tui/screens/up/testdata/up-scm-120.golden create mode 100644 tui/screens/up/testdata/up-scm-80.golden create mode 100644 tui/screens/up/testdata/up-selected-120.golden create mode 100644 tui/screens/up/testdata/up-selected-80.golden create mode 100644 tui/screens/up/view.go diff --git a/pkg/bridge/up/git.go b/pkg/bridge/up/git.go new file mode 100644 index 000000000..591bfb896 --- /dev/null +++ b/pkg/bridge/up/git.go @@ -0,0 +1,15 @@ +package up + +import ( + "os/exec" +) + +// InGitRepo reports whether the current directory is inside a git work tree. +// Same check as wkspace.Preflight before the PLURAL_INIT_AFFIRM_SETUP_REPO prompt. +func InGitRepo() bool { + cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree") + return cmd.Run() == nil +} + +// SetupGitPrompt is the Affirm message from HandleInitWithProject. +const SetupGitPrompt = "You're attempting to setup plural outside a git repository. Would you like us to set one up for you here?" diff --git a/pkg/bridge/up/probe.go b/pkg/bridge/up/probe.go new file mode 100644 index 000000000..540f8634a --- /dev/null +++ b/pkg/bridge/up/probe.go @@ -0,0 +1,72 @@ +package up + +import ( + "context" + + "github.com/pluralsh/plural-cli/pkg/provider" +) + +// ProbeResult is the credential-checked provider setup payload for the Up wizard. +type ProbeResult struct { + Summary string + Fields []FormField +} + +// Prober checks cloud credentials and loads select options via provider.CloudSetup. +type Prober interface { + Probe(ctx context.Context, providerID string) (ProbeResult, error) + FieldOptions(ctx context.Context, providerID, fieldKey string, values map[string]string) ([]string, error) + Preflights(ctx context.Context, providerID string, values map[string]string) error +} + +// LiveProber delegates to each provider's CloudSetup implementation. +type LiveProber struct{} + +// DefaultProber returns the live cloud prober. +func DefaultProber() Prober { return LiveProber{} } + +// Probe validates credentials and returns form fields with select options filled. +func (LiveProber) Probe(ctx context.Context, providerID string) (ProbeResult, error) { + setup, err := provider.Setup(providerID) + if err != nil { + return ProbeResult{}, err + } + res, err := setup.Probe(ctx) + if err != nil { + return ProbeResult{}, err + } + return ProbeResult{Summary: res.Summary, Fields: toFormFields(res.Fields)}, nil +} + +// FieldOptions refreshes a dependent select via the provider CloudSetup. +func (LiveProber) FieldOptions(ctx context.Context, providerID, fieldKey string, values map[string]string) ([]string, error) { + setup, err := provider.Setup(providerID) + if err != nil { + return nil, err + } + return setup.Options(ctx, fieldKey, values) +} + +// Preflights runs provider.Preflights() for the surveyed values. +func (LiveProber) Preflights(ctx context.Context, providerID string, values map[string]string) error { + setup, err := provider.Setup(providerID) + if err != nil { + return err + } + return setup.Preflights(ctx, values) +} + +func toFormFields(in []provider.SetupField) []FormField { + out := make([]FormField, len(in)) + for i, f := range in { + out[i] = FormField{ + Key: f.Key, + Label: f.Label, + Placeholder: f.Placeholder, + Default: f.Default, + Required: f.Required, + Options: f.Options, + } + } + return out +} diff --git a/pkg/bridge/up/scm.go b/pkg/bridge/up/scm.go new file mode 100644 index 000000000..227e609a2 --- /dev/null +++ b/pkg/bridge/up/scm.go @@ -0,0 +1,20 @@ +package up + +// SCMProvider is one option from scm.Setup's first survey. +type SCMProvider struct { + ID string + Title string + Blurb string +} + +// SCMProviders returns the SCM choices offered by pkg/scm.Setup. +func SCMProviders() []SCMProvider { + return []SCMProvider{ + {ID: "github", Title: "GitHub", Blurb: "authenticate · create repo · clone"}, + {ID: "gitlab", Title: "GitLab", Blurb: "authenticate · create repo · clone"}, + {ID: "bitbucket", Title: "Bitbucket", Blurb: "authenticate · create repo · clone"}, + } +} + +// DomainNoneOption matches cmd/command/up noneOption for skipping app domain. +const DomainNoneOption = "None" diff --git a/pkg/bridge/up/up.go b/pkg/bridge/up/up.go new file mode 100644 index 000000000..d2e18a070 --- /dev/null +++ b/pkg/bridge/up/up.go @@ -0,0 +1,113 @@ +// Package up exposes credential-free setup helpers for the Up wizard. +package up + +import ( + "github.com/pluralsh/plural-cli/pkg/api" + "github.com/pluralsh/plural-cli/pkg/provider" +) + +// Flow is one top-level plural-up path (maps to --cloud / --dry-run). +type Flow struct { + ID string + Title string + Blurb string + Cloud bool + DryRun bool +} + +// NeedsProvider is true when this flow runs the self-hosted provider survey +// (CLI GetProvider). Cloud paths pick a Console instance instead. +func (f Flow) NeedsProvider() bool { + return f.ID == "self-hosted" +} + +// CLI returns the equivalent plural up invocation for this flow. +func (f Flow) CLI(ignorePreflights bool) string { + cmd := "plural up" + if f.Cloud { + cmd += " --cloud" + } + if f.DryRun { + cmd += " --dry-run" + } + if ignorePreflights { + cmd += " --ignore-preflights" + } + return cmd +} + +// Flows returns the setup modes offered on the first Up screen. +func Flows() []Flow { + return []Flow{ + { + ID: "self-hosted", + Title: "Self-hosted", + Blurb: "pick a cloud provider · provision management cluster", + }, + { + ID: "cloud", + Title: "Plural Cloud", + Blurb: "pick a Console instance (--cloud) · coming next", + Cloud: true, + }, + { + ID: "dry-run", + Title: "Dry-run", + Blurb: "generate repo only (--dry-run) · coming next", + DryRun: true, + }, + { + ID: "cloud-dry-run", + Title: "Cloud · dry-run", + Blurb: "Plural Cloud generate only · coming next", + Cloud: true, + DryRun: true, + }, + } +} + +// Provider is one cloud target selectable for self-hosted up. +type Provider struct { + ID string + Title string + Blurb string +} + +// CloudProviders returns the providers offered by self-hosted `plural up` init. +func CloudProviders() []Provider { + out := make([]Provider, 0, 4) + for _, s := range provider.Setups() { + switch s.Name() { + case api.ProviderAWS: + out = append(out, Provider{ID: s.Name(), Title: "AWS", Blurb: "Amazon Web Services"}) + case api.ProviderAzure: + out = append(out, Provider{ID: s.Name(), Title: "Azure", Blurb: "Microsoft Azure"}) + case api.ProviderGCP: + out = append(out, Provider{ID: s.Name(), Title: "GCP", Blurb: "Google Cloud Platform"}) + case api.BYOK: + out = append(out, Provider{ID: s.Name(), Title: "BYOK", Blurb: "bring your own Kubernetes cluster"}) + } + } + return out +} + +// FormField describes one provider-setup input (CLI survey parity). +// When Options is non-empty the TUI renders a select (survey.Select parity). +type FormField struct { + Key string + Label string + Placeholder string + Default string + Required bool + Options []string +} + +// ProviderFormFields returns the self-hosted init fields for a provider. +// Sourced from each provider's CloudSetup schema. +func ProviderFormFields(providerID string) []FormField { + setup, err := provider.Setup(providerID) + if err != nil { + return nil + } + return toFormFields(setup.Schema()) +} diff --git a/pkg/bridge/up/up_test.go b/pkg/bridge/up/up_test.go new file mode 100644 index 000000000..5c34e57b4 --- /dev/null +++ b/pkg/bridge/up/up_test.go @@ -0,0 +1,73 @@ +package up + +import "testing" + +func TestFlows(t *testing.T) { + flows := Flows() + if len(flows) != 4 { + t.Fatalf("len = %d", len(flows)) + } + if !flows[0].NeedsProvider() { + t.Fatal("self-hosted should need provider") + } + for _, f := range flows[1:] { + if f.NeedsProvider() { + t.Fatalf("%s should not need provider list", f.ID) + } + } + want := []struct { + id, cli string + cloud, dryRun bool + }{ + {"self-hosted", "plural up", false, false}, + {"cloud", "plural up --cloud", true, false}, + {"dry-run", "plural up --dry-run", false, true}, + {"cloud-dry-run", "plural up --cloud --dry-run", true, true}, + } + for i, w := range want { + f := flows[i] + if f.ID != w.id || f.Cloud != w.cloud || f.DryRun != w.dryRun || f.CLI(false) != w.cli { + t.Fatalf("flows[%d] = %#v cli=%q", i, f, f.CLI(false)) + } + } + if got := flows[0].CLI(true); got != "plural up --ignore-preflights" { + t.Fatalf("ignore-preflights cli = %q", got) + } + if got := flows[1].CLI(true); got != "plural up --cloud --ignore-preflights" { + t.Fatalf("cloud ignore cli = %q", got) + } +} + +func TestCloudProviders(t *testing.T) { + providers := CloudProviders() + if len(providers) != 4 { + t.Fatalf("len = %d", len(providers)) + } + want := []string{"aws", "azure", "gcp", "byok"} + for i, id := range want { + if providers[i].ID != id || providers[i].Title == "" { + t.Fatalf("providers[%d] = %#v", i, providers[i]) + } + } +} + +func TestProviderFormFields(t *testing.T) { + if got := ProviderFormFields("aws"); len(got) != 2 || got[0].Key != "cluster" || got[1].Key != "region" { + t.Fatalf("aws fields = %#v", got) + } + if got := ProviderFormFields("byok"); len(got) != 4 { + t.Fatalf("byok fields = %#v", got) + } +} + +func TestValidateProviderForm(t *testing.T) { + if err := ValidateProviderForm("aws", map[string]string{"cluster": "demo", "region": "us-east-2"}); err != nil { + t.Fatalf("valid aws: %v", err) + } + if err := ValidateProviderForm("aws", map[string]string{"cluster": "this-name-is-way-too-long", "region": "us-east-2"}); err == nil { + t.Fatal("expected cluster length error") + } + if err := ValidateProviderForm("aws", map[string]string{"cluster": "demo", "region": ""}); err == nil { + t.Fatal("expected region required") + } +} diff --git a/pkg/bridge/up/validate.go b/pkg/bridge/up/validate.go new file mode 100644 index 000000000..c435651e7 --- /dev/null +++ b/pkg/bridge/up/validate.go @@ -0,0 +1,43 @@ +package up + +import ( + "fmt" + "strings" +) + +func validateClusterName(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("cluster name is required") + } + if len(name) > 15 { + return fmt.Errorf("cluster name must be at most 15 characters") + } + // Matches utils.ValidateAlphaNumeric used by provider init. + if len(name) < 2 || name[0] < 'a' || name[0] > 'z' { + return fmt.Errorf("cluster name must start with a lowercase letter") + } + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + continue + } + return fmt.Errorf("cluster name must be lowercase alphanumeric with hyphens") + } + return nil +} + +// ValidateProviderForm checks required self-hosted provider fields. +func ValidateProviderForm(providerID string, values map[string]string) error { + for _, field := range ProviderFormFields(providerID) { + val := strings.TrimSpace(values[field.Key]) + if field.Required && val == "" { + return fmt.Errorf("%s is required", field.Label) + } + if field.Key == "cluster" { + if err := validateClusterName(val); err != nil { + return err + } + } + } + return nil +} diff --git a/pkg/provider/aws.go b/pkg/provider/aws.go index ef3571f5f..33c750c37 100644 --- a/pkg/provider/aws.go +++ b/pkg/provider/aws.go @@ -72,6 +72,18 @@ var ( } ) +// AWSRegions returns the region list used by plural up / provider init. +func AWSRegions() []string { + out := make([]string, len(awsRegions)) + copy(out, awsRegions) + return out +} + +// AWSProfileName returns the active AWS CLI profile name. +func AWSProfileName() string { + return getAWSProfileName() +} + func mkAWS(conf config.Config, dryRun bool) (provider *AWSProvider, err error) { ctx := context.Background() provider = &AWSProvider{} diff --git a/pkg/provider/azure_survey.go b/pkg/provider/azure_survey.go index ea7ce5dc6..ea373a60f 100644 --- a/pkg/provider/azure_survey.go +++ b/pkg/provider/azure_survey.go @@ -11,10 +11,11 @@ import ( "github.com/pluralsh/plural-cli/pkg/utils" ) -const createNewOption = "Create new..." +// CreateNewOption is the survey sentinel for typing a new Azure name. +const CreateNewOption = "Create new..." func filterSurveyOptions(filter string, value string, index int) (include bool) { - if value == createNewOption { + if value == CreateNewOption { return true } @@ -35,7 +36,8 @@ func askCluster() (string, error) { return cluster, nil } -func azureLocations(ctx context.Context, client *armsubscription.SubscriptionsClient, subscriptionID string) ([]string, error) { +// AzureLocations lists subscription locations used by plural up. +func AzureLocations(ctx context.Context, client *armsubscription.SubscriptionsClient, subscriptionID string) ([]string, error) { locations := make([]string, 0) pager := client.NewListLocationsPager(subscriptionID, nil) for pager.More() { @@ -55,7 +57,7 @@ func azureLocations(ctx context.Context, client *armsubscription.SubscriptionsCl } func askAzureLocation(ctx context.Context, client *armsubscription.SubscriptionsClient, subscriptionID string) (string, error) { - options, err := azureLocations(ctx, client, subscriptionID) + options, err := AzureLocations(ctx, client, subscriptionID) if err != nil { return "", err } @@ -71,7 +73,8 @@ func askAzureLocation(ctx context.Context, client *armsubscription.Subscriptions return location, nil } -func azureResourceGroups(ctx context.Context, client *armresources.ResourceGroupsClient) ([]string, error) { +// AzureResourceGroups lists resource groups for the signed-in subscription. +func AzureResourceGroups(ctx context.Context, client *armresources.ResourceGroupsClient) ([]string, error) { groups := make([]string, 0) pager := client.NewListPager(nil) for pager.More() { @@ -90,12 +93,20 @@ func azureResourceGroups(ctx context.Context, client *armresources.ResourceGroup return groups, nil } +// AzureResourceGroupChoices is the plural-up resource-group select list (existing + Create new…). +func AzureResourceGroupChoices(ctx context.Context, client *armresources.ResourceGroupsClient) ([]string, error) { + options, err := AzureResourceGroups(ctx, client) + if err != nil { + return nil, err + } + return append(options, CreateNewOption), nil +} + func askAzureResourceGroup(ctx context.Context, client *armresources.ResourceGroupsClient) (string, error) { - options, err := azureResourceGroups(ctx, client) + options, err := AzureResourceGroupChoices(ctx, client) if err != nil { return "", err } - options = append(options, createNewOption) group := "" if err = survey.AskOne( @@ -105,7 +116,7 @@ func askAzureResourceGroup(ctx context.Context, client *armresources.ResourceGro return "", err } - if group == createNewOption { + if group == CreateNewOption { if err = survey.AskOne(&survey.Input{Message: "Enter resource group name:"}, &group, survey.WithValidator(utils.ValidateResourceGroupName)); err != nil { return "", err } @@ -114,7 +125,8 @@ func askAzureResourceGroup(ctx context.Context, client *armresources.ResourceGro return group, nil } -func azureStorageAccounts(ctx context.Context, client *armstorage.AccountsClient) ([]string, error) { +// AzureStorageAccounts lists storage accounts for the signed-in subscription. +func AzureStorageAccounts(ctx context.Context, client *armstorage.AccountsClient) ([]string, error) { accounts := make([]string, 0) pager := client.NewListPager(nil) for pager.More() { @@ -133,12 +145,20 @@ func azureStorageAccounts(ctx context.Context, client *armstorage.AccountsClient return accounts, nil } +// AzureStorageAccountChoices is the plural-up storage-account select list (existing + Create new…). +func AzureStorageAccountChoices(ctx context.Context, client *armstorage.AccountsClient) ([]string, error) { + options, err := AzureStorageAccounts(ctx, client) + if err != nil { + return nil, err + } + return append(options, CreateNewOption), nil +} + func askAzureStorageAccount(ctx context.Context, client *armstorage.AccountsClient) (string, error) { - options, err := azureStorageAccounts(ctx, client) + options, err := AzureStorageAccountChoices(ctx, client) if err != nil { return "", err } - options = append(options, createNewOption) account := "" if err = survey.AskOne( @@ -148,7 +168,7 @@ func askAzureStorageAccount(ctx context.Context, client *armstorage.AccountsClie return "", err } - if account == createNewOption { + if account == CreateNewOption { if err = survey.AskOne(&survey.Input{Message: "Enter globally unique storage account name:"}, &account, survey.WithValidator(utils.ValidateStorageAccountName)); err != nil { return "", err } diff --git a/pkg/provider/setup.go b/pkg/provider/setup.go new file mode 100644 index 000000000..cc8885b92 --- /dev/null +++ b/pkg/provider/setup.go @@ -0,0 +1,80 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/pluralsh/plural-cli/pkg/api" +) + +// SetupField is one plural-up survey field (input or select). +// Options non-empty means survey.Select parity. +type SetupField struct { + Key string + Label string + Placeholder string + Default string + Required bool + Options []string +} + +// SetupResult is the credential-checked setup payload for a cloud provider. +type SetupResult struct { + Summary string + Fields []SetupField +} + +// CloudSetup is implemented by each cloud provider for the non-interactive +// half of plural up init: verify credentials and load select options +// (regions, projects, …) the same way mkAWS / mkAzure / GCP survey do. +// +// CLI order (common.RunPreflights): Probe/survey first, then Preflights(). +// --ignore-preflights only skips Preflights() failures after a successful survey. +type CloudSetup interface { + Name() string + Schema() []SetupField + Probe(ctx context.Context) (SetupResult, error) + Options(ctx context.Context, fieldKey string, values map[string]string) ([]string, error) + // Preflights runs provider.Preflights() checks after the survey fields are known. + Preflights(ctx context.Context, values map[string]string) error +} + +// Setup returns the CloudSetup implementation for a provider id (aws/azure/gcp/byok). +func Setup(name string) (CloudSetup, error) { + switch name { + case api.ProviderAWS: + return awsSetup{}, nil + case api.ProviderAzure: + return azureSetup{}, nil + case api.ProviderGCP: + return gcpSetup{}, nil + case api.BYOK: + return byokSetup{}, nil + default: + return nil, fmt.Errorf("unknown provider %q", name) + } +} + +// Setups returns CloudSetup for every self-hosted up provider. +func Setups() []CloudSetup { + return []CloudSetup{awsSetup{}, azureSetup{}, gcpSetup{}, byokSetup{}} +} + +func withOptions(fields []SetupField, key string, options []string) []SetupField { + out := make([]SetupField, len(fields)) + copy(out, fields) + for i := range out { + if out[i].Key == key { + out[i].Options = options + } + } + return out +} + +func truncateMiddle(v string, n int) string { + if n < 8 || len(v) <= n { + return v + } + keep := (n - 1) / 2 + return v[:keep] + "…" + v[len(v)-keep:] +} diff --git a/pkg/provider/setup_aws.go b/pkg/provider/setup_aws.go new file mode 100644 index 000000000..9588ab388 --- /dev/null +++ b/pkg/provider/setup_aws.go @@ -0,0 +1,62 @@ +package provider + +import ( + "context" + "fmt" + "strings" + + "github.com/samber/lo" +) + +type awsSetup struct{} + +func (awsSetup) Name() string { return "aws" } + +func (awsSetup) Schema() []SetupField { + return []SetupField{ + {Key: "cluster", Label: "Cluster name", Placeholder: "max 15 chars", Required: true}, + {Key: "region", Label: "Region", Placeholder: "e.g. us-east-2", Default: "us-east-2", Required: true}, + } +} + +func (s awsSetup) Probe(ctx context.Context) (SetupResult, error) { + _, identity, err := GetAWSCallerIdentity(ctx) + if err != nil { + return SetupResult{}, fmt.Errorf("AWS credentials: %w", err) + } + return SetupResult{ + Summary: fmt.Sprintf("AWS profile %s · account %s · %s", + AWSProfileName(), + lo.FromPtr(identity.Account), + truncateMiddle(lo.FromPtr(identity.Arn), 48), + ), + Fields: withOptions(s.Schema(), "region", AWSRegions()), + }, nil +} + +func (awsSetup) Options(_ context.Context, fieldKey string, _ map[string]string) ([]string, error) { + if fieldKey == "region" { + return AWSRegions(), nil + } + return nil, nil +} + +// Preflights runs the same IAM permission check as AWSProvider.Preflights(). +func (awsSetup) Preflights(ctx context.Context, values map[string]string) error { + iamSession, _, err := GetAWSCallerIdentity(ctx) + if err != nil { + return err + } + prov := &AWSProvider{ + Clus: strings.TrimSpace(values["cluster"]), + Reg: strings.TrimSpace(values["region"]), + goContext: &ctx, + ctx: map[string]any{"IAMSession": iamSession}, + } + for _, pre := range prov.Preflights() { + if err := pre.Validate(); err != nil { + return err + } + } + return nil +} diff --git a/pkg/provider/setup_azure.go b/pkg/provider/setup_azure.go new file mode 100644 index 000000000..2a2c79097 --- /dev/null +++ b/pkg/provider/setup_azure.go @@ -0,0 +1,82 @@ +package provider + +import ( + "context" + "fmt" +) + +type azureSetup struct{} + +func (azureSetup) Name() string { return "azure" } + +func (azureSetup) Schema() []SetupField { + return []SetupField{ + {Key: "cluster", Label: "Cluster name", Placeholder: "max 15 chars", Required: true}, + {Key: "location", Label: "Location", Placeholder: "e.g. eastus", Default: "eastus", Required: true}, + {Key: "resourceGroup", Label: "Resource group", Placeholder: "existing or new name", Required: true}, + {Key: "storageAccount", Label: "Storage account", Placeholder: "globally unique name", Required: true}, + } +} + +func (s azureSetup) Probe(ctx context.Context) (SetupResult, error) { + subID, tenID, subName, err := GetAzureAccount() + if err != nil { + return SetupResult{}, fmt.Errorf("Azure login (az account show): %w", err) + } + user, err := GetAzureUser() + if err != nil { + return SetupResult{}, fmt.Errorf("Azure user (az ad signed-in-user show): %w", err) + } + clients, err := GetClientSet(subID) + if err != nil { + return SetupResult{}, fmt.Errorf("Azure clients: %w", err) + } + + locations, err := AzureLocations(ctx, clients.Subscriptions, subID) + if err != nil { + return SetupResult{}, fmt.Errorf("Azure locations: %w", err) + } + groups, err := AzureResourceGroupChoices(ctx, clients.Groups) + if err != nil { + return SetupResult{}, fmt.Errorf("Azure resource groups: %w", err) + } + accounts, err := AzureStorageAccountChoices(ctx, clients.Accounts) + if err != nil { + return SetupResult{}, fmt.Errorf("Azure storage accounts: %w", err) + } + + fields := s.Schema() + fields = withOptions(fields, "location", locations) + fields = withOptions(fields, "resourceGroup", groups) + fields = withOptions(fields, "storageAccount", accounts) + + return SetupResult{ + Summary: fmt.Sprintf("%s · subscription %s (%s) · tenant %s", + user, subName, truncateMiddle(subID, 12), truncateMiddle(tenID, 12)), + Fields: fields, + }, nil +} + +func (azureSetup) Options(ctx context.Context, fieldKey string, _ map[string]string) ([]string, error) { + subID, _, _, err := GetAzureAccount() + if err != nil { + return nil, err + } + clients, err := GetClientSet(subID) + if err != nil { + return nil, err + } + switch fieldKey { + case "location": + return AzureLocations(ctx, clients.Subscriptions, subID) + case "resourceGroup": + return AzureResourceGroupChoices(ctx, clients.Groups) + case "storageAccount": + return AzureStorageAccountChoices(ctx, clients.Accounts) + default: + return nil, nil + } +} + +// Preflights: AzureProvider.Preflights is empty — nothing to run. +func (azureSetup) Preflights(context.Context, map[string]string) error { return nil } diff --git a/pkg/provider/setup_byok.go b/pkg/provider/setup_byok.go new file mode 100644 index 000000000..1d44e99ee --- /dev/null +++ b/pkg/provider/setup_byok.go @@ -0,0 +1,30 @@ +package provider + +import "context" + +type byokSetup struct{} + +func (byokSetup) Name() string { return "byok" } + +func (byokSetup) Schema() []SetupField { + return []SetupField{ + {Key: "cluster", Label: "Cluster name", Placeholder: "name for this cluster", Required: true}, + {Key: "kubeconfig", Label: "Kubeconfig path", Placeholder: "~/.kube/config", Default: "~/.kube/config", Required: true}, + {Key: "database", Label: "Console DB URL", Placeholder: "postgres://user:pass@host:5432/db", Required: true}, + {Key: "domain", Label: "Console domain", Placeholder: "console.example.com", Required: true}, + } +} + +func (s byokSetup) Probe(context.Context) (SetupResult, error) { + return SetupResult{ + Summary: "BYOK uses your local kubeconfig (checked on deploy).", + Fields: s.Schema(), + }, nil +} + +func (byokSetup) Options(context.Context, string, map[string]string) ([]string, error) { + return nil, nil +} + +// Preflights: cluster connectivity needs a configured ByokProvider; deferred to deploy. +func (byokSetup) Preflights(context.Context, map[string]string) error { return nil } diff --git a/pkg/provider/setup_gcp.go b/pkg/provider/setup_gcp.go new file mode 100644 index 000000000..e2405203a --- /dev/null +++ b/pkg/provider/setup_gcp.go @@ -0,0 +1,87 @@ +package provider + +import ( + "context" + "fmt" + "strings" + + "github.com/pluralsh/plural-cli/pkg/api" + "github.com/pluralsh/plural-cli/pkg/manifest" + "github.com/pluralsh/plural-cli/pkg/provider/gcp" +) + +type gcpSetup struct{} + +func (gcpSetup) Name() string { return "gcp" } + +func (gcpSetup) Schema() []SetupField { + return []SetupField{ + {Key: "cluster", Label: "Cluster name", Placeholder: "max 15 chars", Required: true}, + {Key: "project", Label: "GCP project ID", Placeholder: "your GCP project", Required: true}, + {Key: "region", Label: "Region", Placeholder: "e.g. us-east1", Default: "us-east1", Required: true}, + } +} + +func (s gcpSetup) Probe(ctx context.Context) (SetupResult, error) { + email, name, err := gcp.LoggedInUserInfo() + if err != nil { + return SetupResult{}, fmt.Errorf("GCP credentials: %w", err) + } + projects, err := gcp.Projects() + if err != nil { + return SetupResult{}, fmt.Errorf("GCP projects: %w", err) + } + + fields := s.Schema() + fields = withOptions(fields, "project", projects) + for i := range fields { + if fields[i].Key == "region" { + // Same as CLI survey: regions load after project (gcp.Regions). + fields[i].Options = nil + fields[i].Placeholder = "select a project first" + } + } + + summary := email + if name != "" { + summary = fmt.Sprintf("%s (%s)", email, name) + } + return SetupResult{Summary: "GCP · " + summary, Fields: fields}, nil +} + +func (gcpSetup) Options(_ context.Context, fieldKey string, values map[string]string) ([]string, error) { + switch fieldKey { + case "project": + return gcp.Projects() + case "region": + return gcp.Regions(strings.TrimSpace(values["project"])), nil + default: + return nil, nil + } +} + +// Preflights runs enabled-services + permissions checks like gcp.Provider.Preflights. +func (gcpSetup) Preflights(ctx context.Context, values map[string]string) error { + project := strings.TrimSpace(values["project"]) + region := strings.TrimSpace(values["region"]) + cluster := strings.TrimSpace(values["cluster"]) + if project == "" { + return fmt.Errorf("GCP project is required for preflights") + } + prov, err := gcp.NewProvider(gcp.WithManifest(&manifest.ProjectManifest{ + Cluster: cluster, + Project: project, + Provider: api.ProviderGCP, + Region: region, + Context: map[string]interface{}{"Location": region, "BucketLocation": "US"}, + })) + if err != nil { + return err + } + for _, pre := range prov.Preflights() { + if err := pre.Validate(); err != nil { + return err + } + } + return nil +} diff --git a/pkg/provider/setup_test.go b/pkg/provider/setup_test.go new file mode 100644 index 000000000..eb6755c45 --- /dev/null +++ b/pkg/provider/setup_test.go @@ -0,0 +1,54 @@ +package provider + +import ( + "context" + "testing" +) + +func TestSetupRegistry(t *testing.T) { + want := []string{"aws", "azure", "gcp", "byok"} + got := Setups() + if len(got) != len(want) { + t.Fatalf("len = %d", len(got)) + } + for i, id := range want { + if got[i].Name() != id { + t.Fatalf("Setups()[%d] = %q", i, got[i].Name()) + } + s, err := Setup(id) + if err != nil { + t.Fatalf("Setup(%q): %v", id, err) + } + if len(s.Schema()) == 0 { + t.Fatalf("%s schema empty", id) + } + opts, err := s.Options(context.Background(), "missing", nil) + if err != nil { + t.Fatalf("%s Options: %v", id, err) + } + _ = opts + } + if _, err := Setup("nope"); err == nil { + t.Fatal("expected error for unknown provider") + } +} + +func TestAWSSchemaHasRegion(t *testing.T) { + s, err := Setup("aws") + if err != nil { + t.Fatal(err) + } + found := false + for _, f := range s.Schema() { + if f.Key == "region" { + found = true + } + } + if !found { + t.Fatal("aws schema missing region") + } + opts, err := s.Options(context.Background(), "region", nil) + if err != nil || len(opts) < 5 { + t.Fatalf("aws regions = %#v err=%v", opts, err) + } +} diff --git a/tui/app/model.go b/tui/app/model.go index 75e77b39d..66e82969e 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -33,6 +33,7 @@ import ( repositoriesscreen "github.com/pluralsh/plural-cli/tui/screens/repositories" servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" stacksscreen "github.com/pluralsh/plural-cli/tui/screens/stacks" + upscreen "github.com/pluralsh/plural-cli/tui/screens/up" welcomescreen "github.com/pluralsh/plural-cli/tui/screens/welcome" workbenchesscreen "github.com/pluralsh/plural-cli/tui/screens/workbenches" "github.com/pluralsh/plural-cli/tui/theme" @@ -78,6 +79,7 @@ type Model struct { ai aiscreen.Model agents agentsscreen.Model workbenches workbenchesscreen.Model + up upscreen.Model route navigation.Route } @@ -100,6 +102,7 @@ func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { ai: aiscreen.New(t), agents: agentsscreen.New(ctx, dependencies.Agents, t), workbenches: workbenchesscreen.New(ctx, dependencies.Workbenches, t), + up: upscreen.New(ctx, t), route: navigation.Welcome, quit: key.NewBinding( key.WithKeys("ctrl+c"), @@ -144,6 +147,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.agents.Init() case navigation.Workbenches: return m, m.workbenches.Init() + case navigation.Up: + return m, m.up.Init() default: return m, m.welcome.Init() } @@ -191,6 +196,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.agents, cmd = m.agents.Update(msg) case navigation.Workbenches: m.workbenches, cmd = m.workbenches.Update(msg) + case navigation.Up: + m.up, cmd = m.up.Update(msg) default: m.welcome, cmd = m.welcome.Update(msg) } diff --git a/tui/app/model_test.go b/tui/app/model_test.go index 4bf2529cb..e2ff8554a 100644 --- a/tui/app/model_test.go +++ b/tui/app/model_test.go @@ -79,6 +79,11 @@ func TestModelRoutesScreensWithoutRebuildingShell(t *testing.T) { if got := updated.(Model).route; got != navigation.Welcome { t.Fatalf("route = %q", got) } + updated, _ = model.Update(navigation.NavigateMsg{Route: navigation.Up}) + routed = updated.(Model) + if routed.route != navigation.Up || !strings.Contains(routed.View().Content, "Setup mode") { + t.Fatalf("up route/view = %q\n%s", routed.route, routed.View().Content) + } } func TestModelRoutesAIDedicatedScreen(t *testing.T) { diff --git a/tui/app/view.go b/tui/app/view.go index 8798d6baf..b25e59c64 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -39,6 +39,8 @@ func (m Model) View() tea.View { content = m.agents.View(m.width, m.height) case navigation.Workbenches: content = m.workbenches.View(m.width, m.height) + case navigation.Up: + content = m.up.View(m.width, m.height) } view := tea.NewView(content) view.AltScreen = true diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index c391a0f94..05f7a598c 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -24,6 +24,7 @@ const ( Providers Route = "providers" Stacks Route = "stacks" PullRequests Route = "pullrequests" + Up Route = "up" ) // NavigateMsg requests a top-level route change. diff --git a/tui/screens/up/model.go b/tui/screens/up/model.go new file mode 100644 index 000000000..e6c2b6107 --- /dev/null +++ b/tui/screens/up/model.go @@ -0,0 +1,1243 @@ +// Package up implements the plural-up setup wizard. +package up + +import ( + "context" + "fmt" + "strings" + + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + upbridge "github.com/pluralsh/plural-cli/pkg/bridge/up" + "github.com/pluralsh/plural-cli/pkg/provider" + pluralspinner "github.com/pluralsh/plural-cli/tui/components/spinner" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeSelectFlow mode = iota + modeIgnorePreflights + modeSelectProvider + modeProbing + modeProviderForm + modeRunPreflights // provider.Preflights() after survey + modeIgnoreContinue // failed check + --ignore-preflights → Enter to continue + modeSetupGit // CLI Affirm: setup git repo here? (Y/n) + modeSelectSCM // scm.Setup: github / gitlab / bitbucket + modeAppDomain // askAppDomain parity + modeAffirmDeploy // common.AffirmUp before deploy + modeSelected + modeCLITip +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionUp + keyActionDown + keyActionConfirm + keyActionBack +) + +var keyActionKeystrokes = map[keyAction]string{ + keyActionUp: "up", + keyActionDown: "down", + keyActionConfirm: "enter", + keyActionBack: "esc", +} + +func actionForKeystroke(keystroke string) keyAction { + for action, candidate := range keyActionKeystrokes { + if keystroke == candidate { + return action + } + } + return keyActionNone +} + +type yesNoOption struct { + value bool + title string + blurb string +} + +func ignorePreflightOptions() []yesNoOption { + return []yesNoOption{ + {value: false, title: "Run checks", blurb: "stop if provider.Preflights() fail (default)"}, + {value: true, title: "Ignore", blurb: "warn and continue (--ignore-preflights)"}, + } +} + +func setupGitOptions() []yesNoOption { + return []yesNoOption{ + {value: true, title: "Yes", blurb: "create a git repo here (default)"}, + {value: false, title: "No", blurb: "cancel — clone a repo first"}, + } +} + +func setupGitContinueOptions() []yesNoOption { + return []yesNoOption{ + {value: true, title: "Yes", blurb: "continue init (domain → deploy Affirm)"}, + {value: false, title: "No", blurb: "cancel"}, + } +} + +func affirmDeployOptions() []yesNoOption { + return []yesNoOption{ + {value: true, title: "Yes", blurb: "ready to set up the management cluster (default)"}, + {value: false, title: "No", blurb: "cancel deploy — review generated terraform/helm first"}, + } +} + +type probeMsg struct { + result upbridge.ProbeResult + err error +} + +type optionsMsg struct { + fieldKey string + options []string + err error +} + +type domainMsg struct { + options []string + err error + text bool // free-text domain entry (GCP / default) + skip bool // CLI ignored domain setup +} + +type preflightMsg struct { + err error +} + +// Model owns Up-wizard interaction state. +type Model struct { + theme theme.Theme + prober upbridge.Prober + ctx context.Context + + mode mode + flows []upbridge.Flow + providers []upbridge.Provider + cursor int + flow upbridge.Flow + provider upbridge.Provider + + ignorePreflights bool + ignoreAsked bool + + credSummary string + probeWarn string // shown when continuing after ignored preflight failure + inGitRepo bool + scm upbridge.SCMProvider + scms []upbridge.SCMProvider + appDomain string + domainOpts []string + domainNote string // zone fetch ignored (CLI "ignoring domain setup...") + spinner spinner.Model + + formInput textinput.Model + formFields []upbridge.FormField + formIndex int + formValues map[string]string + optionCursor int + freeTextKeys map[string]bool // Azure "Create new…" → free text + err error + gitChecker func() bool + domainLoader func() domainMsg // tests stub zone listing + gitAffirmOpen bool // Esc from domain returns here when Affirm was shown +} + +// New creates the Up wizard starting at setup-flow selection. +func New(ctx context.Context, t theme.Theme) Model { + return NewWithProber(ctx, t, upbridge.DefaultProber()) +} + +// NewWithProber is New with an injectable credential/region prober (tests). +func NewWithProber(ctx context.Context, t theme.Theme, prober upbridge.Prober) Model { + input := textinput.New() + input.Prompt = "› " + input.CharLimit = 256 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + input.SetStyles(styles) + if prober == nil { + prober = upbridge.DefaultProber() + } + return Model{ + theme: t, + prober: prober, + ctx: ctx, + mode: modeSelectFlow, + flows: upbridge.Flows(), + providers: upbridge.CloudProviders(), + scms: upbridge.SCMProviders(), + formInput: input, + spinner: pluralspinner.New(t), + gitChecker: upbridge.InGitRepo, + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case probeMsg: + return m.applyProbe(msg) + case preflightMsg: + return m.applyPreflight(msg) + case optionsMsg: + return m.applyOptions(msg) + case domainMsg: + return m.applyDomain(msg) + case spinner.TickMsg: + if m.mode != modeProbing && m.mode != modeAppDomain && m.mode != modeRunPreflights { + return m, nil + } + if m.mode == modeAppDomain && (len(m.domainOpts) > 0 || m.formInput.Focused()) { + return m, nil + } + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + + key, ok := msg.(tea.KeyPressMsg) + if !ok { + switch m.mode { + case modeProviderForm: + if !m.currentIsSelect() { + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(msg) + return m, cmd + } + case modeAppDomain: + if !m.domainIsSelect() { + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(msg) + return m, cmd + } + } + return m, nil + } + action := actionForKeystroke(key.Keystroke()) + + switch m.mode { + case modeSelected: + return m.updateSelected(action) + case modeIgnoreContinue: + return m.updateIgnoreContinue(action) + case modeAffirmDeploy: + return m.updateAffirmDeploy(action, key) + case modeAppDomain: + return m.updateAppDomain(action, key) + case modeSelectSCM: + return m.updateSelectSCM(action, key) + case modeSetupGit: + return m.updateSetupGit(action, key) + case modeCLITip: + return m.updateCLITip(action) + case modeProviderForm: + return m.updateProviderForm(action, key) + case modeRunPreflights, modeProbing: + if action == keyActionBack { + return m.updateProbing(action) + } + return m, nil + case modeSelectProvider: + return m.updateSelectProvider(action, key) + case modeIgnorePreflights: + return m.updateIgnorePreflights(action, key) + default: + return m.updateSelectFlow(action, key) + } +} + +func (m Model) updateSelectFlow(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + return m, navigation.Navigate(navigation.Welcome) + case keyActionUp: + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case keyActionDown: + if m.cursor < len(m.flows)-1 { + m.cursor++ + } + return m, nil + case keyActionConfirm: + return m.selectFlow(m.flows[m.cursor]) + } + + text := keyText(key) + for i, f := range m.flows { + if text == flowShortcut(f.ID) || text == string(rune('1'+i)) { + m.cursor = i + return m.selectFlow(f) + } + } + return m, nil +} + +func (m Model) updateIgnorePreflights(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + opts := ignorePreflightOptions() + switch action { + case keyActionBack: + m.mode = modeSelectFlow + m.flow = upbridge.Flow{} + m.ignorePreflights = false + m.ignoreAsked = false + m.cursor = 0 + return m, nil + case keyActionUp: + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case keyActionDown: + if m.cursor < len(opts)-1 { + m.cursor++ + } + return m, nil + case keyActionConfirm: + return m.chooseIgnorePreflights(opts[m.cursor].value) + } + + text := keyText(key) + switch text { + case "1", "r": + m.cursor = 0 + return m.chooseIgnorePreflights(false) + case "2", "i": + m.cursor = 1 + return m.chooseIgnorePreflights(true) + } + return m, nil +} + +func (m Model) updateSelectProvider(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.mode = modeIgnorePreflights + m.cursor = 0 + if m.ignorePreflights { + m.cursor = 1 + } + m.err = nil + return m, nil + case keyActionUp: + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case keyActionDown: + if m.cursor < len(m.providers)-1 { + m.cursor++ + } + return m, nil + case keyActionConfirm: + return m.beginProviderForm(m.providers[m.cursor]) + } + + text := keyText(key) + for i, p := range m.providers { + if text == providerShortcut(p.ID) || text == string(rune('1'+i)) { + m.cursor = i + return m.beginProviderForm(p) + } + } + return m, nil +} + +func (m Model) updateProbing(action keyAction) (Model, tea.Cmd) { + if action == keyActionBack { + m.mode = modeSelectProvider + m.provider = upbridge.Provider{} + m.err = nil + m.cursor = 0 + return m, nil + } + return m, nil +} + +func (m Model) updateIgnoreContinue(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionBack: + if len(m.formFields) > 0 { + m.mode = modeProviderForm + m.applyLoadFormField() + return m, nil + } + m.mode = modeSelectProvider + m.cursor = 0 + return m, nil + case keyActionConfirm: + return m.continueAfterIgnoredFailure() + } + return m, nil +} + +// continueAfterIgnoredFailure always opens the git Affirm after Enter so the +// warning gate never feels like a dead end. When already in a work tree, Yes +// skips scm.Setup (CLI Affirm is skipped entirely in that case — we still ask +// once here so the TUI has a clear next step). +func (m Model) continueAfterIgnoredFailure() (Model, tea.Cmd) { + check := m.gitChecker + if check == nil { + check = upbridge.InGitRepo + } + m.inGitRepo = check() + m.err = nil + m.mode = modeSetupGit + m.gitAffirmOpen = true + m.cursor = 0 // Yes + return m, nil +} + +func (m Model) updateProviderForm(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.formInput.Blur() + m.mode = modeSelectProvider + m.provider = upbridge.Provider{} + m.formFields = nil + m.formValues = nil + m.formIndex = 0 + m.credSummary = "" + m.probeWarn = "" + m.freeTextKeys = nil + m.err = nil + m.cursor = 0 + return m, nil + case keyActionConfirm: + if m.currentIsSelect() { + return m.confirmSelectOption() + } + m.saveFormField() + return m.advanceForm() + case keyActionDown: + if m.currentIsSelect() { + opts := m.currentOptions() + if m.optionCursor < len(opts)-1 { + m.optionCursor++ + } + return m, nil + } + m.saveFormField() + if m.formIndex < len(m.formFields)-1 { + m.formIndex++ + m.applyLoadFormField() + } + return m, nil + case keyActionUp: + if m.currentIsSelect() { + if m.optionCursor > 0 { + m.optionCursor-- + } + return m, nil + } + m.saveFormField() + if m.formIndex > 0 { + m.formIndex-- + m.applyLoadFormField() + } + return m, nil + } + if !m.currentIsSelect() { + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd + } + return m, nil +} + +func (m Model) updateSelected(action keyAction) (Model, tea.Cmd) { + if action == keyActionBack { + if !m.flow.DryRun { + m.mode = modeAffirmDeploy + m.cursor = 0 + m.err = nil + return m, nil + } + if len(m.formFields) > 0 { + m.mode = modeProviderForm + m.applyLoadFormField() + return m, nil + } + if m.scm.ID != "" { + m.mode = modeSelectSCM + m.cursor = 0 + return m, nil + } + m.mode = modeSelectProvider + m.cursor = 0 + return m, nil + } + return m, nil +} + +func (m Model) updateSetupGit(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + opts := m.gitAffirmOptions() + switch action { + case keyActionBack: + m.err = nil + if m.probeWarn != "" { + m.mode = modeIgnoreContinue + return m, nil + } + if len(m.formFields) > 0 { + m.mode = modeProviderForm + m.applyLoadFormField() + return m, nil + } + m.mode = modeSelectProvider + m.cursor = 0 + return m, nil + case keyActionUp: + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case keyActionDown: + if m.cursor < len(opts)-1 { + m.cursor++ + } + return m, nil + case keyActionConfirm: + return m.chooseSetupGit(opts[m.cursor].value) + } + text := keyText(key) + switch text { + case "1", "y", "Y": + m.cursor = 0 + return m.chooseSetupGit(true) + case "2", "n", "N": + m.cursor = 1 + return m.chooseSetupGit(false) + } + return m, nil +} + +func (m Model) gitAffirmOptions() []yesNoOption { + if m.inGitRepo { + return setupGitContinueOptions() + } + return setupGitOptions() +} + +func (m Model) updateSelectSCM(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.mode = modeSetupGit + m.cursor = 0 + m.scm = upbridge.SCMProvider{} + return m, nil + case keyActionUp: + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case keyActionDown: + if m.cursor < len(m.scms)-1 { + m.cursor++ + } + return m, nil + case keyActionConfirm: + return m.chooseSCM(m.scms[m.cursor]) + } + text := keyText(key) + for i, s := range m.scms { + if text == string(rune('1'+i)) || text == scmShortcut(s.ID) { + m.cursor = i + return m.chooseSCM(s) + } + } + return m, nil +} + +func (m Model) updateAppDomain(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + if m.scm.ID != "" { + m.mode = modeSelectSCM + m.cursor = 0 + for i, s := range m.scms { + if s.ID == m.scm.ID { + m.cursor = i + break + } + } + return m, nil + } + // Affirm was shown this run — Esc returns there (ignore-continue or !inGit). + if m.gitAffirmOpen { + m.mode = modeSetupGit + m.cursor = 0 + return m, nil + } + if len(m.formFields) > 0 { + m.mode = modeProviderForm + m.applyLoadFormField() + return m, nil + } + m.mode = modeSelectProvider + return m, nil + case keyActionConfirm: + return m.confirmAppDomain() + case keyActionDown: + if m.domainIsSelect() && m.optionCursor < len(m.domainOpts)-1 { + m.optionCursor++ + } + return m, nil + case keyActionUp: + if m.domainIsSelect() && m.optionCursor > 0 { + m.optionCursor-- + } + return m, nil + } + if !m.domainIsSelect() { + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd + } + return m, nil +} + +func (m Model) updateAffirmDeploy(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + opts := affirmDeployOptions() + switch action { + case keyActionBack: + m.err = nil + m.mode = modeAppDomain + if m.domainIsSelect() { + m.formInput.Blur() + } else { + m.formInput.SetValue(m.appDomain) + m.formInput.Focus() + } + return m, nil + case keyActionUp: + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case keyActionDown: + if m.cursor < len(opts)-1 { + m.cursor++ + } + return m, nil + case keyActionConfirm: + return m.chooseAffirmDeploy(opts[m.cursor].value) + } + text := keyText(key) + switch text { + case "1", "y", "Y": + m.cursor = 0 + return m.chooseAffirmDeploy(true) + case "2", "n", "N": + m.cursor = 1 + return m.chooseAffirmDeploy(false) + } + return m, nil +} + +func (m Model) updateCLITip(action keyAction) (Model, tea.Cmd) { + if action == keyActionBack { + m.mode = modeIgnorePreflights + m.cursor = 0 + if m.ignorePreflights { + m.cursor = 1 + } + return m, nil + } + return m, nil +} + +func (m Model) selectFlow(f upbridge.Flow) (Model, tea.Cmd) { + m.flow = f + m.err = nil + m.ignorePreflights = false + m.ignoreAsked = false + m.mode = modeIgnorePreflights + m.cursor = 0 + return m, nil +} + +func (m Model) chooseIgnorePreflights(ignore bool) (Model, tea.Cmd) { + m.ignorePreflights = ignore + m.ignoreAsked = true + m.err = nil + if m.flow.NeedsProvider() { + m.mode = modeSelectProvider + m.cursor = 0 + return m, nil + } + m.mode = modeCLITip + return m, nil +} + +func (m Model) beginProviderForm(p upbridge.Provider) (Model, tea.Cmd) { + m.provider = p + m.mode = modeProbing + m.formFields = nil + m.formValues = nil + m.formIndex = 0 + m.credSummary = "" + m.probeWarn = "" + m.freeTextKeys = nil + m.err = nil + return m, tea.Batch(m.spinner.Tick, m.probeCmd()) +} + +func (m Model) probeCmd() tea.Cmd { + prober := m.prober + providerID := m.provider.ID + ctx := m.ctx + if ctx == nil { + ctx = context.Background() + } + return func() tea.Msg { + res, err := prober.Probe(ctx, providerID) + return probeMsg{result: res, err: err} + } +} + +func (m Model) applyProbe(msg probeMsg) (Model, tea.Cmd) { + if m.mode != modeProbing { + return m, nil + } + if msg.err != nil { + // Credential/GetProvider failure. With --ignore-preflights the CLI warns and + // continues to the git Affirm — it does not open a region survey. + m.probeWarn = msg.err.Error() + if m.ignorePreflights { + m.err = nil + m.formFields = nil + m.formValues = nil + m.mode = modeIgnoreContinue + return m, nil + } + m.err = msg.err + m.mode = modeSelectProvider + m.cursor = 0 + for i, p := range m.providers { + if p.ID == m.provider.ID { + m.cursor = i + break + } + } + return m, nil + } + m.probeWarn = "" + m.credSummary = msg.result.Summary + return m.openForm(msg.result.Fields), nil +} + +func (m Model) submitProviderForm() (Model, tea.Cmd) { + m.formInput.Blur() + if err := upbridge.ValidateProviderForm(m.provider.ID, m.formValues); err != nil { + m.err = err + for i, field := range m.formFields { + if field.Key == "cluster" || (field.Required && strings.TrimSpace(m.formValues[field.Key]) == "") { + m.formIndex = i + m.applyLoadFormField() + break + } + } + return m, nil + } + m.err = nil + m.mode = modeRunPreflights + return m, tea.Batch(m.spinner.Tick, m.preflightCmd()) +} + +func (m Model) preflightCmd() tea.Cmd { + prober := m.prober + providerID := m.provider.ID + values := copyStringMap(m.formValues) + ctx := m.ctx + if ctx == nil { + ctx = context.Background() + } + return func() tea.Msg { + return preflightMsg{err: prober.Preflights(ctx, providerID, values)} + } +} + +func (m Model) applyPreflight(msg preflightMsg) (Model, tea.Cmd) { + if m.mode != modeRunPreflights { + return m, nil + } + if msg.err != nil { + m.probeWarn = msg.err.Error() + if m.ignorePreflights { + // CLI: print warning and continue to git Affirm / Flush. + m.err = nil + m.mode = modeIgnoreContinue + return m, nil + } + m.err = fmt.Errorf("preflight checks failed: %w (rerun with --ignore-preflights to skip)", msg.err) + m.mode = modeProviderForm + m.applyLoadFormField() + return m, nil + } + m.probeWarn = "" + return m.beginGitStep() +} + +func (m Model) beginGitStep() (Model, tea.Cmd) { + check := m.gitChecker + if check == nil { + check = upbridge.InGitRepo + } + m.inGitRepo = check() + m.err = nil + if m.inGitRepo { + m.gitAffirmOpen = false + return m.afterGitReady() + } + // Default Yes — same as survey.Confirm Default: true + m.mode = modeSetupGit + m.gitAffirmOpen = true + m.cursor = 0 + return m, nil +} + +func (m Model) chooseSetupGit(yes bool) (Model, tea.Cmd) { + if !yes { + if m.inGitRepo { + m.err = fmt.Errorf("cancelled continuing plural up init") + return m, nil + } + m.err = fmt.Errorf("you're not in a git repository, either clone one directly or let us set it up for you") + return m, nil + } + m.err = nil + if m.inGitRepo { + // CLI skips Affirm + scm.Setup when already in a work tree. + return m.afterGitReady() + } + // CLI runs scm.Setup() — first prompt is SCM provider select. + m.mode = modeSelectSCM + m.cursor = 0 + m.scm = upbridge.SCMProvider{} + return m, nil +} + +func (m Model) chooseSCM(s upbridge.SCMProvider) (Model, tea.Cmd) { + m.scm = s + m.err = nil + return m.afterGitReady() +} + +func (m Model) afterGitReady() (Model, tea.Cmd) { + // CLI handleUp: after init → askAppDomain (unless dry-run) → Affirm deploy. + if m.flow.DryRun { + m.mode = modeSelected + return m, nil + } + return m.beginAppDomain() +} + +func (m Model) beginAppDomain() (Model, tea.Cmd) { + m.mode = modeAppDomain + m.appDomain = "" + m.domainOpts = nil + m.optionCursor = 0 + m.err = nil + m.formInput.SetValue("") + m.formInput.Placeholder = "leave empty to skip" + m.formInput.Focus() + return m, tea.Batch(m.spinner.Tick, m.loadDomainCmd()) +} + +func (m Model) loadDomainCmd() tea.Cmd { + if m.domainLoader != nil { + loader := m.domainLoader + return func() tea.Msg { return loader() } + } + providerID := m.provider.ID + region := strings.TrimSpace(m.formValues["region"]) + if region == "" { + region = strings.TrimSpace(m.formValues["location"]) + } + resourceGroup := strings.TrimSpace(m.formValues["resourceGroup"]) + ctx := m.ctx + if ctx == nil { + ctx = context.Background() + } + return func() tea.Msg { + switch providerID { + case "aws": + if region == "" { + return domainMsg{text: true} + } + zones, err := provider.AWSHostedZones(ctx, region) + if err != nil { + // CLI: print error, "ignoring domain setup...", continue + return domainMsg{err: err, skip: true} + } + return domainMsg{options: append([]string{upbridge.DomainNoneOption}, zones...)} + case "azure": + if resourceGroup == "" { + return domainMsg{text: true} + } + zones, err := provider.AzureDNSZones(ctx, resourceGroup) + if err != nil { + return domainMsg{err: err, skip: true} + } + if len(zones) == 0 { + return domainMsg{skip: true} + } + return domainMsg{options: append([]string{upbridge.DomainNoneOption}, zones...)} + default: + return domainMsg{text: true} + } + } +} + +func (m Model) applyDomain(msg domainMsg) (Model, tea.Cmd) { + if m.mode != modeAppDomain { + return m, nil + } + if msg.skip { + if msg.err != nil { + m.domainNote = msg.err.Error() + } + m.appDomain = "" + return m.beginAffirmDeploy() + } + if msg.text || len(msg.options) == 0 { + m.domainOpts = nil + m.formInput.Focus() + return m, nil + } + m.domainOpts = msg.options + m.optionCursor = 0 + m.formInput.Blur() + return m, nil +} + +func (m Model) domainIsSelect() bool { + return len(m.domainOpts) > 0 +} + +func (m Model) confirmAppDomain() (Model, tea.Cmd) { + if m.domainIsSelect() { + chosen := m.domainOpts[m.optionCursor] + if chosen == upbridge.DomainNoneOption { + m.appDomain = "" + } else { + m.appDomain = chosen + } + } else { + m.appDomain = strings.TrimSpace(m.formInput.Value()) + } + m.formInput.Blur() + m.err = nil + return m.beginAffirmDeploy() +} + +func (m Model) beginAffirmDeploy() (Model, tea.Cmd) { + m.mode = modeAffirmDeploy + m.cursor = 0 // Yes + m.err = nil + return m, nil +} + +func (m Model) chooseAffirmDeploy(yes bool) (Model, tea.Cmd) { + if !yes { + m.err = fmt.Errorf("cancelled deploy") + return m, nil + } + m.err = nil + m.mode = modeSelected + return m, nil +} + +func scmShortcut(id string) string { + switch id { + case "github": + return "g" + case "gitlab": + return "l" + case "bitbucket": + return "b" + default: + return "" + } +} + +func (m Model) openForm(fields []upbridge.FormField) Model { + m.mode = modeProviderForm + m.formFields = fields + m.formIndex = 0 + m.formValues = map[string]string{} + m.freeTextKeys = map[string]bool{} + for _, field := range fields { + if field.Default != "" { + m.formValues[field.Key] = field.Default + } + } + m.err = nil + m.syncOptionCursor() + m.applyLoadFormField() + return m +} + +func (m Model) applyOptions(msg optionsMsg) (Model, tea.Cmd) { + if msg.err != nil { + m.err = msg.err + return m, nil + } + for i := range m.formFields { + if m.formFields[i].Key == msg.fieldKey { + m.formFields[i].Options = msg.options + if cur := m.formValues[msg.fieldKey]; cur != "" { + found := false + for _, opt := range msg.options { + if opt == cur { + found = true + break + } + } + if !found && len(msg.options) > 0 { + m.formValues[msg.fieldKey] = "" + } + } + if m.formValues[msg.fieldKey] == "" && m.formFields[i].Default != "" { + for _, opt := range msg.options { + if opt == m.formFields[i].Default { + m.formValues[msg.fieldKey] = opt + break + } + } + } + break + } + } + if m.currentFieldKey() == msg.fieldKey { + m.syncOptionCursor() + } + m.err = nil + return m, nil +} + +func (m Model) confirmSelectOption() (Model, tea.Cmd) { + opts := m.currentOptions() + if len(opts) == 0 { + return m, nil + } + if m.optionCursor < 0 || m.optionCursor >= len(opts) { + m.optionCursor = 0 + } + chosen := opts[m.optionCursor] + key := m.currentFieldKey() + if chosen == provider.CreateNewOption { + m.freeTextKeys[key] = true + m.formValues[key] = "" + m.applyLoadFormField() + return m, nil + } + m.formValues[key] = chosen + return m.advanceForm() +} + +func (m Model) advanceForm() (Model, tea.Cmd) { + var refresh tea.Cmd + if m.provider.ID == "gcp" && m.currentFieldKey() == "project" { + refresh = m.refreshFieldOptions("region") + } + if m.formIndex < len(m.formFields)-1 { + m.formIndex++ + m.err = nil + m.applyLoadFormField() + return m, refresh + } + return m.submitProviderForm() +} + +func (m Model) refreshFieldOptions(fieldKey string) tea.Cmd { + prober := m.prober + providerID := m.provider.ID + values := copyStringMap(m.formValues) + ctx := m.ctx + if ctx == nil { + ctx = context.Background() + } + return func() tea.Msg { + opts, err := prober.FieldOptions(ctx, providerID, fieldKey, values) + return optionsMsg{fieldKey: fieldKey, options: opts, err: err} + } +} + +func (m *Model) saveFormField() { + if m.formValues == nil { + m.formValues = map[string]string{} + } + if m.formIndex >= 0 && m.formIndex < len(m.formFields) && !m.currentIsSelect() { + m.formValues[m.formFields[m.formIndex].Key] = strings.TrimSpace(m.formInput.Value()) + } +} + +func (m *Model) applyLoadFormField() { + if m.formIndex < 0 || m.formIndex >= len(m.formFields) { + return + } + m.syncOptionCursor() + if m.currentIsSelect() { + m.formInput.Blur() + return + } + field := m.formFields[m.formIndex] + m.formInput.SetValue(m.formValues[field.Key]) + m.formInput.Placeholder = field.Placeholder + if m.formInput.Placeholder == "" { + m.formInput.Placeholder = field.Label + } + m.formInput.Focus() +} + +func (m *Model) syncOptionCursor() { + opts := m.currentOptions() + cur := m.formValues[m.currentFieldKey()] + m.optionCursor = 0 + for i, opt := range opts { + if opt == cur { + m.optionCursor = i + return + } + } + field := m.currentField() + if field.Default != "" { + for i, opt := range opts { + if opt == field.Default { + m.optionCursor = i + return + } + } + } +} + +func (m Model) currentField() upbridge.FormField { + if m.formIndex < 0 || m.formIndex >= len(m.formFields) { + return upbridge.FormField{} + } + return m.formFields[m.formIndex] +} + +func (m Model) currentFieldKey() string { + return m.currentField().Key +} + +func (m Model) currentOptions() []string { + return m.currentField().Options +} + +func (m Model) currentIsSelect() bool { + key := m.currentFieldKey() + if m.freeTextKeys[key] { + return false + } + return len(m.currentOptions()) > 0 +} + +func (m Model) cli() string { + return m.flow.CLI(m.ignorePreflights) +} + +func keyText(key tea.KeyPressMsg) string { + text := key.Text + if text == "" && key.Code > 0 && key.Code < 128 { + text = string(rune(key.Code)) + } + return text +} + +func flowShortcut(id string) string { + switch id { + case "self-hosted": + return "s" + case "cloud": + return "c" + case "dry-run": + return "d" + case "cloud-dry-run": + return "x" + default: + return "" + } +} + +func providerShortcut(id string) string { + switch id { + case "aws": + return "a" + case "azure": + return "z" + case "gcp": + return "g" + case "byok": + return "b" + default: + return "" + } +} + +func formValue(values map[string]string, key string) string { + if v := strings.TrimSpace(values[key]); v != "" { + return v + } + return "—" +} + +func truncate(v string, n int) string { + if len(v) <= n { + return v + } + return v[:n-1] + "…" +} + +func yesNoLabel(v bool) string { + if v { + return "ignored (--ignore-preflights)" + } + return "run (default)" +} + +func copyStringMap(in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +// SelectedFlow returns the chosen flow id, or empty if none yet. +func (m Model) SelectedFlow() string { return m.flow.ID } + +// SelectedProvider returns the chosen provider id, or empty if none yet. +func (m Model) SelectedProvider() string { return m.provider.ID } + +// Cloud reports whether the chosen flow uses --cloud. +func (m Model) Cloud() bool { return m.flow.Cloud } + +// DryRun reports whether the chosen flow uses --dry-run. +func (m Model) DryRun() bool { return m.flow.DryRun } + +// IgnorePreflights reports whether --ignore-preflights was chosen. +func (m Model) IgnorePreflights() bool { return m.ignorePreflights } diff --git a/tui/screens/up/model_test.go b/tui/screens/up/model_test.go new file mode 100644 index 000000000..3baf1757e --- /dev/null +++ b/tui/screens/up/model_test.go @@ -0,0 +1,526 @@ +package up + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + upbridge "github.com/pluralsh/plural-cli/pkg/bridge/up" + "github.com/pluralsh/plural-cli/pkg/provider" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +func (f fakeProber) Probe(_ context.Context, providerID string) (upbridge.ProbeResult, error) { + if f.err != nil { + return upbridge.ProbeResult{}, f.err + } + fields := upbridge.ProviderFormFields(providerID) + for i := range fields { + switch fields[i].Key { + case "region": + fields[i].Options = []string{"us-east-2", "eu-west-1", "ap-southeast-1"} + case "location": + fields[i].Options = []string{"eastus", "westeurope"} + case "project": + fields[i].Options = []string{"demo-project", "other-project"} + case "resourceGroup": + fields[i].Options = []string{"rg-demo", provider.CreateNewOption} + case "storageAccount": + fields[i].Options = []string{"stordemo", provider.CreateNewOption} + } + } + return upbridge.ProbeResult{ + Summary: "fake credentials ok · account 123456789012", + Fields: fields, + }, nil +} + +func (fakeProber) FieldOptions(_ context.Context, _, fieldKey string, values map[string]string) ([]string, error) { + if fieldKey == "region" && values["project"] != "" { + return []string{"us-east1", "europe-west1"}, nil + } + return nil, nil +} + +func (f fakeProber) Preflights(context.Context, string, map[string]string) error { + return f.preflightErr +} + +type fakeProber struct { + err error + preflightErr error +} + +func testModel(t *testing.T) Model { + t.Helper() + model := NewWithProber(t.Context(), theme.New(colorprofile.ASCII), fakeProber{}) + model.gitChecker = func() bool { return true } + model.domainLoader = func() domainMsg { return domainMsg{text: true} } + return model +} + +func testModelOutsideGit(t *testing.T, prober upbridge.Prober) Model { + t.Helper() + model := NewWithProber(t.Context(), theme.New(colorprofile.ASCII), prober) + model.gitChecker = func() bool { return false } + model.domainLoader = func() domainMsg { return domainMsg{text: true} } + return model +} + +func drainProbe(t *testing.T, model Model, _ tea.Cmd) Model { + t.Helper() + if model.mode != modeProbing { + return model + } + msg := model.probeCmd()() + model, _ = model.Update(msg) + if model.mode == modeProbing { + t.Fatalf("still probing after probeMsg: %#v", msg) + } + return model +} + +func drainPreflight(t *testing.T, model Model) Model { + t.Helper() + if model.mode != modeRunPreflights { + return model + } + msg := model.preflightCmd()() + model, _ = model.Update(msg) + return model +} + +func drainDomain(t *testing.T, model Model) Model { + t.Helper() + if model.mode != modeAppDomain { + return model + } + msg := model.loadDomainCmd()() + model, _ = model.Update(msg) + return model +} + +func finishToSelected(t *testing.T, model Model) Model { + t.Helper() + model = drainPreflight(t, model) + if model.mode == modeIgnoreContinue { + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + } + if model.mode == modeSetupGit { + model, _ = model.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + } + if model.mode == modeSelectSCM { + model, _ = model.Update(tea.KeyPressMsg{Code: 'g', Text: "g"}) + } + model = drainDomain(t, model) + if model.mode == modeAppDomain { + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + } + if model.mode == modeAffirmDeploy { + model, _ = model.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + } + if model.mode != modeSelected { + t.Fatalf("expected selected, got %d err=%v", model.mode, model.err) + } + return model +} + +func selectAWSForm(t *testing.T) Model { + t.Helper() + model := testModel(t) + model, _ = model.Update(tea.KeyPressMsg{Code: 's', Text: "s"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'i', Text: "i"}) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + model = drainProbe(t, model, cmd) + if model.mode != modeProviderForm || model.SelectedProvider() != "aws" { + t.Fatalf("after aws probe = mode=%d provider=%q err=%v", model.mode, model.SelectedProvider(), model.err) + } + return model +} + +func TestSelfHostedProviderFormFlow(t *testing.T) { + model := testModel(t) + if model.mode != modeSelectFlow { + t.Fatalf("mode = %d", model.mode) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: 's', Text: "s"}) + if model.mode != modeIgnorePreflights || model.SelectedFlow() != "self-hosted" { + t.Fatalf("after self-hosted = mode=%d flow=%q", model.mode, model.SelectedFlow()) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: 'i', Text: "i"}) + if model.mode != modeSelectProvider || !model.IgnorePreflights() { + t.Fatalf("after ignore = mode=%d ignore=%v", model.mode, model.IgnorePreflights()) + } + + model, cmd := model.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + if model.mode != modeProbing { + t.Fatalf("expected probing, got %d", model.mode) + } + model = drainProbe(t, model, cmd) + if model.mode != modeProviderForm || model.SelectedProvider() != "aws" { + t.Fatalf("after aws = mode=%d provider=%q", model.mode, model.SelectedProvider()) + } + if model.credSummary == "" { + t.Fatal("expected credential summary") + } + if model.formValues["region"] != "us-east-2" { + t.Fatalf("default region = %q", model.formValues["region"]) + } + // cluster is text; region is select + model.formInput.SetValue("demo") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // save cluster → region select + if !model.currentIsSelect() { + t.Fatalf("expected region select, field=%q", model.currentFieldKey()) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // pick us-east-2 → git/domain + model = finishToSelected(t, model) + view := model.View(80, 28) + if !strings.Contains(view, "demo") || !strings.Contains(view, "--ignore-preflights") { + t.Fatalf("plan view:\n%s", view) + } + if !strings.Contains(view, "fake credentials") { + t.Fatalf("plan missing creds:\n%s", view) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeAffirmDeploy { + t.Fatalf("esc to deploy affirm = %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeAppDomain { + t.Fatalf("esc to domain = %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeProviderForm { + t.Fatalf("esc to form = %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeSelectProvider { + t.Fatalf("esc to providers = %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeIgnorePreflights { + t.Fatalf("esc to preflights = %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeSelectFlow { + t.Fatalf("esc to flows = %d", model.mode) + } + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Welcome}) { + t.Fatalf("expected welcome navigation") + } +} + +func TestProbeFailureBlocksWithoutIgnore(t *testing.T) { + model := testModelOutsideGit(t, fakeProber{err: context.DeadlineExceeded}) + model, _ = model.Update(tea.KeyPressMsg{Code: 's', Text: "s"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'r', Text: "r"}) // run checks + model, cmd := model.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + model = drainProbe(t, model, cmd) + if model.mode != modeSelectProvider || model.err == nil { + t.Fatalf("expected provider list with error, mode=%d err=%v", model.mode, model.err) + } +} + +func TestProbeFailureContinuesToGitAffirmWithIgnore(t *testing.T) { + model := testModelOutsideGit(t, fakeProber{err: context.DeadlineExceeded}) + model, _ = model.Update(tea.KeyPressMsg{Code: 's', Text: "s"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'i', Text: "i"}) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + model = drainProbe(t, model, cmd) + if model.mode != modeIgnoreContinue { + t.Fatalf("expected ignore-continue gate, got %d warn=%q", model.mode, model.probeWarn) + } + view := model.View(80, 24) + if !strings.Contains(view, "continuing because --ignore-preflights") { + t.Fatalf("missing ignore warning:\n%s", view) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeSetupGit { + t.Fatalf("expected git affirm after enter, got %d", model.mode) + } + if len(model.formFields) != 0 { + t.Fatalf("should not open region form, fields=%v", model.formFields) + } + if !strings.Contains(model.View(80, 28), "outside a git repository") { + t.Fatalf("view:\n%s", model.View(80, 28)) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + if model.mode != modeSelectSCM { + t.Fatalf("after yes = %d err=%v", model.mode, model.err) + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'g', Text: "g"}) + if model.mode != modeAppDomain { + t.Fatalf("after scm expected app domain, got %d", model.mode) + } + model = drainDomain(t, model) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeAffirmDeploy { + t.Fatalf("after domain expected deploy affirm, got %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + if model.mode != modeSelected || model.scm.ID != "github" { + t.Fatalf("after affirm = mode=%d scm=%q", model.mode, model.scm.ID) + } +} + +func TestPreflightFailureContinuesWithIgnore(t *testing.T) { + model := testModel(t) + model.prober = fakeProber{preflightErr: context.DeadlineExceeded} + model, _ = model.Update(tea.KeyPressMsg{Code: 's', Text: "s"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'i', Text: "i"}) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + model = drainProbe(t, model, cmd) + model.formInput.SetValue("demo") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeRunPreflights { + t.Fatalf("expected run preflights, got %d", model.mode) + } + model = drainPreflight(t, model) + if model.mode != modeIgnoreContinue || model.probeWarn == "" { + t.Fatalf("expected ignore-continue after preflight fail, mode=%d warn=%q", model.mode, model.probeWarn) + } + if model.formValues["cluster"] != "demo" { + t.Fatal("should keep form values when preflights fail with ignore") + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeSetupGit { + t.Fatalf("expected git affirm after enter, got %d", model.mode) + } + model = finishToSelected(t, model) +} + +func TestProbeFailureIgnoreInGitStillOpensAffirm(t *testing.T) { + model := testModel(t) + model.prober = fakeProber{err: context.DeadlineExceeded} + model, _ = model.Update(tea.KeyPressMsg{Code: 's', Text: "s"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'i', Text: "i"}) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + model = drainProbe(t, model, cmd) + if model.mode != modeIgnoreContinue { + t.Fatalf("expected ignore-continue, got %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeSetupGit || !model.inGitRepo { + t.Fatalf("expected in-repo git continue screen, mode=%d inGit=%v", model.mode, model.inGitRepo) + } + if !strings.Contains(model.View(80, 28), "Already inside a git work tree") { + t.Fatalf("view:\n%s", model.View(80, 28)) + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + if model.mode != modeAppDomain { + t.Fatalf("yes should continue to app domain when already in git, got %d", model.mode) + } + model = drainDomain(t, model) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeAffirmDeploy { + t.Fatalf("after domain expected deploy affirm, got %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + if model.mode != modeSelected { + t.Fatalf("after affirm expected plan, got %d", model.mode) + } +} + +func TestPreflightFailureBlocksWithoutIgnore(t *testing.T) { + model := testModel(t) + model.prober = fakeProber{preflightErr: context.DeadlineExceeded} + model, _ = model.Update(tea.KeyPressMsg{Code: 's', Text: "s"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'r', Text: "r"}) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + model = drainProbe(t, model, cmd) + model.formInput.SetValue("demo") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = drainPreflight(t, model) + if model.mode != modeProviderForm || model.err == nil { + t.Fatalf("expected form with error, mode=%d err=%v", model.mode, model.err) + } +} + +func TestCloudModeAsksIgnorePreflights(t *testing.T) { + model := testModel(t) + model, _ = model.Update(tea.KeyPressMsg{Code: 'c', Text: "c"}) + if model.mode != modeIgnorePreflights || !model.Cloud() { + t.Fatalf("cloud = mode=%d cloud=%v", model.mode, model.Cloud()) + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'r', Text: "r"}) + if model.mode != modeCLITip || model.IgnorePreflights() { + t.Fatalf("cli tip = mode=%d ignore=%v", model.mode, model.IgnorePreflights()) + } + if !strings.Contains(model.View(80, 24), "plural up --cloud") || strings.Contains(model.View(80, 24), "--ignore-preflights") { + t.Fatalf("cli tip:\n%s", model.View(80, 24)) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeIgnorePreflights { + t.Fatalf("esc = %d", model.mode) + } +} + +func TestProviderFormValidation(t *testing.T) { + model := selectAWSForm(t) + model.formInput.SetValue("this-name-is-way-too-long") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // to region + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // submit + if model.mode != modeProviderForm || model.err == nil { + t.Fatalf("expected validation error, mode=%d err=%v", model.mode, model.err) + } +} + +func TestFormThenGitAffirmOutsideRepo(t *testing.T) { + model := testModelOutsideGit(t, fakeProber{}) + model, _ = model.Update(tea.KeyPressMsg{Code: 's', Text: "s"}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + model = drainProbe(t, model, cmd) + model.formInput.SetValue("demo") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = drainPreflight(t, model) + if model.mode != modeSetupGit { + t.Fatalf("expected git affirm after form, got %d", model.mode) + } +} + +func goldenModels(t *testing.T) (flow, ignore, provider, form, selected, cliTip, git, scm Model) { + t.Helper() + flow = testModel(t) + ignore = flow + ignore.mode = modeIgnorePreflights + ignore.flow = ignore.flows[0] + provider = ignore + provider.mode = modeSelectProvider + provider.ignoreAsked = true + form, cmd := provider.beginProviderForm(provider.providers[0]) + form = drainProbe(t, form, cmd) + form.formValues["cluster"] = "demo" + form.applyLoadFormField() + selected = form + selected.mode = modeSelected + selected.ignorePreflights = true + selected.inGitRepo = true + selected.appDomain = "" + selected.formValues = map[string]string{"cluster": "demo", "region": "us-east-2"} + cliTip = flow + cliTip.mode = modeCLITip + cliTip.flow = cliTip.flows[1] + cliTip.ignorePreflights = true + cliTip.ignoreAsked = true + git = flow + git.mode = modeSetupGit + git.flow = git.flows[0] + git.provider = git.providers[0] + git.ignorePreflights = true + git.probeWarn = "AWS credentials: failed" + git.cursor = 0 + scm = git + scm.mode = modeSelectSCM + scm.probeWarn = "" + return +} + +func TestUpGoldens(t *testing.T) { + flowModel, ignoreModel, providerModel, formModel, selected, cliTip, gitModel, scmModel := goldenModels(t) + + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"flow-80", flowModel, 80, 24}, + {"flow-120", flowModel, 120, 30}, + {"ignore-80", ignoreModel, 80, 24}, + {"ignore-120", ignoreModel, 120, 30}, + {"provider-80", providerModel, 80, 24}, + {"provider-120", providerModel, 120, 30}, + {"form-80", formModel, 80, 24}, + {"form-120", formModel, 120, 30}, + {"selected-80", selected, 80, 24}, + {"selected-120", selected, 120, 30}, + {"clitip-80", cliTip, 80, 24}, + {"clitip-120", cliTip, 120, 30}, + {"git-80", gitModel, 80, 28}, + {"git-120", gitModel, 120, 30}, + {"scm-80", scmModel, 80, 24}, + {"scm-120", scmModel, 120, 30}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeView(tc.model.View(tc.width, tc.height)) + golden := filepath.Join("testdata", "up-"+tc.name+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + lines := strings.Split(got, "\n") + if len(lines) != tc.height { + t.Fatalf("height = %d, want %d", len(lines), tc.height) + } + for _, line := range lines { + if w := lipgloss.Width(line); w > tc.width { + t.Fatalf("line width %d > %d: %q", w, tc.width, line) + } + } + }) + } +} + +func TestWriteUpGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + flowModel, ignoreModel, providerModel, formModel, selected, cliTip, gitModel, scmModel := goldenModels(t) + _ = os.MkdirAll("testdata", 0o755) + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"flow-80", flowModel, 80, 24}, + {"flow-120", flowModel, 120, 30}, + {"ignore-80", ignoreModel, 80, 24}, + {"ignore-120", ignoreModel, 120, 30}, + {"provider-80", providerModel, 80, 24}, + {"provider-120", providerModel, 120, 30}, + {"form-80", formModel, 80, 24}, + {"form-120", formModel, 120, 30}, + {"selected-80", selected, 80, 24}, + {"selected-120", selected, 120, 30}, + {"clitip-80", cliTip, 80, 24}, + {"clitip-120", cliTip, 120, 30}, + {"git-80", gitModel, 80, 28}, + {"git-120", gitModel, 120, 30}, + {"scm-80", scmModel, 80, 24}, + {"scm-120", scmModel, 120, 30}, + } { + got := normalizeView(tc.model.View(tc.width, tc.height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "up-"+tc.name+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/up/testdata/up-clitip-120.golden b/tui/screens/up/testdata/up-clitip-120.golden new file mode 100644 index 000000000..07a494e7b --- /dev/null +++ b/tui/screens/up/testdata/up-clitip-120.golden @@ -0,0 +1,30 @@ + Plural Up cloud + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Coming next ──────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Plural Cloud uses a different path than self-hosted. │ + │ Provider selection only applies to self-hosted up. │ + │ │ + │ Mode Plural Cloud │ + │ pick a Console instance (--cloud) · coming next │ + │ Preflights ignored (--ignore-preflights) │ + │ │ + │ Equivalent CLI │ + │ plural up --cloud --ignore-preflights │ + │ │ + │ Cloud / dry-run wizards land in a later step. │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + esc change preflights · ctrl+c quit diff --git a/tui/screens/up/testdata/up-clitip-80.golden b/tui/screens/up/testdata/up-clitip-80.golden new file mode 100644 index 000000000..57d05f69b --- /dev/null +++ b/tui/screens/up/testdata/up-clitip-80.golden @@ -0,0 +1,24 @@ + Plural Up cloud + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Coming next ──────────────────────────────────────────────────────────╮ + │ Plural Cloud uses a different path than self-hosted. │ + │ Provider selection only applies to self-hosted up. │ + │ │ + │ Mode Plural Cloud │ + │ pick a Console instance (--cloud) · coming next │ + │ Preflights ignored (--ignore-preflights) │ + │ │ + │ Equivalent CLI │ + │ plural up --cloud --ignore-preflights │ + │ │ + │ Cloud / dry-run wizards land in a later step. │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + esc change preflights · ctrl+c quit diff --git a/tui/screens/up/testdata/up-flow-120.golden b/tui/screens/up/testdata/up-flow-120.golden new file mode 100644 index 000000000..0a9f21bb5 --- /dev/null +++ b/tui/screens/up/testdata/up-flow-120.golden @@ -0,0 +1,30 @@ + Plural Up step 1 · mode + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Setup mode ───────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Sets up your repository and an initial management cluster. │ + │ Only self-hosted continues with the provider survey for now. │ + │ │ + │ › 1 s Self-hosted pick a cloud provider · provision management cluster │ + │ 2 c Plural Cloud pick a Console instance (--cloud) · coming next │ + │ 3 d Dry-run generate repo only (--dry-run) · coming next │ + │ 4 x Cloud · dry-run Plural Cloud generate only · coming next │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · 1–4 / letter · enter · esc welcome diff --git a/tui/screens/up/testdata/up-flow-80.golden b/tui/screens/up/testdata/up-flow-80.golden new file mode 100644 index 000000000..4dee1b7c6 --- /dev/null +++ b/tui/screens/up/testdata/up-flow-80.golden @@ -0,0 +1,24 @@ + Plural Up step 1 · mode + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Setup mode ───────────────────────────────────────────────────────────╮ + │ Sets up your repository and an initial management cluster. │ + │ Only self-hosted continues with the provider survey for now. │ + │ │ + │ › 1 s Self-hosted pick a cloud provider · provision management c… │ + │ 2 c Plural Cloud pick a Console instance (--cloud) · coming next │ + │ 3 d Dry-run generate repo only (--dry-run) · coming next │ + │ 4 x Cloud · dry-run Plural Cloud generate only · coming next │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · esc welcome diff --git a/tui/screens/up/testdata/up-form-120.golden b/tui/screens/up/testdata/up-form-120.golden new file mode 100644 index 000000000..968b62fb0 --- /dev/null +++ b/tui/screens/up/testdata/up-form-120.golden @@ -0,0 +1,30 @@ + Plural Up step 4 · aws + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Provider setup ───────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Provider AWS │ + │ fake credentials ok · account 123456789012 │ + │ │ + │ › Cluster name │ + │ › demo │ + │ Region us-east-2 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + ↑/↓ · enter next/done · esc providers diff --git a/tui/screens/up/testdata/up-form-80.golden b/tui/screens/up/testdata/up-form-80.golden new file mode 100644 index 000000000..21ea6f381 --- /dev/null +++ b/tui/screens/up/testdata/up-form-80.golden @@ -0,0 +1,24 @@ + Plural Up step 4 · aws + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Provider setup ───────────────────────────────────────────────────────╮ + │ Provider AWS │ + │ fake credentials ok · account 123456789012 │ + │ │ + │ › Cluster name │ + │ › demo │ + │ Region us-east-2 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + ↑/↓ · enter next/done · esc providers diff --git a/tui/screens/up/testdata/up-git-120.golden b/tui/screens/up/testdata/up-git-120.golden new file mode 100644 index 000000000..aa6b2505c --- /dev/null +++ b/tui/screens/up/testdata/up-git-120.golden @@ -0,0 +1,30 @@ + Plural Up step · git repository + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Git repository ───────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Preflight checks failed, but continuing because --ignore-preflights was specified. │ + │ Please note that you may encounter issues later on during provisioning. │ + │ Warning: AWS credentials: failed │ + │ │ + │ You're attempting to setup plural outside a git repository. Would you like us to set one up for you here? │ + │ Same Affirm as plural up / init (PLURAL_INIT_AFFIRM_SETUP_REPO). │ + │ │ + │ › [x] y Yes create a git repo here (default) │ + │ [ ] n No cancel — clone a repo first │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + ↑/↓ · y/n · enter · esc back diff --git a/tui/screens/up/testdata/up-git-80.golden b/tui/screens/up/testdata/up-git-80.golden new file mode 100644 index 000000000..7c9edb091 --- /dev/null +++ b/tui/screens/up/testdata/up-git-80.golden @@ -0,0 +1,28 @@ + Plural Up step · git repository + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Git repository ───────────────────────────────────────────────────────╮ + │ Preflight checks failed, but continuing because --ignore-preflights was… │ + │ Please note that you may encounter issues later on during provisioning. │ + │ Warning: AWS credentials: failed │ + │ │ + │ You're attempting to setup plural outside a git repository. Would you l… │ + │ Same Affirm as plural up / init (PLURAL_INIT_AFFIRM_SETUP_REPO). │ + │ │ + │ › [x] y Yes create a git repo here (default) │ + │ [ ] n No cancel — clone a repo first │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + ↑/↓ · y/n · enter · esc back diff --git a/tui/screens/up/testdata/up-ignore-120.golden b/tui/screens/up/testdata/up-ignore-120.golden new file mode 100644 index 000000000..e8a900d49 --- /dev/null +++ b/tui/screens/up/testdata/up-ignore-120.golden @@ -0,0 +1,30 @@ + Plural Up step 2 · preflights + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Preflight checks ─────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Mode Self-hosted │ + │ plural up │ + │ │ + │ After provider setup, run provider.Preflights() (IAM, permissions, …)? │ + │ Ignore = warn and continue — same as plural up --ignore-preflights. │ + │ Credential login + region survey still run first (CLI GetProvider). │ + │ │ + │ › [x] r Run checks stop if provider.Preflights() fail (default) │ + │ [ ] i Ignore warn and continue (--ignore-preflights) │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · 1/r run · 2/i ignore · enter · esc mode diff --git a/tui/screens/up/testdata/up-ignore-80.golden b/tui/screens/up/testdata/up-ignore-80.golden new file mode 100644 index 000000000..4eccfd32a --- /dev/null +++ b/tui/screens/up/testdata/up-ignore-80.golden @@ -0,0 +1,24 @@ + Plural Up step 2 · preflights + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Preflight checks ─────────────────────────────────────────────────────╮ + │ Mode Self-hosted │ + │ plural up │ + │ │ + │ After provider setup, run provider.Preflights() (IAM, permissions, …)? │ + │ Ignore = warn and continue — same as plural up --ignore-preflights. │ + │ Credential login + region survey still run first (CLI GetProvider). │ + │ │ + │ › [x] r Run checks stop if provider.Preflights() fail (default) │ + │ [ ] i Ignore warn and continue (--ignore-preflights) │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · esc mode diff --git a/tui/screens/up/testdata/up-provider-120.golden b/tui/screens/up/testdata/up-provider-120.golden new file mode 100644 index 000000000..a74ec4ab6 --- /dev/null +++ b/tui/screens/up/testdata/up-provider-120.golden @@ -0,0 +1,30 @@ + Plural Up step 3 · provider + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Cloud provider ───────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Mode Self-hosted │ + │ Preflights run (default) │ + │ plural up │ + │ │ + │ Select the cloud provider (same list as plural up init). │ + │ Next: verify credentials · fetch regions/projects. │ + │ │ + │ › 1 a AWS Amazon Web Services │ + │ 2 z Azure Microsoft Azure │ + │ 3 g GCP Google Cloud Platform │ + │ 4 b BYOK bring your own Kubernetes cluster │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + ↑/↓ select · 1–4 / letter · enter · esc preflights diff --git a/tui/screens/up/testdata/up-provider-80.golden b/tui/screens/up/testdata/up-provider-80.golden new file mode 100644 index 000000000..3eebefd05 --- /dev/null +++ b/tui/screens/up/testdata/up-provider-80.golden @@ -0,0 +1,24 @@ + Plural Up step 3 · provider + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Cloud provider ───────────────────────────────────────────────────────╮ + │ Mode Self-hosted │ + │ Preflights run (default) │ + │ plural up │ + │ │ + │ Select the cloud provider (same list as plural up init). │ + │ Next: verify credentials · fetch regions/projects. │ + │ │ + │ › 1 a AWS Amazon Web Services │ + │ 2 z Azure Microsoft Azure │ + │ 3 g GCP Google Cloud Platform │ + │ 4 b BYOK bring your own Kubernetes cluster │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + ↑/↓ · enter · esc preflights diff --git a/tui/screens/up/testdata/up-scm-120.golden b/tui/screens/up/testdata/up-scm-120.golden new file mode 100644 index 000000000..8bce1f801 --- /dev/null +++ b/tui/screens/up/testdata/up-scm-120.golden @@ -0,0 +1,30 @@ + Plural Up step · scm provider + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › SCM provider ─────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Select the SCM provider to use for your repository: │ + │ Same first prompt as scm.Setup() in plural up. │ + │ │ + │ › 1 g GitHub authenticate · create repo · clone │ + │ 2 l GitLab authenticate · create repo · clone │ + │ 3 b Bitbucket authenticate · create repo · clone │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + + + ↑/↓ · 1–3 / letter · enter · esc git diff --git a/tui/screens/up/testdata/up-scm-80.golden b/tui/screens/up/testdata/up-scm-80.golden new file mode 100644 index 000000000..2fb2553d2 --- /dev/null +++ b/tui/screens/up/testdata/up-scm-80.golden @@ -0,0 +1,24 @@ + Plural Up step · scm provider + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › SCM provider ─────────────────────────────────────────────────────────╮ + │ Select the SCM provider to use for your repository: │ + │ Same first prompt as scm.Setup() in plural up. │ + │ │ + │ › 1 g GitHub authenticate · create repo · clone │ + │ 2 l GitLab authenticate · create repo · clone │ + │ 3 b Bitbucket authenticate · create repo · clone │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + ↑/↓ · 1–3 / letter · enter · esc git diff --git a/tui/screens/up/testdata/up-selected-120.golden b/tui/screens/up/testdata/up-selected-120.golden new file mode 100644 index 000000000..3fcff331b --- /dev/null +++ b/tui/screens/up/testdata/up-selected-120.golden @@ -0,0 +1,30 @@ + Plural Up aws + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Plan ─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ✓ Continuing plural up init │ + │ │ + │ Mode Self-hosted │ + │ Preflights ignored (--ignore-preflights) │ + │ Provider AWS (aws) │ + │ Credentials fake credentials ok · account 123456789012 │ + │ Cluster name demo │ + │ Region us-east-2 │ + │ Git already inside a work tree │ + │ App domain (skipped) │ + │ │ + │ Equivalent CLI │ + │ plural up --ignore-preflights │ + │ │ + │ Affirmed deploy · next: workspace.yaml · generate · deploy. │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + esc back · ctrl+c quit diff --git a/tui/screens/up/testdata/up-selected-80.golden b/tui/screens/up/testdata/up-selected-80.golden new file mode 100644 index 000000000..af8c72aaf --- /dev/null +++ b/tui/screens/up/testdata/up-selected-80.golden @@ -0,0 +1,24 @@ + Plural Up aws + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Plan ─────────────────────────────────────────────────────────────────╮ + │ ✓ Continuing plural up init │ + │ │ + │ Mode Self-hosted │ + │ Preflights ignored (--ignore-preflights) │ + │ Provider AWS (aws) │ + │ Credentials fake credentials ok · account 123456789012 │ + │ Cluster name demo │ + │ Region us-east-2 │ + │ Git already inside a work tree │ + │ App domain (skipped) │ + │ │ + │ Equivalent CLI │ + │ plural up --ignore-preflights │ + │ │ + │ Affirmed deploy · next: workspace.yaml · generate · deploy. │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + esc back · ctrl+c quit diff --git a/tui/screens/up/view.go b/tui/screens/up/view.go new file mode 100644 index 000000000..620c604b9 --- /dev/null +++ b/tui/screens/up/view.go @@ -0,0 +1,501 @@ +package up + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/x/ansi" + + upbridge "github.com/pluralsh/plural-cli/pkg/bridge/up" + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + body, help := m.bodyAndHelp(contentWidth) + return page.Render(m.theme, width, height, "Up", m.headerStatus(), body, help) +} + +func (m Model) headerStatus() string { + switch m.mode { + case modeIgnorePreflights: + return m.theme.Muted.Render("step 2 · preflights") + case modeSelectProvider: + return m.theme.Muted.Render("step 3 · provider") + case modeProbing: + return m.theme.Muted.Render("checking credentials…") + case modeProviderForm: + return m.theme.Muted.Render("step 4 · " + m.provider.ID) + case modeRunPreflights: + return m.theme.Muted.Render("running preflights…") + case modeIgnoreContinue: + return m.theme.Muted.Render("continuing · ignored failures") + case modeSetupGit: + return m.theme.Muted.Render("step · git repository") + case modeSelectSCM: + return m.theme.Muted.Render("step · scm provider") + case modeAppDomain: + return m.theme.Muted.Render("step · app domain") + case modeAffirmDeploy: + return m.theme.Muted.Render("step · deploy Affirm") + case modeSelected: + if m.provider.ID != "" { + return m.theme.Success.Render(m.provider.ID) + } + return m.theme.Success.Render("ready") + case modeCLITip: + return m.theme.Muted.Render(m.flow.ID) + default: + return m.theme.Muted.Render("step 1 · mode") + } +} + +func (m Model) bodyAndHelp(width int) (string, string) { + switch m.mode { + case modeSelected: + lines := []string{ + m.theme.Success.Render("✓ Continuing plural up init"), + "", + "Mode " + m.flow.Title, + "Preflights " + yesNoLabel(m.ignorePreflights), + } + if m.provider.Title != "" { + lines = append(lines, "Provider "+m.provider.Title+" ("+m.provider.ID+")") + } + if m.credSummary != "" { + lines = append(lines, "Credentials "+truncate(m.credSummary, max(20, width-16))) + } + if m.probeWarn != "" { + for _, w := range strings.Split(m.probeWarn, "\n") { + if strings.TrimSpace(w) != "" { + lines = append(lines, m.theme.Danger.Render("Warning: "+truncate(w, max(20, width-12)))) + } + } + } + if m.domainNote != "" { + lines = append(lines, m.theme.Muted.Render("Domain setup ignored: "+truncate(m.domainNote, max(20, width-24)))) + } + for _, field := range m.formFields { + label := field.Label + strings.Repeat(" ", max(1, 12-len(field.Label))) + lines = append(lines, label+" "+formValue(m.formValues, field.Key)) + } + if m.scm.ID != "" { + lines = append(lines, "SCM "+m.scm.Title+" (scm.Setup)") + } else if m.inGitRepo { + lines = append(lines, "Git already inside a work tree") + } + domain := m.appDomain + if domain == "" { + domain = "(skipped)" + } + if !m.flow.DryRun { + lines = append(lines, "App domain "+domain) + } + lines = append(lines, + "", + m.theme.Muted.Render("Equivalent CLI"), + " "+m.cli(), + "", + m.theme.Muted.Render("Affirmed deploy · next: workspace.yaml · generate · deploy."), + ) + return page.Panel(m.theme, "Plan", lines, width, 18, true), "esc back · ctrl+c quit" + case modeCLITip: + lines := []string{ + m.theme.Muted.Render(m.flow.Title + " uses a different path than self-hosted."), + m.theme.Muted.Render("Provider selection only applies to self-hosted up."), + "", + "Mode " + m.flow.Title, + m.theme.Muted.Render(" " + m.flow.Blurb), + "Preflights " + yesNoLabel(m.ignorePreflights), + "", + m.theme.Muted.Render("Equivalent CLI"), + " " + m.cli(), + "", + m.theme.Muted.Render("Cloud / dry-run wizards land in a later step."), + } + return page.Panel(m.theme, "Coming next", lines, width, 14, true), "esc change preflights · ctrl+c quit" + case modeSetupGit: + lines := []string{} + if m.probeWarn != "" { + lines = append(lines, + m.theme.Muted.Render("Preflight checks failed, but continuing because --ignore-preflights was specified."), + m.theme.Muted.Render("Please note that you may encounter issues later on during provisioning."), + m.theme.Danger.Render("Warning: "+truncate(strings.Split(m.probeWarn, "\n")[0], max(20, width-12))), + "", + ) + } + if m.inGitRepo { + lines = append(lines, + m.theme.Muted.Render("Already inside a git work tree — plural up skips Affirm / scm.Setup."), + m.theme.Muted.Render("Continue with the rest of init (domain / workspace)?"), + "", + ) + } else { + lines = append(lines, + m.theme.Muted.Render(upbridge.SetupGitPrompt), + m.theme.Muted.Render("Same Affirm as plural up / init (PLURAL_INIT_AFFIRM_SETUP_REPO)."), + "", + ) + } + lines = append(lines, m.setupGitLines(width)...) + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + help := "↑/↓ · y/n · enter · esc back" + return page.Panel(m.theme, "Git repository", lines, width, 16, true), help + case modeAffirmDeploy: + lines := []string{ + m.theme.Muted.Render("Are you ready to set up your initial management cluster?"), + m.theme.Muted.Render("You can check the generated terraform/helm to confirm everything looks good first."), + m.theme.Muted.Render("Same Affirm as plural up (PLURAL_UP_AFFIRM_DEPLOY)."), + "", + } + lines = append(lines, m.affirmDeployLines(width)...) + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + return page.Panel(m.theme, "Deploy", lines, width, 14, true), "↑/↓ · y/n · enter · esc domain" + case modeIgnoreContinue: + lines := []string{ + m.theme.Muted.Render("Preflight checks failed, but continuing because --ignore-preflights was specified."), + m.theme.Muted.Render("Please note that you may encounter issues later on during provisioning."), + "", + m.theme.Danger.Render("Warning: " + truncate(m.probeWarn, max(20, width-12))), + "", + "Mode " + m.flow.Title, + "Provider " + m.provider.Title, + "Preflights " + yesNoLabel(true), + "", + m.theme.Muted.Render("Press enter to continue to the git Affirm (next step)."), + m.theme.Muted.Render("Then: scm.Setup (if needed) → app domain → deploy Affirm → plan."), + } + return page.Panel(m.theme, "Continuing with warning", lines, width, 14, true), "enter continue · esc back" + case modeRunPreflights: + lines := []string{ + "Provider " + m.provider.Title, + "", + m.spinner.View() + " " + m.theme.Muted.Render("Running provider.Preflights()…"), + m.theme.Muted.Render("IAM / permissions checks — skipped on failure if --ignore-preflights."), + } + return page.Panel(m.theme, "Preflight checks", lines, width, 10, true), "esc cancel" + case modeSelectSCM: + lines := []string{ + m.theme.Muted.Render("Select the SCM provider to use for your repository:"), + m.theme.Muted.Render("Same first prompt as scm.Setup() in plural up."), + "", + } + lines = append(lines, m.scmLines(width)...) + help := "↑/↓ · 1–3 / letter · enter · esc git" + return page.Panel(m.theme, "SCM provider", lines, width, 12, true), help + case modeAppDomain: + lines := []string{ + m.theme.Muted.Render("Application domain (askAppDomain)."), + m.theme.Muted.Render("None / empty skips — same as plural up."), + "", + } + if len(m.domainOpts) == 0 && m.err == nil { + // still loading select options, or free-text mode after load + if m.formInput.Focused() { + lines = append(lines, "› Domain") + lines = append(lines, " "+m.formInput.View()) + } else { + lines = append(lines, m.spinner.View()+" "+m.theme.Muted.Render("Fetching DNS zones…")) + } + } else if m.domainIsSelect() { + lines = append(lines, "› Select hosted / DNS zone") + start, end := optionWindow(m.optionCursor, len(m.domainOpts), 8) + for j := start; j < end; j++ { + mark := " " + style := m.theme.Muted + if j == m.optionCursor { + mark = "• " + style = m.theme.Title + } + lines = append(lines, " "+style.Render(mark+truncate(m.domainOpts[j], max(12, width-8)))) + } + } + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + help := "enter confirm · esc back" + if m.domainIsSelect() { + help = "↑/↓ · enter · esc back" + } + return page.Panel(m.theme, "App domain", lines, width, 16, true), help + case modeProbing: + lines := []string{ + "Provider " + m.provider.Title, + "", + m.spinner.View() + " " + m.theme.Muted.Render("Checking credentials and fetching regions…"), + m.theme.Muted.Render("Same checks plural up runs before the provider survey."), + } + return page.Panel(m.theme, "Provider setup", lines, width, 10, true), "esc cancel" + case modeProviderForm: + return m.formView(width) + case modeSelectProvider: + intro := []string{ + "Mode " + m.flow.Title, + "Preflights " + yesNoLabel(m.ignorePreflights), + m.theme.Muted.Render(" " + m.cli()), + "", + m.theme.Muted.Render("Select the cloud provider (same list as plural up init)."), + m.theme.Muted.Render("Next: verify credentials · fetch regions/projects."), + "", + } + lines := append(intro, m.providerLines(width)...) + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + lines = append(lines, m.theme.Muted.Render("Fix credentials, or choose Ignore to warn and continue without the region survey.")) + } + help := "↑/↓ select · 1–4 / letter · enter · esc preflights" + if width < 100 { + help = "↑/↓ · enter · esc preflights" + } + return page.Panel(m.theme, "Cloud provider", lines, width, 16, true), help + case modeIgnorePreflights: + intro := []string{ + "Mode " + m.flow.Title, + m.theme.Muted.Render(" " + m.flow.CLI(false)), + "", + m.theme.Muted.Render("After provider setup, run provider.Preflights() (IAM, permissions, …)?"), + m.theme.Muted.Render("Ignore = warn and continue — same as plural up --ignore-preflights."), + m.theme.Muted.Render("Credential login + region survey still run first (CLI GetProvider)."), + "", + } + lines := append(intro, m.ignoreLines(width)...) + help := "↑/↓ select · 1/r run · 2/i ignore · enter · esc mode" + if width < 100 { + help = "↑/↓ · enter · esc mode" + } + return page.Panel(m.theme, "Preflight checks", lines, width, 14, true), help + default: + intro := []string{ + m.theme.Muted.Render("Sets up your repository and an initial management cluster."), + m.theme.Muted.Render("Only self-hosted continues with the provider survey for now."), + "", + } + lines := append(intro, m.flowLines(width)...) + help := "↑/↓ select · 1–4 / letter · enter · esc welcome" + if width < 100 { + help = "↑/↓ · enter · esc welcome" + } + return page.Panel(m.theme, "Setup mode", lines, width, 14, true), help + } +} + +func (m Model) formView(width int) (string, string) { + lines := []string{ + "Provider " + m.provider.Title, + } + if m.credSummary != "" { + lines = append(lines, m.theme.Muted.Render(" "+truncate(m.credSummary, max(24, width-12)))) + } else { + lines = append(lines, m.theme.Muted.Render(" Matches plural up / provider init prompts.")) + } + if m.probeWarn != "" { + lines = append(lines, m.theme.Danger.Render("⚠ "+truncate(m.probeWarn, max(24, width-4)))) + } + lines = append(lines, "") + + for i, field := range m.formFields { + cursor := " " + active := i == m.formIndex + if active { + cursor = "› " + } + if active && m.currentIsSelect() { + lines = append(lines, cursor+field.Label) + opts := field.Options + start, end := optionWindow(m.optionCursor, len(opts), 6) + for j := start; j < end; j++ { + mark := " " + style := m.theme.Muted + if j == m.optionCursor { + mark = "• " + style = m.theme.Title + } + lines = append(lines, " "+style.Render(mark+truncate(opts[j], max(12, width-8)))) + } + if start > 0 || end < len(opts) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" (%d/%d)", m.optionCursor+1, len(opts)))) + } + continue + } + if active { + lines = append(lines, cursor+field.Label) + lines = append(lines, " "+m.formInput.View()) + continue + } + val := formValue(m.formValues, field.Key) + lines = append(lines, cursor+field.Label+" "+m.theme.Muted.Render(truncate(val, max(8, width-24)))) + } + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + help := "↑/↓ · enter next/done · esc providers" + if m.currentIsSelect() { + help = "↑/↓ options · enter select · esc providers" + } + return page.Panel(m.theme, "Provider setup", lines, width, 18, true), help +} + +func optionWindow(cursor, total, size int) (int, int) { + if total <= size { + return 0, total + } + start := cursor - size/2 + if start < 0 { + start = 0 + } + end := start + size + if end > total { + end = total + start = end - size + } + return start, end +} + +func (m Model) flowLines(width int) []string { + lines := make([]string, 0, len(m.flows)) + for i, f := range m.flows { + cursor := " " + if i == m.cursor { + cursor = "› " + } + left := fmt.Sprintf("%d %s %-14s %s", i+1, flowShortcut(f.ID), f.Title, f.Blurb) + var row string + if i == m.cursor { + row = cursor + m.theme.Title.Render(left) + } else { + row = cursor + m.theme.Body.Render(left) + } + lines = append(lines, ansi.Truncate(row, max(1, width-2), "…")) + } + return lines +} + +func (m Model) ignoreLines(width int) []string { + opts := ignorePreflightOptions() + lines := make([]string, 0, len(opts)) + for i, opt := range opts { + cursor := " " + if i == m.cursor { + cursor = "› " + } + check := "[ ]" + if i == m.cursor { + check = "[x]" + } + shortcut := "r" + if opt.value { + shortcut = "i" + } + left := fmt.Sprintf("%s %s %-10s %s", check, shortcut, opt.title, opt.blurb) + var row string + if i == m.cursor { + row = cursor + m.theme.Title.Render(left) + } else { + row = cursor + m.theme.Body.Render(left) + } + lines = append(lines, ansi.Truncate(row, max(1, width-2), "…")) + } + return lines +} + +func (m Model) setupGitLines(width int) []string { + opts := m.gitAffirmOptions() + lines := make([]string, 0, len(opts)) + for i, opt := range opts { + cursor := " " + if i == m.cursor { + cursor = "› " + } + check := "[ ]" + if i == m.cursor { + check = "[x]" + } + shortcut := "y" + if !opt.value { + shortcut = "n" + } + left := fmt.Sprintf("%s %s %-4s %s", check, shortcut, opt.title, opt.blurb) + var row string + if i == m.cursor { + row = cursor + m.theme.Title.Render(left) + } else { + row = cursor + m.theme.Body.Render(left) + } + lines = append(lines, ansi.Truncate(row, max(1, width-2), "…")) + } + return lines +} + +func (m Model) affirmDeployLines(width int) []string { + opts := affirmDeployOptions() + lines := make([]string, 0, len(opts)) + for i, opt := range opts { + cursor := " " + if i == m.cursor { + cursor = "› " + } + check := "[ ]" + if i == m.cursor { + check = "[x]" + } + shortcut := "y" + if !opt.value { + shortcut = "n" + } + left := fmt.Sprintf("%s %s %-4s %s", check, shortcut, opt.title, opt.blurb) + var row string + if i == m.cursor { + row = cursor + m.theme.Title.Render(left) + } else { + row = cursor + m.theme.Body.Render(left) + } + lines = append(lines, ansi.Truncate(row, max(1, width-2), "…")) + } + return lines +} + +func (m Model) scmLines(width int) []string { + lines := make([]string, 0, len(m.scms)) + for i, s := range m.scms { + cursor := " " + if i == m.cursor { + cursor = "› " + } + left := fmt.Sprintf("%d %s %-10s %s", i+1, scmShortcut(s.ID), s.Title, s.Blurb) + var row string + if i == m.cursor { + row = cursor + m.theme.Title.Render(left) + } else { + row = cursor + m.theme.Body.Render(left) + } + lines = append(lines, ansi.Truncate(row, max(1, width-2), "…")) + } + return lines +} + +func (m Model) providerLines(width int) []string { + lines := make([]string, 0, len(m.providers)) + for i, p := range m.providers { + cursor := " " + if i == m.cursor { + cursor = "› " + } + left := fmt.Sprintf("%d %s %-6s %s", i+1, providerShortcut(p.ID), p.Title, p.Blurb) + var row string + if i == m.cursor { + row = cursor + m.theme.Title.Render(left) + } else { + row = cursor + m.theme.Body.Render(left) + } + lines = append(lines, ansi.Truncate(row, max(1, width-2), "…")) + } + return lines +} diff --git a/tui/screens/welcome/groups.go b/tui/screens/welcome/groups.go index ed01a5e3a..53c9f3b22 100644 --- a/tui/screens/welcome/groups.go +++ b/tui/screens/welcome/groups.go @@ -5,7 +5,8 @@ import "github.com/pluralsh/plural-cli/tui/navigation" type groupID uint8 const ( - groupDeployments groupID = iota + groupUp groupID = iota + groupDeployments groupAccess groupDiagnose groupAI @@ -23,10 +24,11 @@ type group struct { func welcomeGroups() []group { return []group{ - {id: groupDeployments, number: "1", shortcut: "d", title: "CD / Deployments", blurb: "clusters · services · repos", route: navigation.Deployments}, - {id: groupAccess, number: "2", shortcut: "a", title: "Access", blurb: "login · profiles · Console", route: navigation.Access}, - {id: groupDiagnose, number: "3", shortcut: "g", title: "Diagnose", blurb: "local context · checks", route: navigation.Diagnostics}, - {id: groupAI, number: "4", shortcut: "i", title: "AI", blurb: "agents · workbenches", route: navigation.AI}, - {id: groupHelp, number: "5", shortcut: "?", title: "Help", blurb: "shortcuts · about"}, + {id: groupUp, number: "1", shortcut: "u", title: "Up", blurb: "bootstrap · management cluster", route: navigation.Up}, + {id: groupDeployments, number: "2", shortcut: "d", title: "CD / Deployments", blurb: "clusters · services · repos", route: navigation.Deployments}, + {id: groupAccess, number: "3", shortcut: "a", title: "Access", blurb: "login · profiles · Console", route: navigation.Access}, + {id: groupDiagnose, number: "4", shortcut: "g", title: "Diagnose", blurb: "local context · checks", route: navigation.Diagnostics}, + {id: groupAI, number: "5", shortcut: "i", title: "AI", blurb: "agents · workbenches", route: navigation.AI}, + {id: groupHelp, number: "6", shortcut: "?", title: "Help", blurb: "shortcuts · about"}, } } diff --git a/tui/screens/welcome/model_test.go b/tui/screens/welcome/model_test.go index fb194faf5..448520273 100644 --- a/tui/screens/welcome/model_test.go +++ b/tui/screens/welcome/model_test.go @@ -123,6 +123,20 @@ func TestWelcomeGroupPickerGolden(t *testing.T) { } } +func TestWelcomeOpensUpFromShortcut(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + if cmd == nil { + t.Fatal("selecting Up did not emit navigation") + } + if got := cmd().(navigation.NavigateMsg).Route; got != navigation.Up { + t.Fatalf("route = %q, want up", got) + } + if model.cursor != 0 { + t.Fatalf("cursor = %d, want 0", model.cursor) + } +} + func TestWelcomeOpensDeploymentsFromShortcut(t *testing.T) { model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) model, cmd := model.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) @@ -132,14 +146,14 @@ func TestWelcomeOpensDeploymentsFromShortcut(t *testing.T) { if got := cmd().(navigation.NavigateMsg).Route; got != navigation.Deployments { t.Fatalf("route = %q, want deployments", got) } - if model.cursor != 0 { - t.Fatalf("cursor = %d, want 0", model.cursor) + if model.cursor != 1 { + t.Fatalf("cursor = %d, want 1", model.cursor) } } func TestWelcomeOpensAccessFromNumber(t *testing.T) { model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) - _, cmd := model.Update(tea.KeyPressMsg{Code: '2', Text: "2"}) + _, cmd := model.Update(tea.KeyPressMsg{Code: '3', Text: "3"}) if cmd == nil { t.Fatal("selecting Access did not emit navigation") } @@ -161,7 +175,7 @@ func TestWelcomeOpensAIFromShortcut(t *testing.T) { func TestWelcomeOpensAIFromNumber(t *testing.T) { model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) - _, cmd := model.Update(tea.KeyPressMsg{Code: '4', Text: "4"}) + _, cmd := model.Update(tea.KeyPressMsg{Code: '5', Text: "5"}) if cmd == nil { t.Fatal("selecting AI did not emit navigation") } @@ -174,6 +188,7 @@ func TestWelcomeArrowAndEnterOpensDiagnose(t *testing.T) { model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if cmd == nil { t.Fatal("enter did not emit navigation") diff --git a/tui/screens/welcome/testdata/welcome-120.golden b/tui/screens/welcome/testdata/welcome-120.golden index daa241328..618cf17ae 100644 --- a/tui/screens/welcome/testdata/welcome-120.golden +++ b/tui/screens/welcome/testdata/welcome-120.golden @@ -17,14 +17,14 @@ - ╭─ Choose an area ─────────────────────────────────────────────────────────────────────────────────────────────────╮ - │ › 1 d CD / Deployments clusters · services · repos │ - │ 2 a Access login · profiles · Console │ - │ 3 g Diagnose local context · checks │ - │ 4 i AI agents · workbenches │ - │ 5 ? Help shortcuts · about │ + │ › 1 u Up bootstrap · management cluster │ + │ 2 d CD / Deployments clusters · services · repos │ + │ 3 a Access login · profiles · Console │ + │ 4 g Diagnose local context · checks │ + │ 5 i AI agents · workbenches │ + │ 6 ? Help shortcuts · about │ │ │ │ Setup · Agents · Develop — later │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - 1–5 open · letter shortcut · ↑/↓ · enter · ctrl+c quit + 1–6 open · letter shortcut · ↑/↓ · enter · ctrl+c quit diff --git a/tui/screens/welcome/testdata/welcome-80.golden b/tui/screens/welcome/testdata/welcome-80.golden index 7250e9e4a..3b84abf1d 100644 --- a/tui/screens/welcome/testdata/welcome-80.golden +++ b/tui/screens/welcome/testdata/welcome-80.golden @@ -11,14 +11,14 @@ - ╭─ Choose an area ─────────────────────────────────────────────────────────╮ - │ › 1 d CD / Deployments clusters · services · repos │ - │ 2 a Access login · profiles · Console │ - │ 3 g Diagnose local context · checks │ - │ 4 i AI agents · workbenches │ - │ 5 ? Help shortcuts · about │ + │ › 1 u Up bootstrap · management cluster │ + │ 2 d CD / Deployments clusters · services · repos │ + │ 3 a Access login · profiles · Console │ + │ 4 g Diagnose local context · checks │ + │ 5 i AI agents · workbenches │ + │ 6 ? Help shortcuts · about │ │ │ │ Setup · Agents · Develop — later │ ╰──────────────────────────────────────────────────────────────────────────╯ - 1–5 open · letter shortcut · ↑/↓ · enter · ctrl+c quit + 1–6 open · letter shortcut · ↑/↓ · enter · ctrl+c quit diff --git a/tui/screens/welcome/testdata/welcome-popup-80.golden b/tui/screens/welcome/testdata/welcome-popup-80.golden index edf2ed630..86f0849e5 100644 --- a/tui/screens/welcome/testdata/welcome-popup-80.golden +++ b/tui/screens/welcome/testdata/welcome-popup-80.golden @@ -11,14 +11,14 @@ - ╭─ Choose an area ─────────────────────────────────────────────────────────╮ - │ 1 d CD / Deployments clusters · services · repos │ - │ › 2 a Access login · profiles · Console │ - │ 3 g Diagnose local context · checks │ - │ 4 i AI agents · workbenches │ - │ 5 ? Help shortcuts · about │ + │ 1 u Up bootstrap · management cluster │ + │ › 2 d CD / Deployments clusters · services · repos │ + │ 3 a Access login · profiles · Console │ + │ 4 g Diagnose local context · checks │ + │ 5 i AI agents · workbenches │ + │ 6 ? Help shortcuts · about │ │ │ │ Setup · Agents · Develop — later │ ╰──────────────────────────────────────────────────────────────────────────╯ - 1–5 open · letter shortcut · ↑/↓ · enter · ctrl+c quit + 1–6 open · letter shortcut · ↑/↓ · enter · ctrl+c quit diff --git a/tui/screens/welcome/view.go b/tui/screens/welcome/view.go index 411675b13..e0e9a7278 100644 --- a/tui/screens/welcome/view.go +++ b/tui/screens/welcome/view.go @@ -53,7 +53,7 @@ func (m Model) renderGroups(width int) string { var body []string if m.helpOpen { body = []string{ - m.theme.Body.Render("1–5 / letter opens an area"), + m.theme.Body.Render("1–6 / letter opens an area"), m.theme.Muted.Render("↑/↓ move · enter confirm · esc close help"), m.theme.Muted.Render("ctrl+c quit"), "", @@ -86,7 +86,7 @@ func (m Model) renderGroups(width int) string { } rows = append(rows, bottom) - help := m.theme.Muted.Render(ansi.Truncate("1–5 open · letter shortcut · ↑/↓ · enter · ctrl+c quit", max(1, width-2), "…")) + help := m.theme.Muted.Render(ansi.Truncate("1–6 open · letter shortcut · ↑/↓ · enter · ctrl+c quit", max(1, width-2), "…")) if m.helpOpen { help = m.theme.Muted.Render(ansi.Truncate("any key closes help · ctrl+c quit", max(1, width-2), "…")) } From 4651237ed3d73c9e0291b04adb92d9d26919f9ca Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Fri, 7 Aug 2026 14:45:35 +0200 Subject: [PATCH 16/16] plural up --cloud --- pkg/bridge/up/instances.go | 105 +++ pkg/bridge/up/instances_test.go | 77 +++ pkg/bridge/up/runner.go | 370 +++++++++++ pkg/bridge/up/runner_test.go | 133 ++++ pkg/bridge/up/up.go | 9 +- pkg/bridge/up/up_test.go | 7 +- tui/screens/up/model.go | 617 +++++++++++++++++- tui/screens/up/model_test.go | 345 +++++++++- tui/screens/up/testdata/up-clitip-120.golden | 14 +- tui/screens/up/testdata/up-clitip-80.golden | 14 +- tui/screens/up/testdata/up-flow-120.golden | 2 +- tui/screens/up/testdata/up-flow-80.golden | 2 +- .../up/testdata/up-selected-120.golden | 4 +- tui/screens/up/testdata/up-selected-80.golden | 4 +- tui/screens/up/view.go | 273 +++++++- 15 files changed, 1914 insertions(+), 62 deletions(-) create mode 100644 pkg/bridge/up/instances.go create mode 100644 pkg/bridge/up/instances_test.go create mode 100644 pkg/bridge/up/runner.go create mode 100644 pkg/bridge/up/runner_test.go diff --git a/pkg/bridge/up/instances.go b/pkg/bridge/up/instances.go new file mode 100644 index 000000000..88a964d5e --- /dev/null +++ b/pkg/bridge/up/instances.go @@ -0,0 +1,105 @@ +package up + +import ( + "context" + "fmt" + "strings" + + "github.com/pluralsh/plural-cli/pkg/api" + "github.com/pluralsh/plural-cli/pkg/common" + "github.com/pluralsh/plural-cli/pkg/console" +) + +// ConsoleInstance is one Plural Cloud Console from GetConsoleInstances. +type ConsoleInstance struct { + ID string + Name string + URL string +} + +// InstanceLister lists cloud Console instances (App GraphQL). +type InstanceLister interface { + List(ctx context.Context) ([]ConsoleInstance, error) +} + +// LiveInstanceLister uses the App API client (same plane as CLI InitPluralClient). +type LiveInstanceLister struct{} + +// DefaultInstanceLister returns the live App GraphQL lister. +func DefaultInstanceLister() InstanceLister { return LiveInstanceLister{} } + +// List returns Console instances for the logged-in Plural account. +func (LiveInstanceLister) List(ctx context.Context) ([]ConsoleInstance, error) { + _ = ctx + instances, err := api.NewClient().GetConsoleInstances() + if err != nil { + return nil, err + } + out := make([]ConsoleInstance, 0, len(instances)) + for _, inst := range instances { + if inst == nil { + continue + } + out = append(out, ConsoleInstance{ID: inst.ID, Name: inst.Name, URL: inst.URL}) + } + return out, nil +} + +// DefaultInstanceIndex returns the index matching prior console.yml hostname, or 0. +func DefaultInstanceIndex(instances []ConsoleInstance, priorURL string) int { + if priorURL == "" || len(instances) == 0 { + return 0 + } + priorHost := common.GetHostnameFromURL(priorURL) + for i, inst := range instances { + if strings.EqualFold(priorHost, common.GetHostnameFromURL(inst.URL)) { + return i + } + } + return 0 +} + +// PriorConsoleConfig matches selected URL hostname (HandleCdLogin Affirm path). +func PriorConsoleMatches(priorURL, selectedURL string) bool { + if priorURL == "" || selectedURL == "" { + return false + } + return strings.EqualFold(common.GetHostnameFromURL(priorURL), common.GetHostnameFromURL(selectedURL)) +} + +// ReadPriorConsole returns console.yml Url/Token (may be empty). +func ReadPriorConsole() console.Config { + return console.ReadConfig() +} + +// SaveConsoleConfig writes console.yml after CD login (URL + token). +func SaveConsoleConfig(rawURL, token string) error { + conf := console.Config{ + Url: console.NormalizeUrl(rawURL), + Token: strings.TrimSpace(token), + } + return conf.Save() +} + +// ValidateConsoleConfig mirrors cmd/command/up ValidateConsoleConfig: +// console.yml must match one of the listed instances. +func ValidateConsoleConfig(instances []ConsoleInstance, conf console.Config) error { + if conf.Url == "" { + return fmt.Errorf("you haven't configured your Plural Console client yet") + } + var id string + for _, inst := range instances { + if strings.Contains(conf.Url, inst.URL) { + id = inst.ID + break + } + if strings.EqualFold(common.GetHostnameFromURL(conf.Url), common.GetHostnameFromURL(inst.URL)) { + id = inst.ID + break + } + } + if id == "" { + return fmt.Errorf("your configuration doesn't match to any existing Plural Console") + } + return nil +} diff --git a/pkg/bridge/up/instances_test.go b/pkg/bridge/up/instances_test.go new file mode 100644 index 000000000..322cdecff --- /dev/null +++ b/pkg/bridge/up/instances_test.go @@ -0,0 +1,77 @@ +package up + +import ( + "context" + "errors" + "testing" + + "github.com/pluralsh/plural-cli/pkg/console" +) + +func TestDefaultInstanceIndex(t *testing.T) { + instances := []ConsoleInstance{ + {Name: "a", URL: "https://a.onplural.sh"}, + {Name: "b", URL: "https://b.onplural.sh"}, + } + if got := DefaultInstanceIndex(instances, ""); got != 0 { + t.Fatalf("empty prior = %d", got) + } + if got := DefaultInstanceIndex(instances, "https://b.onplural.sh"); got != 1 { + t.Fatalf("match b = %d", got) + } + if got := DefaultInstanceIndex(instances, "https://other.example"); got != 0 { + t.Fatalf("no match = %d", got) + } +} + +func TestPriorConsoleMatches(t *testing.T) { + if !PriorConsoleMatches("https://demo.onplural.sh", "https://demo.onplural.sh/gql") { + t.Fatal("expected hostname match") + } + if PriorConsoleMatches("", "https://demo.onplural.sh") { + t.Fatal("empty prior should not match") + } +} + +func TestValidateConsoleConfig(t *testing.T) { + instances := []ConsoleInstance{{ID: "1", Name: "demo", URL: "https://demo.onplural.sh"}} + if err := ValidateConsoleConfig(instances, console.Config{}); err == nil { + t.Fatal("expected empty url error") + } + if err := ValidateConsoleConfig(instances, console.Config{Url: "https://demo.onplural.sh"}); err != nil { + t.Fatalf("matching config: %v", err) + } + if err := ValidateConsoleConfig(instances, console.Config{Url: "https://other.onplural.sh"}); err == nil { + t.Fatal("expected mismatch error") + } +} + +type stubLister struct { + items []ConsoleInstance + err error +} + +func (s stubLister) List(context.Context) ([]ConsoleInstance, error) { + return s.items, s.err +} + +func TestStubInstanceLister(t *testing.T) { + l := stubLister{err: errors.New("boom")} + if _, err := l.List(context.Background()); err == nil { + t.Fatal("expected error") + } + l = stubLister{items: []ConsoleInstance{{Name: "x", URL: "https://x.onplural.sh"}}} + got, err := l.List(context.Background()) + if err != nil || len(got) != 1 || got[0].Name != "x" { + t.Fatalf("got=%v err=%v", got, err) + } +} + +func TestNeedsProviderIncludesCloud(t *testing.T) { + for _, f := range Flows() { + want := f.ID == "self-hosted" || f.ID == "cloud" + if f.NeedsProvider() != want { + t.Fatalf("%s NeedsProvider=%v want %v", f.ID, f.NeedsProvider(), want) + } + } +} diff --git a/pkg/bridge/up/runner.go b/pkg/bridge/up/runner.go new file mode 100644 index 000000000..af98a2a95 --- /dev/null +++ b/pkg/bridge/up/runner.go @@ -0,0 +1,370 @@ +package up + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/pluralsh/console/go/polly/algorithms" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/pkg/api" + "github.com/pluralsh/plural-cli/pkg/config" + "github.com/pluralsh/plural-cli/pkg/console" + "github.com/pluralsh/plural-cli/pkg/manifest" + "github.com/pluralsh/plural-cli/pkg/provider" + pkgup "github.com/pluralsh/plural-cli/pkg/up" + "github.com/pluralsh/plural-cli/pkg/utils/git" +) + +const defaultBootstrapBranch = "main" + +// FlushInput carries wizard survey values for writing workspace.yaml. +type FlushInput struct { + ProviderID string + Values map[string]string + AppDomain string + Cloud bool +} + +// GenerateInput is the post-Flush generate step (Build → ImportCluster → Backfill → Generate). +type GenerateInput struct { + Cloud bool + CloudCluster string // Console instance name (--cloud) + IgnorePreflights bool + GitRef string + ImportClusterID string // optional; resolved when empty and Cloud +} + +// DeployInput runs up.Context.Deploy after Generate (terraform + optional git sync). +type DeployInput struct { + Cloud bool + CloudCluster string + ImportClusterID string + IgnorePreflights bool + CommitMsg string // empty skips git.Sync (CLI CommitMsg parity) +} + +// RunInput is the Plan → Flush + Generate pipeline. +type RunInput struct { + Flush FlushInput + Generate GenerateInput +} + +// RunResult carries values needed for a later Deploy step. +type RunResult struct { + ImportClusterID string +} + +// Progress reports a human-readable step while Run/Deploy executes. +type ProgressFunc func(step string) + +// Runner executes Flush + Generate, then optionally Deploy. +type Runner interface { + Run(ctx context.Context, in RunInput, progress ProgressFunc) (RunResult, error) + Deploy(ctx context.Context, in DeployInput, progress ProgressFunc) error +} + +// LiveRunner writes workspace.yaml and runs up.Build / Generate / Deploy. +type LiveRunner struct{} + +// DefaultRunner returns the live Flush+Generate+Deploy runner. +func DefaultRunner() Runner { return LiveRunner{} } + +// Run flushes the workspace, resolves ImportCluster when cloud, then generates. +func (LiveRunner) Run(ctx context.Context, in RunInput, progress ProgressFunc) (RunResult, error) { + report := progress + if report == nil { + report = func(string) {} + } + var result RunResult + + report("Writing workspace.yaml…") + if err := FlushWorkspace(ctx, in.Flush); err != nil { + return result, err + } + + gen := in.Generate + if gen.Cloud && gen.ImportClusterID == "" { + report("Resolving management cluster (ImportCluster)…") + id, err := ResolveImportCluster(ctx) + if err != nil { + return result, err + } + gen.ImportClusterID = id + } + result.ImportClusterID = gen.ImportClusterID + + report("Generating bootstrap / terraform…") + return result, GenerateWorkspace(ctx, gen) +} + +// Deploy runs up.Context.Deploy (CreateBucket / terraform / commit / apps). +func (LiveRunner) Deploy(ctx context.Context, in DeployInput, progress ProgressFunc) error { + report := progress + if report == nil { + report = func(string) {} + } + + provider.SetCloudFlag(in.Cloud) + report("Building deploy context…") + upCtx, err := pkgup.Build(in.Cloud) + if err != nil { + return err + } + upCtx.IgnorePreflights(in.IgnorePreflights) + + if in.Cloud { + id := in.ImportClusterID + if id == "" { + report("Resolving management cluster (ImportCluster)…") + id, err = ResolveImportCluster(ctx) + if err != nil { + return err + } + } + upCtx.SetImportCluster(id) + upCtx.CloudCluster = in.CloudCluster + } + + report("Deploying management cluster…") + return upCtx.Deploy(func() error { + msg := strings.TrimSpace(in.CommitMsg) + if msg == "" { + report("Skipping git commit (empty message)…") + return nil + } + report("Pushing git commit…") + root, err := git.Root() + if err != nil { + return err + } + return git.Sync(root, msg, false) + }) +} + +// FlushWorkspace builds ProjectManifest from survey values and writes workspace.yaml. +func FlushWorkspace(ctx context.Context, in FlushInput) error { + if strings.TrimSpace(in.ProviderID) == "" { + return fmt.Errorf("provider is required to write workspace.yaml") + } + if len(in.Values) == 0 { + return fmt.Errorf("provider survey values are required to write workspace.yaml (complete credentials/region first)") + } + cluster := strings.TrimSpace(in.Values["cluster"]) + if cluster == "" { + return fmt.Errorf("cluster name is required") + } + + conf := config.Read() + pm, err := projectManifestFromSurvey(ctx, in.ProviderID, in.Values, in.Cloud, conf) + if err != nil { + return err + } + if err := writeWorkspaceSilent(pm, in.Cloud, cluster); err != nil { + return err + } + if d := strings.TrimSpace(in.AppDomain); d != "" { + pm.AppDomain = d + return pm.Write(manifest.ProjectManifestPath()) + } + return nil +} + +func projectManifestFromSurvey(ctx context.Context, providerID string, values map[string]string, cloud bool, conf config.Config) (*manifest.ProjectManifest, error) { + owner := &manifest.Owner{Email: conf.Email, Endpoint: conf.Endpoint} + cluster := strings.TrimSpace(values["cluster"]) + + switch providerID { + case api.ProviderAWS: + region := strings.TrimSpace(values["region"]) + if region == "" { + return nil, fmt.Errorf("region is required for aws") + } + project := "" + ctxMap := map[string]interface{}{} + if sess, identity, err := provider.GetAWSCallerIdentity(ctx); err == nil { + project = lo.FromPtr(identity.Account) + ctxMap["IAMSession"] = sess + } + return &manifest.ProjectManifest{ + Cluster: cluster, + Project: project, + Provider: api.ProviderAWS, + Region: region, + Context: ctxMap, + Owner: owner, + }, nil + + case api.ProviderAzure: + location := strings.TrimSpace(values["location"]) + if location == "" { + return nil, fmt.Errorf("location is required for azure") + } + rg := strings.TrimSpace(values["resourceGroup"]) + storage := strings.TrimSpace(values["storageAccount"]) + ctxMap := map[string]interface{}{} + if subID, tenID, _, err := provider.GetAzureAccount(); err == nil { + ctxMap["SubscriptionId"] = subID + ctxMap["TenantId"] = tenID + } + if storage != "" { + ctxMap["StorageAccount"] = storage + } + return &manifest.ProjectManifest{ + Cluster: cluster, + Project: rg, + Provider: api.ProviderAzure, + Region: location, + Context: ctxMap, + Owner: owner, + }, nil + + case api.ProviderGCP: + project := strings.TrimSpace(values["project"]) + region := strings.TrimSpace(values["region"]) + if region == "" { + region = strings.TrimSpace(values["location"]) + } + if project == "" || region == "" { + return nil, fmt.Errorf("project and region are required for gcp") + } + ctxMap := map[string]interface{}{ + "BucketLocation": strings.ToUpper(strings.Split(region, "-")[0]), + "Location": region, + } + return &manifest.ProjectManifest{ + Cluster: cluster, + Project: project, + Provider: api.ProviderGCP, + Region: region, + Context: ctxMap, + Owner: owner, + }, nil + + case api.BYOK: + ctxMap := map[string]interface{}{} + kubePath := strings.TrimSpace(values["kubeconfig"]) + if kubePath == "" { + kubePath = "~/.kube/config" + } + expanded, err := expandPath(kubePath) + if err != nil { + return nil, err + } + data, err := os.ReadFile(expanded) + if err != nil { + return nil, fmt.Errorf("kubeconfig: %w", err) + } + ctxMap["kubeconfig"] = base64.StdEncoding.EncodeToString(data) + pm := &manifest.ProjectManifest{ + Cluster: cluster, + Provider: api.BYOK, + Owner: owner, + Context: ctxMap, + } + if !cloud { + if db := strings.TrimSpace(values["database"]); db != "" { + ctxMap["DbUrl"] = db + } + if domain := strings.TrimSpace(values["domain"]); domain != "" { + pm.Network = &manifest.NetworkConfig{Subdomain: domain, PluralDns: false} + } + } + return pm, nil + + default: + return nil, fmt.Errorf("unsupported provider %q", providerID) + } +} + +// writeWorkspaceSilent mirrors Configure without interactive bucket/network surveys. +func writeWorkspaceSilent(pm *manifest.ProjectManifest, cloud bool, cluster string) error { + pm.BucketPrefix = cluster + if cloud { + pm.Bucket = fmt.Sprintf("plrl-cloud-%s-%s", cluster, algorithms.String(4)) + } else { + pm.Bucket = fmt.Sprintf("%s-tf-state", cluster) + } + return pm.Write(manifest.ProjectManifestPath()) +} + +func expandPath(p string) (string, error) { + if strings.HasPrefix(p, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, p[2:]), nil + } + if filepath.IsAbs(p) { + return p, nil + } + return filepath.Abs(p) +} + +// ResolveImportCluster finds the Console cluster with handle "mgmt". +func ResolveImportCluster(ctx context.Context) (string, error) { + _ = ctx + conf := console.ReadConfig() + if conf.Token == "" || conf.Url == "" { + return "", fmt.Errorf("you have not set up a console login, you can run `plural cd login` to save your credentials") + } + client, err := console.NewConsoleClient(conf.Token, conf.Url) + if err != nil { + return "", err + } + clusters, err := client.ListClusters() + if err != nil { + return "", err + } + if clusters == nil || clusters.Clusters == nil { + return "", fmt.Errorf("could not find the management cluster in your Plural cloud instance, contact support for assistance") + } + for _, edge := range clusters.Clusters.Edges { + if edge == nil || edge.Node == nil { + continue + } + if lo.FromPtr(edge.Node.Handle) == "mgmt" { + return edge.Node.ID, nil + } + } + return "", fmt.Errorf("could not find the management cluster in your Plural cloud instance, contact support for assistance") +} + +// GenerateWorkspace runs up.Build → optional ImportCluster → Backfill → Generate. +func GenerateWorkspace(ctx context.Context, in GenerateInput) error { + _ = ctx + provider.SetCloudFlag(in.Cloud) + + upCtx, err := pkgup.Build(in.Cloud) + if err != nil { + return err + } + upCtx.IgnorePreflights(in.IgnorePreflights) + + if in.Cloud { + if in.ImportClusterID == "" { + return fmt.Errorf("ImportCluster id is required for cloud generate") + } + upCtx.SetImportCluster(in.ImportClusterID) + upCtx.CloudCluster = in.CloudCluster + } + + if err := upCtx.Backfill(); err != nil { + return err + } + + gitRef := in.GitRef + if gitRef == "" { + gitRef = defaultBootstrapBranch + } + dir, err := upCtx.Generate(gitRef) + if dir != "" { + defer func() { _ = os.RemoveAll(dir) }() + } + return err +} diff --git a/pkg/bridge/up/runner_test.go b/pkg/bridge/up/runner_test.go new file mode 100644 index 000000000..606526c19 --- /dev/null +++ b/pkg/bridge/up/runner_test.go @@ -0,0 +1,133 @@ +package up + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pluralsh/plural-cli/pkg/api" + "github.com/pluralsh/plural-cli/pkg/manifest" +) + +func TestFlushWorkspaceRequiresValues(t *testing.T) { + err := FlushWorkspace(context.Background(), FlushInput{ProviderID: "aws"}) + if err == nil || !strings.Contains(err.Error(), "survey values") { + t.Fatalf("expected survey values error, got %v", err) + } +} + +func TestFlushWorkspaceAWSWritesManifest(t *testing.T) { + dir := t.TempDir() + prev, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(prev) }) + + err := FlushWorkspace(context.Background(), FlushInput{ + ProviderID: api.ProviderAWS, + Values: map[string]string{"cluster": "demo", "region": "us-east-2"}, + AppDomain: "apps.example.com", + Cloud: true, + }) + if err != nil { + t.Fatalf("FlushWorkspace: %v", err) + } + path := filepath.Join(dir, "workspace.yaml") + pm, err := manifest.ReadProject(path) + if err != nil { + t.Fatalf("ReadProject: %v", err) + } + if pm.Cluster != "demo" || pm.Region != "us-east-2" || pm.Provider != api.ProviderAWS { + t.Fatalf("manifest = %#v", pm) + } + if pm.AppDomain != "apps.example.com" { + t.Fatalf("appDomain = %q", pm.AppDomain) + } + if pm.Bucket == "" || pm.BucketPrefix != "demo" { + t.Fatalf("bucket=%q prefix=%q", pm.Bucket, pm.BucketPrefix) + } + if !strings.HasPrefix(pm.Bucket, "plrl-cloud-demo-") { + t.Fatalf("cloud bucket naming = %q", pm.Bucket) + } +} + +func TestFlushWorkspaceSelfHostedBucket(t *testing.T) { + dir := t.TempDir() + prev, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(prev) }) + + if err := FlushWorkspace(context.Background(), FlushInput{ + ProviderID: api.ProviderAWS, + Values: map[string]string{"cluster": "acme", "region": "eu-west-1"}, + Cloud: false, + }); err != nil { + t.Fatalf("FlushWorkspace: %v", err) + } + pm, err := manifest.ReadProject(filepath.Join(dir, "workspace.yaml")) + if err != nil { + t.Fatal(err) + } + if pm.Bucket != "acme-tf-state" { + t.Fatalf("self-hosted bucket = %q", pm.Bucket) + } +} + +func TestFakeRunnerRecordsCloud(t *testing.T) { + f := &fakeRunner{} + in := RunInput{ + Flush: FlushInput{ProviderID: "aws", Values: map[string]string{"cluster": "x", "region": "us-east-2"}, Cloud: true}, + Generate: GenerateInput{Cloud: true, CloudCluster: "demo-cloud", IgnorePreflights: true}, + } + res, err := f.Run(context.Background(), in, nil) + if err != nil { + t.Fatal(err) + } + if len(f.calls) != 1 || !f.calls[0].Generate.Cloud || f.calls[0].Generate.CloudCluster != "demo-cloud" { + t.Fatalf("calls = %#v", f.calls) + } + if res.ImportClusterID != "mgmt-id" { + t.Fatalf("ImportClusterID = %q", res.ImportClusterID) + } + if err := f.Deploy(context.Background(), DeployInput{Cloud: true, ImportClusterID: res.ImportClusterID, CommitMsg: "init"}, nil); err != nil { + t.Fatal(err) + } + if len(f.deploys) != 1 || f.deploys[0].CommitMsg != "init" { + t.Fatalf("deploys = %#v", f.deploys) + } +} + +type fakeRunner struct { + calls []RunInput + deploys []DeployInput + progress []string + err error + deployErr error +} + +func (f *fakeRunner) Run(_ context.Context, in RunInput, progress ProgressFunc) (RunResult, error) { + f.calls = append(f.calls, in) + if progress != nil { + progress("Writing workspace.yaml…") + progress("Generating…") + f.progress = append(f.progress, "Writing workspace.yaml…", "Generating…") + } + res := RunResult{} + if in.Generate.Cloud { + res.ImportClusterID = "mgmt-id" + } + return res, f.err +} + +func (f *fakeRunner) Deploy(_ context.Context, in DeployInput, progress ProgressFunc) error { + f.deploys = append(f.deploys, in) + if progress != nil { + progress("Deploying…") + } + return f.deployErr +} diff --git a/pkg/bridge/up/up.go b/pkg/bridge/up/up.go index d2e18a070..f88eeb63c 100644 --- a/pkg/bridge/up/up.go +++ b/pkg/bridge/up/up.go @@ -15,10 +15,11 @@ type Flow struct { DryRun bool } -// NeedsProvider is true when this flow runs the self-hosted provider survey -// (CLI GetProvider). Cloud paths pick a Console instance instead. +// NeedsProvider is true when this flow runs the provider survey (CLI GetProvider +// via HandleInitWithProject). Cloud runs that survey after Console instance pick; +// dry-run-only stubs still skip it in the TUI for now. func (f Flow) NeedsProvider() bool { - return f.ID == "self-hosted" + return f.ID == "self-hosted" || f.ID == "cloud" } // CLI returns the equivalent plural up invocation for this flow. @@ -47,7 +48,7 @@ func Flows() []Flow { { ID: "cloud", Title: "Plural Cloud", - Blurb: "pick a Console instance (--cloud) · coming next", + Blurb: "pick a Console instance (--cloud) · then provider survey", Cloud: true, }, { diff --git a/pkg/bridge/up/up_test.go b/pkg/bridge/up/up_test.go index 5c34e57b4..49e13c8e0 100644 --- a/pkg/bridge/up/up_test.go +++ b/pkg/bridge/up/up_test.go @@ -10,9 +10,12 @@ func TestFlows(t *testing.T) { if !flows[0].NeedsProvider() { t.Fatal("self-hosted should need provider") } - for _, f := range flows[1:] { + if !flows[1].NeedsProvider() { + t.Fatal("cloud should need provider after Console pick") + } + for _, f := range flows[2:] { if f.NeedsProvider() { - t.Fatalf("%s should not need provider list", f.ID) + t.Fatalf("%s should not need provider list yet", f.ID) } } want := []struct { diff --git a/tui/screens/up/model.go b/tui/screens/up/model.go index e6c2b6107..0e2e59853 100644 --- a/tui/screens/up/model.go +++ b/tui/screens/up/model.go @@ -11,6 +11,7 @@ import ( tea "charm.land/bubbletea/v2" upbridge "github.com/pluralsh/plural-cli/pkg/bridge/up" + "github.com/pluralsh/plural-cli/pkg/console" "github.com/pluralsh/plural-cli/pkg/provider" pluralspinner "github.com/pluralsh/plural-cli/tui/components/spinner" "github.com/pluralsh/plural-cli/tui/navigation" @@ -22,6 +23,9 @@ type mode uint8 const ( modeSelectFlow mode = iota modeIgnorePreflights + modeLoadInstances // GetConsoleInstances + modeSelectInstance // choseCluster survey + modeConsoleLogin // HandleCdLogin Affirm or token modeSelectProvider modeProbing modeProviderForm @@ -31,7 +35,12 @@ const ( modeSelectSCM // scm.Setup: github / gitlab / bitbucket modeAppDomain // askAppDomain parity modeAffirmDeploy // common.AffirmUp before deploy - modeSelected + modeSelected // Plan summary + modeRunning // Flush + Generate + modeDone // generate finished (or failed) + modeCommitMsg // optional commit before Deploy + modeDeploying // up.Context.Deploy + modeComplete // deploy finished modeCLITip ) @@ -95,6 +104,13 @@ func affirmDeployOptions() []yesNoOption { } } +func consoleCredOptions(priorURL string) []yesNoOption { + return []yesNoOption{ + {value: true, title: "Yes", blurb: "keep credentials for " + truncate(priorURL, 40)}, + {value: false, title: "No", blurb: "enter a new console access token"}, + } +} + type probeMsg struct { result upbridge.ProbeResult err error @@ -117,6 +133,22 @@ type preflightMsg struct { err error } +type instancesMsg struct { + items []upbridge.ConsoleInstance + err error +} + +type runDoneMsg struct { + err error + steps []string + importClusterID string +} + +type deployDoneMsg struct { + err error + steps []string +} + // Model owns Up-wizard interaction state. type Model struct { theme theme.Theme @@ -143,16 +175,29 @@ type Model struct { domainNote string // zone fetch ignored (CLI "ignoring domain setup...") spinner spinner.Model - formInput textinput.Model - formFields []upbridge.FormField - formIndex int - formValues map[string]string - optionCursor int - freeTextKeys map[string]bool // Azure "Create new…" → free text - err error - gitChecker func() bool - domainLoader func() domainMsg // tests stub zone listing - gitAffirmOpen bool // Esc from domain returns here when Affirm was shown + instances []upbridge.ConsoleInstance + cloudInstance upbridge.ConsoleInstance + consoleTokenMode bool // true = token textinput; false = use-existing Affirm + instanceLister upbridge.InstanceLister + priorConsole func() (url, token string) + saveConsole func(url, token string) error + runner upbridge.Runner + runSteps []string + runErr error + importClusterID string + commitMsg string + deployErr error + + formInput textinput.Model + formFields []upbridge.FormField + formIndex int + formValues map[string]string + optionCursor int + freeTextKeys map[string]bool // Azure "Create new…" → free text + err error + gitChecker func() bool + domainLoader func() domainMsg // tests stub zone listing + gitAffirmOpen bool // Esc from domain returns here when Affirm was shown } // New creates the Up wizard starting at setup-flow selection. @@ -175,16 +220,23 @@ func NewWithProber(ctx context.Context, t theme.Theme, prober upbridge.Prober) M prober = upbridge.DefaultProber() } return Model{ - theme: t, - prober: prober, - ctx: ctx, - mode: modeSelectFlow, - flows: upbridge.Flows(), - providers: upbridge.CloudProviders(), - scms: upbridge.SCMProviders(), - formInput: input, - spinner: pluralspinner.New(t), - gitChecker: upbridge.InGitRepo, + theme: t, + prober: prober, + ctx: ctx, + mode: modeSelectFlow, + flows: upbridge.Flows(), + providers: upbridge.CloudProviders(), + scms: upbridge.SCMProviders(), + formInput: input, + spinner: pluralspinner.New(t), + gitChecker: upbridge.InGitRepo, + instanceLister: upbridge.DefaultInstanceLister(), + runner: upbridge.DefaultRunner(), + priorConsole: func() (string, string) { + c := upbridge.ReadPriorConsole() + return c.Url, c.Token + }, + saveConsole: upbridge.SaveConsoleConfig, } } @@ -202,8 +254,14 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m.applyOptions(msg) case domainMsg: return m.applyDomain(msg) + case instancesMsg: + return m.applyInstances(msg) + case runDoneMsg: + return m.applyRunDone(msg) + case deployDoneMsg: + return m.applyDeployDone(msg) case spinner.TickMsg: - if m.mode != modeProbing && m.mode != modeAppDomain && m.mode != modeRunPreflights { + if m.mode != modeProbing && m.mode != modeAppDomain && m.mode != modeRunPreflights && m.mode != modeLoadInstances && m.mode != modeRunning && m.mode != modeDeploying { return m, nil } if m.mode == modeAppDomain && (len(m.domainOpts) > 0 || m.formInput.Focused()) { @@ -229,6 +287,16 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.formInput, cmd = m.formInput.Update(msg) return m, cmd } + case modeConsoleLogin: + if m.consoleTokenMode { + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(msg) + return m, cmd + } + case modeCommitMsg: + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(msg) + return m, cmd } return m, nil } @@ -237,6 +305,14 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { switch m.mode { case modeSelected: return m.updateSelected(action) + case modeRunning, modeDeploying: + return m, nil // ignore keys while running + case modeDone: + return m.updateDone(action) + case modeCommitMsg: + return m.updateCommitMsg(action, key) + case modeComplete: + return m.updateComplete(action) case modeIgnoreContinue: return m.updateIgnoreContinue(action) case modeAffirmDeploy: @@ -251,11 +327,18 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m.updateCLITip(action) case modeProviderForm: return m.updateProviderForm(action, key) - case modeRunPreflights, modeProbing: + case modeRunPreflights, modeProbing, modeLoadInstances: if action == keyActionBack { + if m.mode == modeLoadInstances { + return m.updateLoadInstances(action) + } return m.updateProbing(action) } return m, nil + case modeSelectInstance: + return m.updateSelectInstance(action, key) + case modeConsoleLogin: + return m.updateConsoleLogin(action, key) case modeSelectProvider: return m.updateSelectProvider(action, key) case modeIgnorePreflights: @@ -332,12 +415,27 @@ func (m Model) updateIgnorePreflights(action keyAction, key tea.KeyPressMsg) (Mo func (m Model) updateSelectProvider(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { switch action { case keyActionBack: + m.err = nil + if m.flow.ID == "cloud" { + m.resetConsoleInput() + if len(m.instances) > 1 { + m.mode = modeSelectInstance + priorURL, _ := m.readPriorConsole() + m.cursor = upbridge.DefaultInstanceIndex(m.instances, priorURL) + return m, nil + } + m.mode = modeIgnorePreflights + m.cursor = 0 + if m.ignorePreflights { + m.cursor = 1 + } + return m, nil + } m.mode = modeIgnorePreflights m.cursor = 0 if m.ignorePreflights { m.cursor = 1 } - m.err = nil return m, nil case keyActionUp: if m.cursor > 0 { @@ -466,7 +564,24 @@ func (m Model) updateProviderForm(action keyAction, key tea.KeyPressMsg) (Model, } func (m Model) updateSelected(action keyAction) (Model, tea.Cmd) { - if action == keyActionBack { + switch action { + case keyActionConfirm: + return m.beginRun() + case keyActionBack: + if m.flow.Cloud { + // Cloud skips Affirm — Esc returns to domain (or provider when dry-run). + if !m.flow.DryRun { + m.mode = modeAppDomain + if m.domainIsSelect() { + m.formInput.Blur() + } else { + m.formInput.SetValue(m.appDomain) + m.formInput.Focus() + } + m.err = nil + return m, nil + } + } if !m.flow.DryRun { m.mode = modeAffirmDeploy m.cursor = 0 @@ -490,6 +605,170 @@ func (m Model) updateSelected(action keyAction) (Model, tea.Cmd) { return m, nil } +func (m Model) updateDone(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.mode = modeSelected + m.runErr = nil + m.runSteps = nil + return m, nil + case keyActionConfirm: + if m.runErr != nil || m.flow.DryRun { + return m, nil + } + return m.beginCommitMsg() + } + return m, nil +} + +func (m Model) updateComplete(action keyAction) (Model, tea.Cmd) { + if action == keyActionBack { + m.mode = modeDone + m.deployErr = nil + return m, nil + } + return m, nil +} + +func (m Model) beginCommitMsg() (Model, tea.Cmd) { + m.mode = modeCommitMsg + m.err = nil + m.formInput.EchoMode = textinput.EchoNormal + m.formInput.SetValue(m.commitMsg) + m.formInput.Placeholder = "empty to skip git commit/push" + m.formInput.Focus() + return m, nil +} + +func (m Model) updateCommitMsg(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.formInput.Blur() + m.mode = modeDone + return m, nil + case keyActionConfirm: + m.commitMsg = strings.TrimSpace(m.formInput.Value()) + m.formInput.Blur() + return m.beginDeploy() + } + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd +} + +func (m Model) beginDeploy() (Model, tea.Cmd) { + m.mode = modeDeploying + m.deployErr = nil + m.runSteps = nil + m.err = nil + return m, tea.Batch(m.spinner.Tick, m.deployCmd()) +} + +func (m Model) deployCmd() tea.Cmd { + runner := m.runner + if runner == nil { + runner = upbridge.DefaultRunner() + } + in := upbridge.DeployInput{ + Cloud: m.flow.Cloud, + CloudCluster: m.cloudInstance.Name, + ImportClusterID: m.importClusterID, + IgnorePreflights: m.ignorePreflights || m.flow.DryRun, + CommitMsg: m.commitMsg, + } + ctx := m.ctx + if ctx == nil { + ctx = context.Background() + } + return func() tea.Msg { + var steps []string + err := runner.Deploy(ctx, in, func(step string) { + steps = append(steps, step) + }) + return deployDoneMsg{err: err, steps: steps} + } +} + +func (m Model) applyDeployDone(msg deployDoneMsg) (Model, tea.Cmd) { + if m.mode != modeDeploying { + return m, nil + } + if len(msg.steps) > 0 { + m.runSteps = msg.steps + } + m.deployErr = msg.err + m.mode = modeComplete + if msg.err != nil { + m.err = msg.err + } else { + m.err = nil + } + return m, nil +} + +func (m Model) beginRun() (Model, tea.Cmd) { + if len(m.formValues) == 0 { + m.err = fmt.Errorf("provider survey values are required to write workspace.yaml (complete credentials/region first)") + return m, nil + } + m.err = nil + m.runErr = nil + m.deployErr = nil + m.runSteps = nil + m.importClusterID = "" + m.mode = modeRunning + return m, tea.Batch(m.spinner.Tick, m.runCmd()) +} + +func (m Model) runCmd() tea.Cmd { + runner := m.runner + if runner == nil { + runner = upbridge.DefaultRunner() + } + in := upbridge.RunInput{ + Flush: upbridge.FlushInput{ + ProviderID: m.provider.ID, + Values: copyStringMap(m.formValues), + AppDomain: m.appDomain, + Cloud: m.flow.Cloud, + }, + Generate: upbridge.GenerateInput{ + Cloud: m.flow.Cloud, + CloudCluster: m.cloudInstance.Name, + IgnorePreflights: m.ignorePreflights || m.flow.DryRun, + }, + } + ctx := m.ctx + if ctx == nil { + ctx = context.Background() + } + return func() tea.Msg { + var steps []string + res, err := runner.Run(ctx, in, func(step string) { + steps = append(steps, step) + }) + return runDoneMsg{err: err, steps: steps, importClusterID: res.ImportClusterID} + } +} + +func (m Model) applyRunDone(msg runDoneMsg) (Model, tea.Cmd) { + if m.mode != modeRunning { + return m, nil + } + if len(msg.steps) > 0 { + m.runSteps = msg.steps + } + m.importClusterID = msg.importClusterID + m.runErr = msg.err + m.mode = modeDone + if msg.err != nil { + m.err = msg.err + } else { + m.err = nil + } + return m, nil +} + func (m Model) updateSetupGit(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { opts := m.gitAffirmOptions() switch action { @@ -681,6 +960,9 @@ func (m Model) chooseIgnorePreflights(ignore bool) (Model, tea.Cmd) { m.ignorePreflights = ignore m.ignoreAsked = true m.err = nil + if m.flow.ID == "cloud" { + return m.beginLoadInstances() + } if m.flow.NeedsProvider() { m.mode = modeSelectProvider m.cursor = 0 @@ -690,6 +972,285 @@ func (m Model) chooseIgnorePreflights(ignore bool) (Model, tea.Cmd) { return m, nil } +func (m Model) readPriorConsole() (url, token string) { + if m.priorConsole != nil { + return m.priorConsole() + } + c := upbridge.ReadPriorConsole() + return c.Url, c.Token +} + +func (m Model) resetConsoleInput() { + m.formInput.EchoMode = textinput.EchoNormal + m.formInput.SetValue("") + m.formInput.Placeholder = "" + m.formInput.Blur() + m.consoleTokenMode = false +} + +func (m Model) beginLoadInstances() (Model, tea.Cmd) { + m.mode = modeLoadInstances + m.instances = nil + m.cloudInstance = upbridge.ConsoleInstance{} + m.err = nil + m.resetConsoleInput() + return m, tea.Batch(m.spinner.Tick, m.listInstancesCmd()) +} + +func (m Model) listInstancesCmd() tea.Cmd { + lister := m.instanceLister + if lister == nil { + lister = upbridge.DefaultInstanceLister() + } + ctx := m.ctx + if ctx == nil { + ctx = context.Background() + } + return func() tea.Msg { + items, err := lister.List(ctx) + return instancesMsg{items: items, err: err} + } +} + +func (m Model) applyInstances(msg instancesMsg) (Model, tea.Cmd) { + if m.mode != modeLoadInstances { + return m, nil + } + if msg.err != nil { + m.err = msg.err + m.mode = modeIgnorePreflights + m.cursor = 0 + if m.ignorePreflights { + m.cursor = 1 + } + return m, nil + } + if len(msg.items) == 0 { + m.err = fmt.Errorf("no cloud instances are available for this account") + m.mode = modeIgnorePreflights + m.cursor = 0 + if m.ignorePreflights { + m.cursor = 1 + } + return m, nil + } + m.instances = msg.items + m.err = nil + if len(msg.items) == 1 { + return m.chooseInstance(msg.items[0]) + } + priorURL, _ := m.readPriorConsole() + m.mode = modeSelectInstance + m.cursor = upbridge.DefaultInstanceIndex(msg.items, priorURL) + return m, nil +} + +func (m Model) updateLoadInstances(action keyAction) (Model, tea.Cmd) { + if action == keyActionBack { + m.mode = modeIgnorePreflights + m.cursor = 0 + if m.ignorePreflights { + m.cursor = 1 + } + m.err = nil + return m, nil + } + return m, nil +} + +func (m Model) updateSelectInstance(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.mode = modeIgnorePreflights + m.cursor = 0 + if m.ignorePreflights { + m.cursor = 1 + } + m.err = nil + m.cloudInstance = upbridge.ConsoleInstance{} + return m, nil + case keyActionUp: + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case keyActionDown: + if m.cursor < len(m.instances)-1 { + m.cursor++ + } + return m, nil + case keyActionConfirm: + if m.cursor >= 0 && m.cursor < len(m.instances) { + return m.chooseInstance(m.instances[m.cursor]) + } + return m, nil + } + text := keyText(key) + for i := range m.instances { + if text == string(rune('1'+i)) { + m.cursor = i + return m.chooseInstance(m.instances[i]) + } + } + return m, nil +} + +func (m Model) chooseInstance(inst upbridge.ConsoleInstance) (Model, tea.Cmd) { + m.cloudInstance = inst + m.err = nil + return m.beginConsoleLogin() +} + +func (m Model) beginConsoleLogin() (Model, tea.Cmd) { + m.mode = modeConsoleLogin + m.err = nil + priorURL, _ := m.readPriorConsole() + if upbridge.PriorConsoleMatches(priorURL, m.cloudInstance.URL) { + m.resetConsoleInput() + m.consoleTokenMode = false + m.cursor = 0 // Yes keep existing + return m, nil + } + return m.beginConsoleToken() +} + +func (m Model) beginConsoleToken() (Model, tea.Cmd) { + m.consoleTokenMode = true + m.mode = modeConsoleLogin + m.err = nil + m.formInput.EchoMode = textinput.EchoPassword + m.formInput.EchoCharacter = '*' + m.formInput.SetValue("") + m.formInput.Placeholder = "console access token" + m.formInput.Focus() + return m, nil +} + +func (m Model) updateConsoleLogin(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + if m.consoleTokenMode { + return m.updateConsoleToken(action, key) + } + priorURL, _ := m.readPriorConsole() + opts := consoleCredOptions(priorURL) + switch action { + case keyActionBack: + m.resetConsoleInput() + if len(m.instances) > 1 { + m.mode = modeSelectInstance + m.cursor = upbridge.DefaultInstanceIndex(m.instances, priorURL) + return m, nil + } + m.mode = modeIgnorePreflights + m.cursor = 0 + if m.ignorePreflights { + m.cursor = 1 + } + return m, nil + case keyActionUp: + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case keyActionDown: + if m.cursor < len(opts)-1 { + m.cursor++ + } + return m, nil + case keyActionConfirm: + return m.chooseConsoleCreds(opts[m.cursor].value) + } + text := keyText(key) + switch text { + case "1", "y", "Y": + m.cursor = 0 + return m.chooseConsoleCreds(true) + case "2", "n", "N": + m.cursor = 1 + return m.chooseConsoleCreds(false) + } + return m, nil +} + +func (m Model) chooseConsoleCreds(keep bool) (Model, tea.Cmd) { + if keep { + return m.finishConsoleLogin("") + } + return m.beginConsoleToken() +} + +func (m Model) updateConsoleToken(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + priorURL, _ := m.readPriorConsole() + if upbridge.PriorConsoleMatches(priorURL, m.cloudInstance.URL) { + m.resetConsoleInput() + m.consoleTokenMode = false + m.cursor = 0 + m.err = nil + return m, nil + } + m.resetConsoleInput() + if len(m.instances) > 1 { + m.mode = modeSelectInstance + m.cursor = upbridge.DefaultInstanceIndex(m.instances, priorURL) + return m, nil + } + m.mode = modeIgnorePreflights + m.cursor = 0 + if m.ignorePreflights { + m.cursor = 1 + } + return m, nil + case keyActionConfirm: + token := strings.TrimSpace(m.formInput.Value()) + if token == "" { + m.err = fmt.Errorf("console access token is required") + return m, nil + } + return m.finishConsoleLogin(token) + } + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd +} + +func (m Model) finishConsoleLogin(newToken string) (Model, tea.Cmd) { + priorURL, priorToken := m.readPriorConsole() + url := m.cloudInstance.URL + token := priorToken + confURL := priorURL + + if newToken != "" { + token = newToken + confURL = url + save := m.saveConsole + if save == nil { + save = upbridge.SaveConsoleConfig + } + if err := save(url, token); err != nil { + m.err = err + return m, nil + } + } else if !upbridge.PriorConsoleMatches(priorURL, url) { + m.err = fmt.Errorf("console credentials do not match the selected instance") + return m, nil + } + if confURL == "" { + confURL = url + } + + if err := upbridge.ValidateConsoleConfig(m.instances, console.Config{Url: confURL, Token: token}); err != nil { + m.err = err + return m, nil + } + + m.resetConsoleInput() + m.err = nil + m.mode = modeSelectProvider + m.cursor = 0 + return m, nil +} + func (m Model) beginProviderForm(p upbridge.Provider) (Model, tea.Cmd) { m.provider = p m.mode = modeProbing @@ -953,6 +1514,12 @@ func (m Model) confirmAppDomain() (Model, tea.Cmd) { } func (m Model) beginAffirmDeploy() (Model, tea.Cmd) { + // CLI skips AffirmUp when --cloud. + if m.flow.Cloud { + m.mode = modeSelected + m.err = nil + return m, nil + } m.mode = modeAffirmDeploy m.cursor = 0 // Yes m.err = nil diff --git a/tui/screens/up/model_test.go b/tui/screens/up/model_test.go index 3baf1757e..e6337757d 100644 --- a/tui/screens/up/model_test.go +++ b/tui/screens/up/model_test.go @@ -64,6 +64,15 @@ func testModel(t *testing.T) Model { model := NewWithProber(t.Context(), theme.New(colorprofile.ASCII), fakeProber{}) model.gitChecker = func() bool { return true } model.domainLoader = func() domainMsg { return domainMsg{text: true} } + model.instanceLister = fakeInstanceLister{ + items: []upbridge.ConsoleInstance{ + {ID: "1", Name: "demo-cloud", URL: "https://demo.onplural.sh"}, + {ID: "2", Name: "other-cloud", URL: "https://other.onplural.sh"}, + }, + } + model.priorConsole = func() (string, string) { return "", "" } + model.saveConsole = func(url, token string) error { return nil } + model.runner = &stubRunner{} return model } @@ -72,6 +81,101 @@ func testModelOutsideGit(t *testing.T, prober upbridge.Prober) Model { model := NewWithProber(t.Context(), theme.New(colorprofile.ASCII), prober) model.gitChecker = func() bool { return false } model.domainLoader = func() domainMsg { return domainMsg{text: true} } + model.instanceLister = fakeInstanceLister{items: []upbridge.ConsoleInstance{ + {ID: "1", Name: "demo-cloud", URL: "https://demo.onplural.sh"}, + }} + model.priorConsole = func() (string, string) { return "", "" } + model.saveConsole = func(url, token string) error { return nil } + model.runner = &stubRunner{} + return model +} + +type fakeInstanceLister struct { + items []upbridge.ConsoleInstance + err error +} + +func (f fakeInstanceLister) List(context.Context) ([]upbridge.ConsoleInstance, error) { + return f.items, f.err +} + +type stubRunner struct { + err error + deployErr error + calls []upbridge.RunInput + deploys []upbridge.DeployInput +} + +func (s *stubRunner) Run(_ context.Context, in upbridge.RunInput, progress upbridge.ProgressFunc) (upbridge.RunResult, error) { + s.calls = append(s.calls, in) + if progress != nil { + progress("Writing workspace.yaml…") + if in.Generate.Cloud { + progress("Resolving management cluster (ImportCluster)…") + } + progress("Generating bootstrap / terraform…") + } + res := upbridge.RunResult{} + if in.Generate.Cloud { + res.ImportClusterID = "mgmt-test-id" + } + return res, s.err +} + +func (s *stubRunner) Deploy(_ context.Context, in upbridge.DeployInput, progress upbridge.ProgressFunc) error { + s.deploys = append(s.deploys, in) + if progress != nil { + progress("Deploying management cluster…") + } + return s.deployErr +} + +func drainRun(t *testing.T, model Model, _ tea.Cmd) Model { + t.Helper() + if model.mode != modeRunning { + return model + } + msg := model.runCmd()() + model, _ = model.Update(msg) + return model +} + +func drainDeploy(t *testing.T, model Model) Model { + t.Helper() + if model.mode != modeDeploying { + return model + } + msg := model.deployCmd()() + model, _ = model.Update(msg) + return model +} + +func drainInstances(t *testing.T, model Model) Model { + t.Helper() + if model.mode != modeLoadInstances { + return model + } + msg := model.listInstancesCmd()() + model, _ = model.Update(msg) + return model +} + +func finishCloudToProvider(t *testing.T, model Model) Model { + t.Helper() + model = drainInstances(t, model) + if model.mode == modeSelectInstance { + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + } + if model.mode == modeConsoleLogin && !model.consoleTokenMode { + model, _ = model.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + } + if model.mode == modeConsoleLogin && model.consoleTokenMode { + model.formInput.SetValue("test-token") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + } + if model.mode != modeSelectProvider { + t.Fatalf("expected provider select after cloud login, got %d err=%v", model.mode, model.err) + } return model } @@ -357,12 +461,17 @@ func TestCloudModeAsksIgnorePreflights(t *testing.T) { if model.mode != modeIgnorePreflights || !model.Cloud() { t.Fatalf("cloud = mode=%d cloud=%v", model.mode, model.Cloud()) } - model, _ = model.Update(tea.KeyPressMsg{Code: 'r', Text: "r"}) - if model.mode != modeCLITip || model.IgnorePreflights() { - t.Fatalf("cli tip = mode=%d ignore=%v", model.mode, model.IgnorePreflights()) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'r', Text: "r"}) + if model.mode != modeLoadInstances || model.IgnorePreflights() { + t.Fatalf("load instances = mode=%d ignore=%v", model.mode, model.IgnorePreflights()) + } + _ = cmd + model = drainInstances(t, model) + if model.mode != modeSelectInstance { + t.Fatalf("expected instance select, got %d err=%v", model.mode, model.err) } - if !strings.Contains(model.View(80, 24), "plural up --cloud") || strings.Contains(model.View(80, 24), "--ignore-preflights") { - t.Fatalf("cli tip:\n%s", model.View(80, 24)) + if !strings.Contains(model.View(80, 24), "demo-cloud") { + t.Fatalf("missing instance:\n%s", model.View(80, 24)) } model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) if model.mode != modeIgnorePreflights { @@ -370,6 +479,230 @@ func TestCloudModeAsksIgnorePreflights(t *testing.T) { } } +func TestCloudPicksInstanceThenProvider(t *testing.T) { + model := testModel(t) + model.priorConsole = func() (string, string) { + return "https://demo.onplural.sh", "existing-token" + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'c', Text: "c"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'i', Text: "i"}) + model = drainInstances(t, model) + if model.mode != modeSelectInstance { + t.Fatalf("select instance = %d", model.mode) + } + // default cursor should prefer prior hostname match (demo-cloud) + if model.cursor != 0 { + t.Fatalf("default cursor = %d", model.cursor) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeConsoleLogin || model.consoleTokenMode { + t.Fatalf("expected use-existing Affirm, mode=%d token=%v", model.mode, model.consoleTokenMode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + if model.mode != modeSelectProvider || model.cloudInstance.Name != "demo-cloud" { + t.Fatalf("after login = mode=%d inst=%q err=%v", model.mode, model.cloudInstance.Name, model.err) + } +} + +func TestCloudSingleInstanceAutoSelects(t *testing.T) { + model := testModel(t) + model.instanceLister = fakeInstanceLister{items: []upbridge.ConsoleInstance{ + {ID: "1", Name: "only", URL: "https://only.onplural.sh"}, + }} + model, _ = model.Update(tea.KeyPressMsg{Code: 'c', Text: "c"}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = drainInstances(t, model) + if model.mode != modeConsoleLogin || model.cloudInstance.Name != "only" { + t.Fatalf("auto-select = mode=%d inst=%q err=%v", model.mode, model.cloudInstance.Name, model.err) + } +} + +func TestCloudEmptyInstancesErrors(t *testing.T) { + model := testModel(t) + model.instanceLister = fakeInstanceLister{} + model, _ = model.Update(tea.KeyPressMsg{Code: 'c', Text: "c"}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = drainInstances(t, model) + if model.mode != modeIgnorePreflights || model.err == nil { + t.Fatalf("expected error back to preflights, mode=%d err=%v", model.mode, model.err) + } +} + +func TestCloudSkipsDeployAffirm(t *testing.T) { + model := testModel(t) + model.priorConsole = func() (string, string) { + return "https://demo.onplural.sh", "tok" + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'c', Text: "c"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'i', Text: "i"}) + model = finishCloudToProvider(t, model) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + model = drainProbe(t, model, cmd) + model.formInput.SetValue("demo") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = finishToSelected(t, model) + if model.mode != modeSelected { + t.Fatalf("expected plan, got %d", model.mode) + } + view := model.View(100, 30) + if !strings.Contains(view, "demo-cloud") || !strings.Contains(view, "plural up --cloud") { + t.Fatalf("plan:\n%s", view) + } + // Esc from plan goes to domain (cloud skipped Affirm) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeAppDomain { + t.Fatalf("esc from cloud plan = %d", model.mode) + } +} + +func TestDryRunStillShowsCLITip(t *testing.T) { + model := testModel(t) + model, _ = model.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'r', Text: "r"}) + if model.mode != modeCLITip { + t.Fatalf("dry-run tip = %d", model.mode) + } +} + +func TestPlanEnterRunsGenerate(t *testing.T) { + model := selectAWSForm(t) + model.formInput.SetValue("demo") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = finishToSelected(t, model) + runner := model.runner.(*stubRunner) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeRunning { + t.Fatalf("expected running, got %d err=%v", model.mode, model.err) + } + model = drainRun(t, model, nil) + if model.mode != modeDone || model.runErr != nil { + t.Fatalf("done = mode=%d err=%v", model.mode, model.runErr) + } + if len(runner.calls) != 1 || runner.calls[0].Flush.Values["cluster"] != "demo" { + t.Fatalf("runner calls = %#v", runner.calls) + } + if !strings.Contains(model.View(80, 24), "Finished generating") { + t.Fatalf("done view:\n%s", model.View(80, 24)) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeSelected { + t.Fatalf("esc to plan = %d", model.mode) + } +} + +func TestPlanEnterWithoutFormValuesBlocked(t *testing.T) { + model := testModel(t) + model.mode = modeSelected + model.flow = model.flows[0] + model.provider = model.providers[0] + model.formValues = nil + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeSelected || model.err == nil { + t.Fatalf("expected stay on plan with error, mode=%d err=%v", model.mode, model.err) + } +} + +func TestPlanRunErrorShowsDone(t *testing.T) { + model := selectAWSForm(t) + model.formInput.SetValue("demo") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = finishToSelected(t, model) + model.runner = &stubRunner{err: context.DeadlineExceeded} + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = drainRun(t, model, nil) + if model.mode != modeDone || model.runErr == nil { + t.Fatalf("expected failed done, mode=%d err=%v", model.mode, model.runErr) + } + if !strings.Contains(model.View(80, 24), "Generate failed") { + t.Fatalf("view:\n%s", model.View(80, 24)) + } +} + +func TestCloudPlanRunPassesCloudFlags(t *testing.T) { + model := testModel(t) + model.priorConsole = func() (string, string) { + return "https://demo.onplural.sh", "tok" + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'c', Text: "c"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'i', Text: "i"}) + model = finishCloudToProvider(t, model) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + model = drainProbe(t, model, cmd) + model.formInput.SetValue("demo") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = finishToSelected(t, model) + runner := model.runner.(*stubRunner) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = drainRun(t, model, nil) + if model.mode != modeDone || len(runner.calls) != 1 { + t.Fatalf("mode=%d calls=%d", model.mode, len(runner.calls)) + } + call := runner.calls[0] + if !call.Flush.Cloud || !call.Generate.Cloud || call.Generate.CloudCluster != "demo-cloud" { + t.Fatalf("cloud run input = %#v", call) + } + if model.importClusterID != "mgmt-test-id" { + t.Fatalf("importClusterID = %q", model.importClusterID) + } +} + +func TestDeployAfterGenerate(t *testing.T) { + model := selectAWSForm(t) + model.formInput.SetValue("demo") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = finishToSelected(t, model) + runner := model.runner.(*stubRunner) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = drainRun(t, model, nil) + if model.mode != modeDone { + t.Fatalf("after generate = %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeCommitMsg { + t.Fatalf("expected commit msg, got %d", model.mode) + } + model.formInput.SetValue("bootstrap mgmt") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeDeploying { + t.Fatalf("expected deploying, got %d", model.mode) + } + model = drainDeploy(t, model) + if model.mode != modeComplete || model.deployErr != nil { + t.Fatalf("complete = mode=%d err=%v", model.mode, model.deployErr) + } + if len(runner.deploys) != 1 || runner.deploys[0].CommitMsg != "bootstrap mgmt" { + t.Fatalf("deploys = %#v", runner.deploys) + } + if !strings.Contains(model.View(80, 24), "Finished setting up") { + t.Fatalf("view:\n%s", model.View(80, 24)) + } +} + +func TestDeployErrorShowsComplete(t *testing.T) { + model := selectAWSForm(t) + model.formInput.SetValue("demo") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = finishToSelected(t, model) + model.runner = &stubRunner{deployErr: context.DeadlineExceeded} + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = drainRun(t, model, nil) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // empty commit + model = drainDeploy(t, model) + if model.mode != modeComplete || model.deployErr == nil { + t.Fatalf("expected deploy failure, mode=%d err=%v", model.mode, model.deployErr) + } + if !strings.Contains(model.View(80, 24), "Deploy failed") { + t.Fatalf("view:\n%s", model.View(80, 24)) + } +} + func TestProviderFormValidation(t *testing.T) { model := selectAWSForm(t) model.formInput.SetValue("this-name-is-way-too-long") @@ -416,7 +749,7 @@ func goldenModels(t *testing.T) (flow, ignore, provider, form, selected, cliTip, selected.formValues = map[string]string{"cluster": "demo", "region": "us-east-2"} cliTip = flow cliTip.mode = modeCLITip - cliTip.flow = cliTip.flows[1] + cliTip.flow = cliTip.flows[2] // dry-run stub tip cliTip.ignorePreflights = true cliTip.ignoreAsked = true git = flow diff --git a/tui/screens/up/testdata/up-clitip-120.golden b/tui/screens/up/testdata/up-clitip-120.golden index 07a494e7b..8e34843dc 100644 --- a/tui/screens/up/testdata/up-clitip-120.golden +++ b/tui/screens/up/testdata/up-clitip-120.golden @@ -1,18 +1,18 @@ - Plural Up cloud + Plural Up dry-run ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╭─ › Coming next ──────────────────────────────────────────────────────────────────────────────────────────────────╮ - │ Plural Cloud uses a different path than self-hosted. │ - │ Provider selection only applies to self-hosted up. │ + │ Dry-run is not fully wired in the TUI yet. │ + │ Use the CLI for this path, or pick Self-hosted / Plural Cloud. │ │ │ - │ Mode Plural Cloud │ - │ pick a Console instance (--cloud) · coming next │ + │ Mode Dry-run │ + │ generate repo only (--dry-run) · coming next │ │ Preflights ignored (--ignore-preflights) │ │ │ │ Equivalent CLI │ - │ plural up --cloud --ignore-preflights │ + │ plural up --dry-run --ignore-preflights │ │ │ - │ Cloud / dry-run wizards land in a later step. │ + │ Dry-run wizards land in a later step. │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/tui/screens/up/testdata/up-clitip-80.golden b/tui/screens/up/testdata/up-clitip-80.golden index 57d05f69b..d19d43186 100644 --- a/tui/screens/up/testdata/up-clitip-80.golden +++ b/tui/screens/up/testdata/up-clitip-80.golden @@ -1,18 +1,18 @@ - Plural Up cloud + Plural Up dry-run ──────────────────────────────────────────────────────────────────────────── ╭─ › Coming next ──────────────────────────────────────────────────────────╮ - │ Plural Cloud uses a different path than self-hosted. │ - │ Provider selection only applies to self-hosted up. │ + │ Dry-run is not fully wired in the TUI yet. │ + │ Use the CLI for this path, or pick Self-hosted / Plural Cloud. │ │ │ - │ Mode Plural Cloud │ - │ pick a Console instance (--cloud) · coming next │ + │ Mode Dry-run │ + │ generate repo only (--dry-run) · coming next │ │ Preflights ignored (--ignore-preflights) │ │ │ │ Equivalent CLI │ - │ plural up --cloud --ignore-preflights │ + │ plural up --dry-run --ignore-preflights │ │ │ - │ Cloud / dry-run wizards land in a later step. │ + │ Dry-run wizards land in a later step. │ │ │ ╰──────────────────────────────────────────────────────────────────────────╯ diff --git a/tui/screens/up/testdata/up-flow-120.golden b/tui/screens/up/testdata/up-flow-120.golden index 0a9f21bb5..7ba1bac65 100644 --- a/tui/screens/up/testdata/up-flow-120.golden +++ b/tui/screens/up/testdata/up-flow-120.golden @@ -6,7 +6,7 @@ │ Only self-hosted continues with the provider survey for now. │ │ │ │ › 1 s Self-hosted pick a cloud provider · provision management cluster │ - │ 2 c Plural Cloud pick a Console instance (--cloud) · coming next │ + │ 2 c Plural Cloud pick a Console instance (--cloud) · then provider survey │ │ 3 d Dry-run generate repo only (--dry-run) · coming next │ │ 4 x Cloud · dry-run Plural Cloud generate only · coming next │ │ │ diff --git a/tui/screens/up/testdata/up-flow-80.golden b/tui/screens/up/testdata/up-flow-80.golden index 4dee1b7c6..3820eca98 100644 --- a/tui/screens/up/testdata/up-flow-80.golden +++ b/tui/screens/up/testdata/up-flow-80.golden @@ -6,7 +6,7 @@ │ Only self-hosted continues with the provider survey for now. │ │ │ │ › 1 s Self-hosted pick a cloud provider · provision management c… │ - │ 2 c Plural Cloud pick a Console instance (--cloud) · coming next │ + │ 2 c Plural Cloud pick a Console instance (--cloud) · then provi… │ │ 3 d Dry-run generate repo only (--dry-run) · coming next │ │ 4 x Cloud · dry-run Plural Cloud generate only · coming next │ │ │ diff --git a/tui/screens/up/testdata/up-selected-120.golden b/tui/screens/up/testdata/up-selected-120.golden index 3fcff331b..04755fe4d 100644 --- a/tui/screens/up/testdata/up-selected-120.golden +++ b/tui/screens/up/testdata/up-selected-120.golden @@ -16,7 +16,7 @@ │ Equivalent CLI │ │ plural up --ignore-preflights │ │ │ - │ Affirmed deploy · next: workspace.yaml · generate · deploy. │ + │ Enter to Flush workspace.yaml + Generate, then Deploy. │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -27,4 +27,4 @@ - esc back · ctrl+c quit + enter run · esc back · ctrl+c quit diff --git a/tui/screens/up/testdata/up-selected-80.golden b/tui/screens/up/testdata/up-selected-80.golden index af8c72aaf..48532e1dd 100644 --- a/tui/screens/up/testdata/up-selected-80.golden +++ b/tui/screens/up/testdata/up-selected-80.golden @@ -16,9 +16,9 @@ │ Equivalent CLI │ │ plural up --ignore-preflights │ │ │ - │ Affirmed deploy · next: workspace.yaml · generate · deploy. │ + │ Enter to Flush workspace.yaml + Generate, then Deploy. │ │ │ ╰──────────────────────────────────────────────────────────────────────────╯ - esc back · ctrl+c quit + enter run · esc back · ctrl+c quit diff --git a/tui/screens/up/view.go b/tui/screens/up/view.go index 620c604b9..cfe44c3f7 100644 --- a/tui/screens/up/view.go +++ b/tui/screens/up/view.go @@ -24,6 +24,15 @@ func (m Model) headerStatus() string { switch m.mode { case modeIgnorePreflights: return m.theme.Muted.Render("step 2 · preflights") + case modeLoadInstances: + return m.theme.Muted.Render("loading Console instances…") + case modeSelectInstance: + return m.theme.Muted.Render("step · Console instance") + case modeConsoleLogin: + if m.consoleTokenMode { + return m.theme.Muted.Render("step · console token") + } + return m.theme.Muted.Render("step · console credentials") case modeSelectProvider: return m.theme.Muted.Render("step 3 · provider") case modeProbing: @@ -43,10 +52,29 @@ func (m Model) headerStatus() string { case modeAffirmDeploy: return m.theme.Muted.Render("step · deploy Affirm") case modeSelected: + if m.cloudInstance.Name != "" { + return m.theme.Success.Render(m.cloudInstance.Name) + } if m.provider.ID != "" { return m.theme.Success.Render(m.provider.ID) } return m.theme.Success.Render("ready") + case modeRunning: + return m.theme.Muted.Render("running Flush + Generate…") + case modeDone: + if m.runErr != nil { + return m.theme.Danger.Render("failed") + } + return m.theme.Success.Render("generated") + case modeCommitMsg: + return m.theme.Muted.Render("step · commit message") + case modeDeploying: + return m.theme.Muted.Render("deploying…") + case modeComplete: + if m.deployErr != nil { + return m.theme.Danger.Render("deploy failed") + } + return m.theme.Success.Render("deployed") case modeCLITip: return m.theme.Muted.Render(m.flow.ID) default: @@ -63,6 +91,12 @@ func (m Model) bodyAndHelp(width int) (string, string) { "Mode " + m.flow.Title, "Preflights " + yesNoLabel(m.ignorePreflights), } + if m.cloudInstance.Name != "" { + lines = append(lines, "Console "+m.cloudInstance.Name) + if m.cloudInstance.URL != "" { + lines = append(lines, " "+truncate(m.cloudInstance.URL, max(20, width-16))) + } + } if m.provider.Title != "" { lines = append(lines, "Provider "+m.provider.Title+" ("+m.provider.ID+")") } @@ -95,18 +129,146 @@ func (m Model) bodyAndHelp(width int) (string, string) { if !m.flow.DryRun { lines = append(lines, "App domain "+domain) } + next := "Enter to Flush workspace.yaml + Generate, then Deploy." + if m.flow.Cloud { + next = "Enter to Flush + ImportCluster + Generate, then Deploy." + } lines = append(lines, "", m.theme.Muted.Render("Equivalent CLI"), " "+m.cli(), "", - m.theme.Muted.Render("Affirmed deploy · next: workspace.yaml · generate · deploy."), + m.theme.Muted.Render(next), ) - return page.Panel(m.theme, "Plan", lines, width, 18, true), "esc back · ctrl+c quit" + help := "enter run · esc back · ctrl+c quit" + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + return page.Panel(m.theme, "Plan", lines, width, 18, true), help + case modeRunning: + lines := []string{ + m.spinner.View() + " " + m.theme.Muted.Render("Running Flush + Generate…"), + m.theme.Muted.Render("Next: commit message → Deploy."), + "", + } + if len(m.runSteps) == 0 { + lines = append(lines, m.theme.Muted.Render("Starting…")) + } else { + for _, s := range m.runSteps { + lines = append(lines, " · "+s) + } + } + return page.Panel(m.theme, "Generating", lines, width, 12, true), "please wait" + case modeDone: + lines := []string{} + if m.runErr != nil { + lines = append(lines, + m.theme.Danger.Render("Generate failed"), + "", + m.theme.Danger.Render(m.runErr.Error()), + "", + m.theme.Muted.Render("Esc returns to Plan to retry."), + ) + return page.Panel(m.theme, "Done", lines, width, 14, true), "esc plan · ctrl+c quit" + } + lines = append(lines, + m.theme.Success.Render("✓ Finished generating the repo"), + "", + ) + if m.flow.Cloud { + lines = append(lines, "Console "+m.cloudInstance.Name) + } + if m.provider.Title != "" { + lines = append(lines, "Provider "+m.provider.Title) + } + for _, s := range m.runSteps { + lines = append(lines, m.theme.Muted.Render(" · "+s)) + } + if m.flow.DryRun { + lines = append(lines, "", + m.theme.Muted.Render("Dry-run: no Deploy will run."), + ) + return page.Panel(m.theme, "Done", lines, width, 14, true), "esc plan · ctrl+c quit" + } + lines = append(lines, "", + m.theme.Muted.Render("Enter to continue to Deploy (commit message, then terraform)."), + m.theme.Muted.Render("Equivalent CLI: "+m.cli()), + ) + return page.Panel(m.theme, "Generated", lines, width, 16, true), "enter deploy · esc plan · ctrl+c quit" + case modeCommitMsg: + lines := []string{ + m.theme.Muted.Render("Enter a commit message to push your configuration."), + m.theme.Muted.Render("Leave empty to skip commit/push (same as plural up CommitMsg)."), + "", + "› Message", + " " + m.formInput.View(), + } + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + return page.Panel(m.theme, "Git commit", lines, width, 12, true), "enter deploy · esc back" + case modeDeploying: + lines := []string{ + m.spinner.View() + " " + m.theme.Muted.Render("Running Deploy (terraform / import / apps)…"), + "", + } + if len(m.runSteps) == 0 { + lines = append(lines, m.theme.Muted.Render("Starting…")) + } else { + for _, s := range m.runSteps { + lines = append(lines, " · "+s) + } + } + return page.Panel(m.theme, "Deploying", lines, width, 12, true), "please wait" + case modeComplete: + lines := []string{} + if m.deployErr != nil { + lines = append(lines, + m.theme.Danger.Render("Deploy failed"), + "", + m.theme.Danger.Render(m.deployErr.Error()), + "", + m.theme.Muted.Render("Esc returns to Generated to retry Deploy."), + ) + } else { + lines = append(lines, + m.theme.Success.Render("✓ Finished setting up your management cluster!"), + "", + ) + if m.flow.Cloud { + lines = append(lines, "Console "+m.cloudInstance.Name) + } + if m.provider.Title != "" { + lines = append(lines, "Provider "+m.provider.Title) + } + if m.commitMsg != "" { + lines = append(lines, "Commit "+truncate(m.commitMsg, max(20, width-12))) + } else { + lines = append(lines, "Commit (skipped)") + } + for _, s := range m.runSteps { + lines = append(lines, m.theme.Muted.Render(" · "+s)) + } + if m.provider.ID == "byok" && m.flow.Cloud { + lines = append(lines, "", + m.theme.Muted.Render("BYOK cloud: configure IAM for plrl-deploy-operator/stacks"), + m.theme.Muted.Render("(operator reinstall not run from TUI yet)."), + ) + } else { + lines = append(lines, "", + m.theme.Muted.Render("Use terraform as usual; gitops lives under bootstrap/."), + ) + } + lines = append(lines, "", + m.theme.Muted.Render("Equivalent CLI"), + " "+m.cli(), + ) + } + return page.Panel(m.theme, "Complete", lines, width, 16, true), "esc back · ctrl+c quit" case modeCLITip: lines := []string{ - m.theme.Muted.Render(m.flow.Title + " uses a different path than self-hosted."), - m.theme.Muted.Render("Provider selection only applies to self-hosted up."), + m.theme.Muted.Render(m.flow.Title + " is not fully wired in the TUI yet."), + m.theme.Muted.Render("Use the CLI for this path, or pick Self-hosted / Plural Cloud."), "", "Mode " + m.flow.Title, m.theme.Muted.Render(" " + m.flow.Blurb), @@ -115,9 +277,60 @@ func (m Model) bodyAndHelp(width int) (string, string) { m.theme.Muted.Render("Equivalent CLI"), " " + m.cli(), "", - m.theme.Muted.Render("Cloud / dry-run wizards land in a later step."), + m.theme.Muted.Render("Dry-run wizards land in a later step."), } return page.Panel(m.theme, "Coming next", lines, width, 14, true), "esc change preflights · ctrl+c quit" + case modeLoadInstances: + lines := []string{ + "Mode " + m.flow.Title, + "", + m.spinner.View() + " " + m.theme.Muted.Render("Fetching Console instances (GetConsoleInstances)…"), + m.theme.Muted.Render("Same list plural up --cloud uses in choseCluster."), + } + return page.Panel(m.theme, "Plural Cloud", lines, width, 10, true), "esc cancel" + case modeSelectInstance: + lines := []string{ + m.theme.Muted.Render("Select one of the following clusters:"), + m.theme.Muted.Render("Same survey as plural up --cloud choseCluster."), + "", + } + lines = append(lines, m.instanceLines(width)...) + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + help := "↑/↓ · 1–n · enter · esc preflights" + return page.Panel(m.theme, "Console instance", lines, width, 14, true), help + case modeConsoleLogin: + if m.consoleTokenMode { + lines := []string{ + "Instance " + m.cloudInstance.Name, + m.theme.Muted.Render(" "+truncate(m.cloudInstance.URL, max(20, width-12))), + "", + m.theme.Muted.Render("Enter your console access token (plural cd login)."), + "", + "› Token", + " " + m.formInput.View(), + } + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + return page.Panel(m.theme, "Console login", lines, width, 14, true), "enter continue · esc back" + } + priorURL, _ := m.readPriorConsole() + lines := []string{ + "Instance " + m.cloudInstance.Name, + m.theme.Muted.Render(" "+truncate(m.cloudInstance.URL, max(20, width-12))), + "", + m.theme.Muted.Render(fmt.Sprintf("You've already configured your console at %s,", truncate(priorURL, max(24, width-8)))), + m.theme.Muted.Render("continue using those credentials?"), + m.theme.Muted.Render("Same Affirm as HandleCdLogin (PLURAL_CD_USE_EXISTING_CREDENTIALS)."), + "", + } + lines = append(lines, m.consoleCredLines(width)...) + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + return page.Panel(m.theme, "Console credentials", lines, width, 14, true), "↑/↓ · y/n · enter · esc back" case modeSetupGit: lines := []string{} if m.probeWarn != "" { @@ -434,6 +647,56 @@ func (m Model) setupGitLines(width int) []string { return lines } +func (m Model) consoleCredLines(width int) []string { + priorURL, _ := m.readPriorConsole() + opts := consoleCredOptions(priorURL) + lines := make([]string, 0, len(opts)) + for i, opt := range opts { + cursor := " " + if i == m.cursor { + cursor = "› " + } + check := "[ ]" + if i == m.cursor { + check = "[x]" + } + shortcut := "y" + if !opt.value { + shortcut = "n" + } + left := fmt.Sprintf("%s %s %-4s %s", check, shortcut, opt.title, opt.blurb) + var row string + if i == m.cursor { + row = cursor + m.theme.Title.Render(left) + } else { + row = cursor + m.theme.Body.Render(left) + } + lines = append(lines, ansi.Truncate(row, max(1, width-2), "…")) + } + return lines +} + +func (m Model) instanceLines(width int) []string { + lines := make([]string, 0, len(m.instances)) + start, end := optionWindow(m.cursor, len(m.instances), 8) + for i := start; i < end; i++ { + inst := m.instances[i] + cursor := " " + if i == m.cursor { + cursor = "› " + } + left := fmt.Sprintf("%d %-20s %s", i+1, inst.Name, truncate(inst.URL, max(12, width-28))) + var row string + if i == m.cursor { + row = cursor + m.theme.Title.Render(left) + } else { + row = cursor + m.theme.Body.Render(left) + } + lines = append(lines, ansi.Truncate(row, max(1, width-2), "…")) + } + return lines +} + func (m Model) affirmDeployLines(width int) []string { opts := affirmDeployOptions() lines := make([]string, 0, len(opts))