diff --git a/.gitignore b/.gitignore index cd0d11399..e945beb17 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,6 @@ vendor/ # Agents .codex/ -AGENTS.md \ No newline at end of file +.claude/ +AGENTS.md +CLAUDE.md \ No newline at end of file diff --git a/cmd/command/ai/ai.go b/cmd/command/ai/ai.go index 64cd434a0..ee759d703 100644 --- a/cmd/command/ai/ai.go +++ b/cmd/command/ai/ai.go @@ -9,12 +9,11 @@ import ( "github.com/briandowns/spinner" "github.com/fatih/color" "github.com/pluralsh/plural-cli/pkg/api" + aibridge "github.com/pluralsh/plural-cli/pkg/bridge/ai" "github.com/pluralsh/plural-cli/pkg/utils" "github.com/urfave/cli" ) -const intro = "What can we do to help you with Plural, using open source, or kubernetes?" - type Plural struct { client.Plural } @@ -33,16 +32,16 @@ func Command(clients client.Plural) cli.Command { func (p *Plural) aiHelp(c *cli.Context) error { p.InitPluralClient() - chat := []*api.ChatMessage{{Role: "system", Content: intro}} + chat := []*api.ChatMessage{{Role: aibridge.RoleSystem, Content: aibridge.Intro}} utils.Success("Plural AI:\n") - fmt.Printf("%s\n\n", intro) + fmt.Printf("%s\n\n", aibridge.Intro) for { prompt, err := utils.ReadLine(color.New(color.FgYellow).Sprintf("You:\n")) if err != nil { return err } - chat = append(chat, &api.ChatMessage{Role: "user", Content: prompt}) + chat = append(chat, &api.ChatMessage{Role: aibridge.RoleUser, Content: prompt}) fmt.Print("\n") utils.Success("Plural AI:\n") diff --git a/cmd/command/edge/flash.go b/cmd/command/edge/flash.go index 427d2111e..c771dd925 100644 --- a/cmd/command/edge/flash.go +++ b/cmd/command/edge/flash.go @@ -1,10 +1,9 @@ package edge import ( - "fmt" - "io" "os" + pkgedge "github.com/pluralsh/plural-cli/pkg/edge" "github.com/pluralsh/plural-cli/pkg/utils" "github.com/schollz/progressbar/v3" "github.com/urfave/cli" @@ -14,33 +13,15 @@ func (p *Plural) handleEdgeFlash(c *cli.Context) error { image := c.String("image") device := c.String("device") - if err := p.flashImage(image, device); err != nil { - return err - } - - utils.Success("image flashed on %s device\n", device) - return nil -} - -func (p *Plural) flashImage(image, device string) error { - out, err := os.OpenFile(device, os.O_WRONLY, 0644) - if err != nil { - return fmt.Errorf("could not open device: %w", err) - } - defer out.Close() - - in, err := os.Open(image) - if err != nil { - return fmt.Errorf("could not open image: %w", err) - } - defer in.Close() - stat, err := os.Stat(image) if err != nil { return err } - bar := progressbar.DefaultBytes(stat.Size(), "flashing") - _, err = io.Copy(io.MultiWriter(out, bar), in) - return err + if err := pkgedge.Flash(pkgedge.FlashOptions{Image: image, Device: device, Progress: bar}); err != nil { + return err + } + + utils.Success("image flashed on %s device\n", device) + return nil } diff --git a/cmd/command/edge/image.go b/cmd/command/edge/image.go index ad491658d..b53b14bf5 100644 --- a/cmd/command/edge/image.go +++ b/cmd/command/edge/image.go @@ -1,274 +1,51 @@ package edge import ( - "bytes" "fmt" "io" - "net/http" "os" "path/filepath" - "strings" "time" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/remote" - gqlclient "github.com/pluralsh/console/go/client" "github.com/pluralsh/plural-cli/pkg/console" + pkgedge "github.com/pluralsh/plural-cli/pkg/edge" "github.com/pluralsh/plural-cli/pkg/utils" "github.com/urfave/cli" ) -const ( - cloudConfigURL = "https://raw.githubusercontent.com/pluralsh/edge/main/cloud-config.yaml" - pluralConfigURL = "https://raw.githubusercontent.com/pluralsh/edge/main/plural-config.yaml" - buildDir = "build" - cloudConfigFile = "cloud-config.yaml" - volumeName = "edge-rootfs" - volumeMountPath = "/rootfs" - volumeMount = "source=edge-rootfs,target=/rootfs" - wifiConfigTemplate = ` -stages: - boot: - - name: Setup Wi-Fi - commands: - - connmanctl enable wifi - - wpa_passphrase '@WIFI_SSID@' '@WIFI_PASSWORD@' > /etc/wpa_supplicant/wpa_supplicant.conf - - wpa_supplicant -B -i wlan0 -c /etc/wpa_supplicant/wpa_supplicant.conf - - udhcpc -i wlan0 &` - defaults = `#cloud-config -stages: - boot: - - name: Delete default Kairos user - commands: - - deluser --remove-home kairos` - dockerfile = "FROM scratch\nWORKDIR /build\nCOPY kairos.img /build" -) - -type Configuration struct { - Image string `json:"image"` - AurorabootImage string `json:"aurorabootImage"` - CraneImage string `json:"craneImage"` - Bundles map[string]string `json:"bundles"` -} - func (p *Plural) handleEdgeImage(c *cli.Context) error { - outputDir := c.String("output-dir") - project := c.String("project") - user := c.String("user") - pluralConfig := c.String("plural-config") - cloudConfig := c.String("cloud-config") - username := c.String("username") - password := c.String("password") - wifiSsid := c.String("wifi-ssid") - wifiPassword := c.String("wifi-password") - model := c.String("model") - imagePushURL := c.String("oci-url") - - if err := p.InitConsoleClient(consoleToken, consoleURL); err != nil { - return err - } - - utils.Highlight("reading configuration\n") - config, err := p.readPluralConfig(pluralConfig) - if err != nil { - return err - } - - utils.Highlight("preparing output directory\n") - currentDir, err := os.Getwd() - if err != nil { - return err - } - - outputDirPath := filepath.Join(currentDir, outputDir) - if err = os.MkdirAll(outputDirPath, os.ModePerm); err != nil { - return err - } - - buildDirPath := filepath.Join(outputDirPath, buildDir) - if err = os.MkdirAll(buildDirPath, os.ModePerm); err != nil { - return err - } - defer func() { - _ = os.RemoveAll(buildDirPath) - }() - - utils.Highlight("writing configuration\n") - cloudConfigPath := filepath.Join(outputDirPath, cloudConfigFile) - if err = p.writeCloudConfig(project, user, username, password, wifiSsid, wifiPassword, cloudConfigPath, cloudConfig); err != nil { - return err - } - - utils.Highlight("overwriting default configuration to remove default user\n") - defaultsPath := filepath.Join(outputDirPath, "defaults.yaml") - if err := utils.WriteFile(defaultsPath, []byte(defaults)); err != nil { - return err - } - defer func() { - _ = os.Remove(defaultsPath) - }() - - utils.Highlight("preparing %s volume\n", volumeName) - if err = utils.Exec("docker", "volume", "create", volumeName); err != nil { - return err - } - defer func() { - utils.Highlight("removing %s volume\n", volumeName) - _ = utils.Exec("docker", "volume", "rm", volumeName) - }() - - for bundle, image := range config.Bundles { - utils.Highlight("writing %s bundle\n", bundle) - if err = utils.Exec( - "docker", "run", "-i", "--rm", "--user", "root", "--mount", volumeMount, - config.CraneImage, "--platform=linux/arm64", "pull", image, fmt.Sprintf("%s/%s.tar", volumeMountPath, bundle)); err != nil { + options := pkgedge.ImageOptions{ + OutputDir: c.String("output-dir"), + Project: c.String("project"), + User: c.String("user"), + PluralConfig: c.String("plural-config"), + CloudConfig: c.String("cloud-config"), + Username: c.String("username"), + Password: c.String("password"), + WifiSSID: c.String("wifi-ssid"), + WifiPassword: c.String("wifi-password"), + Model: c.String("model"), + OCIURL: c.String("oci-url"), + } + + var client pkgedge.ConsoleAPI + if options.CloudConfig == "" { + if err := p.InitConsoleClient(consoleToken, consoleURL); err != nil { return err } - } - - utils.Highlight("unpacking image contents\n") - if err = utils.Exec("docker", "run", "-i", "--rm", "--privileged", "--mount", volumeMount, - "quay.io/luet/base", "util", "unpack", config.Image, volumeMountPath); err != nil { - return err - } - - utils.Highlight("building image\n") - if err = utils.Exec("docker", "run", "-v", "/var/run/docker.sock:/var/run/docker.sock", - "-v", buildDirPath+":/tmp/build", - "-v", cloudConfigPath+":/cloud-config.yaml", - "-v", defaultsPath+":/defaults.yaml", - "--mount", volumeMount, - "--privileged", "-i", "--rm", - "--entrypoint=/build-arm-image.sh", config.AurorabootImage, - "--model", model, - "--directory", volumeMountPath, - "--config", "/cloud-config.yaml", "/tmp/build/kairos.img"); err != nil { - return err - } - - if imagePushURL != "" { - dockerfilePath := filepath.Join(buildDirPath, "Dockerfile") - if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0644); err != nil { - return fmt.Errorf("cannot create Dockerfile: %w", err) - } - if err = utils.Exec("docker", "build", "-t", imagePushURL, "-f", dockerfilePath, buildDirPath); err != nil { - return err - } - if err = utils.Exec("docker", "push", imagePushURL); err != nil { - return err - } - - utils.Success("image pushed successfully to %s\n", imagePushURL) - } - - if err = utils.CopyDir(buildDirPath, outputDirPath); err != nil { - return fmt.Errorf("cannot move output files: %w", err) - } - - utils.Success("image saved to %s directory\n", outputDir) - - return nil -} - -func (p *Plural) createBootstrapToken(project, user string) (string, error) { - attrributes := gqlclient.BootstrapTokenAttributes{} - - if user != "" { - usr, err := p.ConsoleClient.GetUser(user) - if err != nil { - return "", err - } - if usr == nil { - return "", fmt.Errorf("cannot find %s user", user) + client = p.ConsoleClient + url := consoleURL + if url == "" { + url = console.ReadConfig().Url } - attrributes.UserID = &usr.ID - } - - proj, err := p.ConsoleClient.GetProject(project) - if err != nil { - return "", err - } - if proj == nil { - return "", fmt.Errorf("cannot find %s project", project) - } - attrributes.ProjectID = proj.ID - - return p.ConsoleClient.CreateBootstrapToken(attrributes) -} - -func (p *Plural) readPluralConfig(override string) (config *Configuration, err error) { - if override != "" { - err = utils.YamlFile(override, &config) - } else { - err = utils.RemoteYamlFile(pluralConfigURL, &config) - } - return config, err -} - -func (p *Plural) writeCloudConfig(project, user, username, password, wifiSsid, wifiPassword, path, override string) error { - if override != "" { - return utils.CopyFile(override, path) - } - - url := consoleURL - if url == "" { - url = console.ReadConfig().Url // Read URL from config if it was not provided via args or env var - } - - token, err := p.createBootstrapToken(project, user) - if err != nil { - return err - } - - if url == "" { - return fmt.Errorf("url cannot be empty when cloud config is not specified") - } - - if token == "" { - return fmt.Errorf("token cannot be empty when cloud config is not specified") - } - - if username == "" { - return fmt.Errorf("username cannot be empty when cloud config is not specified") - } - - if password == "" { - return fmt.Errorf("password cannot be empty when cloud config is not specified") - } - - response, err := http.Get(cloudConfigURL) - if err != nil { - return err - } - - defer response.Body.Close() - buffer := new(bytes.Buffer) - if _, err = buffer.ReadFrom(response.Body); err != nil { - return err - } - - template := buffer.String() - template = strings.ReplaceAll(template, "@URL@", url) - template = strings.ReplaceAll(template, "@TOKEN@", token) - template = strings.ReplaceAll(template, "@USERNAME@", username) - template = strings.ReplaceAll(template, "@PASSWORD@", password) - - if wifiSsid != "" && wifiPassword != "" { - wifiConfig := strings.ReplaceAll(wifiConfigTemplate, "@WIFI_SSID@", wifiSsid) - wifiConfig = strings.ReplaceAll(wifiConfig, "@WIFI_PASSWORD@", wifiPassword) - template += "\n" + wifiConfig - } - - file, err := os.Create(path) - if err != nil { - return err + options.ConsoleURL = url } - defer file.Close() - _, err = file.WriteString(template) - return err + return pkgedge.NewService(client).Build(options) } func (p *Plural) handleEdgeDownload(c *cli.Context) error { 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..aa4841252 --- /dev/null +++ b/cmd/command/tui/tui.go @@ -0,0 +1,73 @@ +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" + agentsbridge "github.com/pluralsh/plural-cli/pkg/bridge/agents" + aibridge "github.com/pluralsh/plural-cli/pkg/bridge/ai" + clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + edgebridge "github.com/pluralsh/plural-cli/pkg/bridge/edge" + 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" + 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" +) + +// 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) + services := servicesbridge.NewService(access) + clusters := clustersbridge.NewService(access) + repositories := repositoriesbridge.NewService(access) + pipelines := pipelinesbridge.NewService(access) + notifications := notificationsbridge.NewService(access) + providers := providersbridge.NewService(access) + stacks := stacksbridge.NewService(access) + pullrequests := pullrequestsbridge.NewService(access) + agents := agentsbridge.NewService(access) + workbenches := workbenchesbridge.NewService(access) + ai := aibridge.NewService() + edge := edgebridge.NewService(access) + return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{ + Welcome: welcome, + Access: access, + Services: services, + Clusters: clusters, + Repositories: repositories, + Pipelines: pipelines, + Notifications: notifications, + Providers: providers, + Stacks: stacks, + PullRequests: pullrequests, + Agents: agents, + Workbenches: workbenches, + AI: ai, + Edge: edge, + }) + }) +} + +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/cmd/command/up/up.go b/cmd/command/up/up.go index 3cfcbc8a9..d621d3dbf 100644 --- a/cmd/command/up/up.go +++ b/cmd/command/up/up.go @@ -248,6 +248,11 @@ func askAppDomain(project *manifest.ProjectManifest) error { return fmt.Errorf("project manifest is required to set app domain") } + if manifest.AppDomainAlreadyConfigured(project) { + utils.Highlight("App domain already configured, skipping...\n") + return nil + } + var domain string switch project.Provider { @@ -256,7 +261,7 @@ func askAppDomain(project *manifest.ProjectManifest) error { if err != nil { utils.Error("Failed to fetch hosted zones from AWS: %s\n", err) fmt.Println("ignoring domain setup...") - break + return nil } if err := survey.AskOne( @@ -274,12 +279,12 @@ func askAppDomain(project *manifest.ProjectManifest) error { if err != nil { utils.Error("Failed to fetch DNS zones from Azure: %s\n", err) fmt.Println("ignoring domain setup...") - break + return nil } // Skip domain setup if no DNS zones exist in the resource group. if len(dnsZones) == 0 { - break + return nil } if err := survey.AskOne( @@ -311,8 +316,8 @@ func askAppDomain(project *manifest.ProjectManifest) error { func processAppDomain(domain string, project *manifest.ProjectManifest) error { if lo.IsEmpty(domain) { - // No domain was provided, domain checks and setup can be skipped. - return nil + // Persist the skip so resume does not re-prompt. + return project.PersistAppDomain("") } if project.Provider == api.ProviderGCP { @@ -357,9 +362,7 @@ func processAppDomain(domain string, project *manifest.ProjectManifest) error { project.Context["ManagedZone"] = managedZone } - // Save the domain and other changes to the project manifest. - project.AppDomain = domain - return project.Flush() + return project.PersistAppDomain(domain) } func getCluster(cd *cdpkg.Plural) (id string, err error) { diff --git a/cmd/command/up/up_test.go b/cmd/command/up/up_test.go new file mode 100644 index 000000000..6fef7a2d1 --- /dev/null +++ b/cmd/command/up/up_test.go @@ -0,0 +1,117 @@ +package up + +import ( + "path/filepath" + "testing" + + "github.com/pluralsh/plural-cli/pkg/manifest" +) + +func TestAppDomainAlreadyConfigured(t *testing.T) { + tests := []struct { + name string + project *manifest.ProjectManifest + want bool + }{ + { + name: "nil project", + project: nil, + want: false, + }, + { + name: "never asked", + project: &manifest.ProjectManifest{}, + want: false, + }, + { + name: "legacy manifest with app domain", + project: &manifest.ProjectManifest{AppDomain: "apps.example.com"}, + want: true, + }, + { + name: "explicit skip", + project: &manifest.ProjectManifest{AppDomainConfigured: true}, + want: true, + }, + { + name: "configured with domain", + project: &manifest.ProjectManifest{ + AppDomain: "apps.example.com", + AppDomainConfigured: true, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := manifest.AppDomainAlreadyConfigured(tt.project); got != tt.want { + t.Errorf("AppDomainAlreadyConfigured() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestProcessAppDomainPersistsSkip(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + project := &manifest.ProjectManifest{Cluster: "test"} + if err := project.Write(filepath.Join(dir, "workspace.yaml")); err != nil { + t.Fatal(err) + } + + if err := processAppDomain("", project); err != nil { + t.Fatal(err) + } + + if !project.AppDomainConfigured { + t.Fatal("expected AppDomainConfigured after skipping domain") + } + if project.AppDomain != "" { + t.Fatalf("expected empty AppDomain, got %q", project.AppDomain) + } + + loaded, err := manifest.ReadProject(filepath.Join(dir, "workspace.yaml")) + if err != nil { + t.Fatal(err) + } + if !loaded.AppDomainConfigured { + t.Fatal("expected AppDomainConfigured to be flushed to workspace.yaml") + } + if loaded.AppDomain != "" { + t.Fatalf("expected flushed AppDomain to be empty, got %q", loaded.AppDomain) + } +} + +func TestProcessAppDomainPersistsDomain(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + project := &manifest.ProjectManifest{Cluster: "test", Provider: "aws"} + if err := project.Write(filepath.Join(dir, "workspace.yaml")); err != nil { + t.Fatal(err) + } + + if err := processAppDomain("apps.example.com", project); err != nil { + t.Fatal(err) + } + + if !project.AppDomainConfigured { + t.Fatal("expected AppDomainConfigured after setting domain") + } + if project.AppDomain != "apps.example.com" { + t.Fatalf("AppDomain = %q, want apps.example.com", project.AppDomain) + } + + loaded, err := manifest.ReadProject(filepath.Join(dir, "workspace.yaml")) + if err != nil { + t.Fatal(err) + } + if !loaded.AppDomainConfigured { + t.Fatal("expected AppDomainConfigured to be flushed to workspace.yaml") + } + if loaded.AppDomain != "apps.example.com" { + t.Fatalf("flushed AppDomain = %q, want apps.example.com", loaded.AppDomain) + } +} diff --git a/cmd/command/workbenches/workbenches.go b/cmd/command/workbenches/workbenches.go index 0eb413ea3..7c23260dd 100644 --- a/cmd/command/workbenches/workbenches.go +++ b/cmd/command/workbenches/workbenches.go @@ -11,6 +11,7 @@ import ( pluralclient "github.com/pluralsh/plural-cli/pkg/client" "github.com/pluralsh/plural-cli/pkg/common" "github.com/pluralsh/plural-cli/pkg/utils" + pkgworkbenches "github.com/pluralsh/plural-cli/pkg/workbenches" ) type outputFormat string @@ -93,7 +94,7 @@ func (w *Workbenches) prFollowupCommand() cli.Command { cli.StringFlag{ Name: "provider", Usage: "source control provider (auto, github, gitlab, or bitbucket)", - Value: string(ProviderAuto), + Value: string(pkgworkbenches.ProviderAuto), }, cli.StringFlag{ Name: "defer", @@ -132,12 +133,12 @@ func (w *Workbenches) handlePRFollowup(ctx *cli.Context) error { return err } - service := NewPRFollowupService(w.ConsoleClient, NewPullRequestResolver(nil)) - result, err := service.Create(PRFollowupOptions{ + service := pkgworkbenches.NewPRFollowupService(w.ConsoleClient, pkgworkbenches.NewPullRequestResolver(nil)) + result, err := service.Create(pkgworkbenches.PRFollowupOptions{ Prompt: ctx.String("prompt"), DeferBy: deferBy, SkipMissing: ctx.Bool("skip-missing"), - PullRequest: PullRequestOptions{ + PullRequest: pkgworkbenches.PullRequestOptions{ URL: ctx.String("url"), Commit: ctx.String("commit"), BaseURL: ctx.String("base-url"), @@ -151,7 +152,7 @@ func (w *Workbenches) handlePRFollowup(ctx *cli.Context) error { return w.writePRFollowupResult(output, result) } -func (w *Workbenches) writePRFollowupResult(output outputFormat, result PRFollowupResult) error { +func (w *Workbenches) writePRFollowupResult(output outputFormat, result pkgworkbenches.PRFollowupResult) error { switch output { case outputFormatRaw: if result.Skipped { diff --git a/cmd/command/workbenches/workbenches_test.go b/cmd/command/workbenches/workbenches_test.go index 564297e0c..260ec408b 100644 --- a/cmd/command/workbenches/workbenches_test.go +++ b/cmd/command/workbenches/workbenches_test.go @@ -16,6 +16,7 @@ import ( pluralclient "github.com/pluralsh/plural-cli/pkg/client" "github.com/pluralsh/plural-cli/pkg/test/mocks" + pkgworkbenches "github.com/pluralsh/plural-cli/pkg/workbenches" ) func TestCommandShape(t *testing.T) { @@ -117,7 +118,7 @@ func TestHandlePRFollowupRejectsEmptyWorkbenchJobURL(t *testing.T) { } func TestPRFollowupResultJSON(t *testing.T) { - result := PRFollowupResult{ + result := pkgworkbenches.PRFollowupResult{ PromptID: "prompt-1", PullRequestURL: "https://github.com/pluralsh/plural-cli/pull/5078", WorkbenchJobURL: "https://console.example.com/workbenches/jobs/job-1", @@ -153,7 +154,7 @@ func prFollowupContext(t *testing.T, args ...string) *cli.Context { flags.String("commit", "", "") flags.String("base-url", "", "") flags.String("prompt", "", "") - flags.String("provider", string(ProviderAuto), "") + flags.String("provider", string(pkgworkbenches.ProviderAuto), "") flags.String("defer", "0s", "") flags.String("output", "raw", "") flags.String("o", "raw", "") diff --git a/go.mod b/go.mod index 0acbb9c8d..c9b423cb2 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/pluralsh/plural-cli go 1.26.6 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.2 @@ -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.55.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 8d55cb9f8..8e4051303 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/agents/command.go b/pkg/agents/command.go index a072d1c63..ec89c4435 100644 --- a/pkg/agents/command.go +++ b/pkg/agents/command.go @@ -1,8 +1,10 @@ package agents import ( + "bytes" "context" "fmt" + "io" "os" "os/exec" "strings" @@ -35,8 +37,16 @@ func (in *executable) Run(ctx context.Context, command string, args ...string) e cmd.Env = append(os.Environ(), in.env...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + var stderr bytes.Buffer + cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) + if err := cmd.Run(); err != nil { + text := strings.TrimSpace(stderr.String()) + if text == "" { + return fmt.Errorf("%s: %w", cmd.String(), err) + } + return fmt.Errorf("%s: %w\n%s", cmd.String(), err, text) + } + return nil } func (in *executable) Output(ctx context.Context, command string, args ...string) (string, error) { diff --git a/pkg/agents/command_test.go b/pkg/agents/command_test.go new file mode 100644 index 000000000..cd98362b3 --- /dev/null +++ b/pkg/agents/command_test.go @@ -0,0 +1,27 @@ +package agents + +import ( + "strings" + "testing" +) + +func TestExecutableRunIncludesStderr(t *testing.T) { + err := Executable(".").Run(t.Context(), "sh", "-c", "echo boom >&2; exit 7") + if err == nil { + t.Fatal("expected command failure") + } + got := err.Error() + if !strings.Contains(got, "boom") || !strings.Contains(got, "exit status 7") { + t.Fatalf("error %q missing stderr or exit status", got) + } +} + +func TestExecutableRunWrapsEmptyStderr(t *testing.T) { + err := Executable(".").Run(t.Context(), "false") + if err == nil { + t.Fatal("expected command failure") + } + if !strings.Contains(err.Error(), "false") { + t.Fatalf("error %q missing command", err) + } +} diff --git a/pkg/agents/interaction.go b/pkg/agents/interaction.go index 6d198d5c9..a237266aa 100644 --- a/pkg/agents/interaction.go +++ b/pkg/agents/interaction.go @@ -58,6 +58,31 @@ func (SurveyInteraction) Confirm(message string, def bool) (bool, error) { return confirmed, nil } +// AcceptingInteraction answers restore confirmations without prompting. The TUI +// resume flow uses this so survey does not steal the path-entry Enter key and +// fail with an empty or interrupt error. +type AcceptingInteraction struct{} + +func (AcceptingInteraction) Confirm(string, bool) (bool, error) { return true, nil } + +func (AcceptingInteraction) Select(_ string, options []string) (string, error) { + if len(options) == 0 { + return "", fmt.Errorf("no options to select") + } + return options[0], nil +} + +func (AcceptingInteraction) Directory(_, def string) (string, error) { + if strings.TrimSpace(def) == "" { + return "", fmt.Errorf("directory path is required") + } + expanded, err := homedir.Expand(def) + if err != nil { + return "", err + } + return filepath.Abs(expanded) +} + func (SurveyInteraction) Select(message string, options []string) (string, error) { var selected string if err := survey.AskOne(&survey.Select{ diff --git a/pkg/agents/restorer_codex.go b/pkg/agents/restorer_codex.go index 91c533665..d3bfdd896 100644 --- a/pkg/agents/restorer_codex.go +++ b/pkg/agents/restorer_codex.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "strings" "time" @@ -66,11 +67,78 @@ func (r *CodexRestorer) Prepare(_ context.Context, opts RestoreOptions) (*Prepar } func (r *CodexRestorer) Resume(ctx context.Context, prepared *PreparedSession) error { - return r.resume(ctx, prepared, nil, "codex", "resume", prepared.SessionID, "-C", ".") + _, env, args, err := r.resumeInvocation(prepared) + if err != nil { + return err + } + return r.resume(ctx, prepared, env, "codex", args...) +} + +func (r *CodexRestorer) resumeInvocation(prepared *PreparedSession) (home string, env, args []string, err error) { + home, err = r.configDir() + if err != nil { + return "", nil, nil, err + } + repo := "." + if prepared != nil && strings.TrimSpace(prepared.RepoPath) != "" { + repo = prepared.RepoPath + if abs, absErr := filepath.Abs(repo); absErr == nil { + repo = abs + } + } + args = []string{"resume", "--all"} + if prepared != nil && strings.TrimSpace(prepared.SessionID) != "" { + args = append(args, prepared.SessionID) + } + args = append(args, "-C", repo) + return home, []string{"CODEX_HOME=" + home}, args, nil } func (r *CodexRestorer) configDir() (string, error) { - return r.baseRestorer.configDir("CODEX_HOME", ".codex") + if dir := strings.TrimSpace(os.Getenv("CODEX_HOME")); dir != "" { + return dir, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + if path, err := lookPath("codex"); err == nil { + if dir := snapCodexHome(path, home); dir != "" { + return dir, nil + } + } + return filepath.Join(home, ".codex"), nil +} + +// lookPath locates executables on PATH. Tests replace it. +var lookPath = exec.LookPath + +// snapCodexHome returns the snap-confined Codex home when `codex` is a snap +// binary. Snap stores config.toml at ~/snap/codex/current, not ~/.codex. +func snapCodexHome(codexPath, userHome string) string { + if !strings.Contains(filepath.ToSlash(codexPath), "/snap/") { + return "" + } + current := filepath.Join(userHome, "snap", "codex", "current") + common := filepath.Join(userHome, "snap", "codex", "common") + for _, candidate := range []string{current, common} { + if looksLikeCodexHome(candidate) { + return candidate + } + } + if info, err := os.Stat(current); err == nil && info.IsDir() { + return current + } + return "" +} + +func looksLikeCodexHome(dir string) bool { + for _, name := range []string{"config.toml", "auth.json"} { + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + return true + } + } + return false } func (r *CodexRestorer) archivedSessionFile(sessionsDir, sessionID string) (string, error) { diff --git a/pkg/agents/restorer_codex_test.go b/pkg/agents/restorer_codex_test.go index 83e919c7f..69471493a 100644 --- a/pkg/agents/restorer_codex_test.go +++ b/pkg/agents/restorer_codex_test.go @@ -138,3 +138,108 @@ func TestCodexRestorerPrepareUsesExistingSessionWhenOverwriteDenied(t *testing.T assertFileContent(t, existing, `{"type":"session_meta","payload":{"id":"session-id","timestamp":"2026-06-01T10:00:00Z"}}`) assertNotExists(t, filepath.Join(codexHome, "sessions", "2026", "06", "02", "session.jsonl")) } + +func TestCodexResumeInvocationDisablesCwdFilterAndSetsHome(t *testing.T) { + codexHome := t.TempDir() + t.Setenv("CODEX_HOME", codexHome) + repo := t.TempDir() + abs, err := filepath.Abs(repo) + if err != nil { + t.Fatal(err) + } + + home, env, args, err := (&CodexRestorer{}).resumeInvocation(&PreparedSession{ + RepoPath: repo, + SessionID: "01a08034-e9fb-7a00-baba-df7d0660e69f", + }) + if err != nil { + t.Fatalf("resumeInvocation returned error: %v", err) + } + if home != codexHome { + t.Fatalf("expected home %q, got %q", codexHome, home) + } + if len(env) != 1 || env[0] != "CODEX_HOME="+codexHome { + t.Fatalf("expected CODEX_HOME env, got %v", env) + } + want := []string{"resume", "--all", "01a08034-e9fb-7a00-baba-df7d0660e69f", "-C", abs} + if len(args) != len(want) { + t.Fatalf("expected args %v, got %v", want, args) + } + for i := range want { + if args[i] != want[i] { + t.Fatalf("expected args %v, got %v", want, args) + } + } +} + +func TestCodexConfigDirUsesSnapHome(t *testing.T) { + userHome := t.TempDir() + t.Setenv("HOME", userHome) + t.Setenv("CODEX_HOME", "") + snapHome := filepath.Join(userHome, "snap", "codex", "current") + if err := os.MkdirAll(snapHome, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(snapHome, "config.toml"), []byte(""), 0644); err != nil { + t.Fatal(err) + } + + orig := lookPath + t.Cleanup(func() { lookPath = orig }) + lookPath = func(string) (string, error) { + return "/snap/bin/codex", nil + } + + got, err := (&CodexRestorer{}).configDir() + if err != nil { + t.Fatalf("configDir returned error: %v", err) + } + if got != snapHome { + t.Fatalf("expected snap home %q, got %q", snapHome, got) + } +} + +func TestCodexConfigDirFallsBackToDotCodex(t *testing.T) { + userHome := t.TempDir() + t.Setenv("HOME", userHome) + t.Setenv("CODEX_HOME", "") + + orig := lookPath + t.Cleanup(func() { lookPath = orig }) + lookPath = func(string) (string, error) { + return "/usr/local/bin/codex", nil + } + + got, err := (&CodexRestorer{}).configDir() + if err != nil { + t.Fatalf("configDir returned error: %v", err) + } + want := filepath.Join(userHome, ".codex") + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestSnapCodexHome(t *testing.T) { + userHome := t.TempDir() + if got := snapCodexHome("/usr/bin/codex", userHome); got != "" { + t.Fatalf("expected empty for non-snap binary, got %q", got) + } + + current := filepath.Join(userHome, "snap", "codex", "current") + if err := os.MkdirAll(current, 0755); err != nil { + t.Fatal(err) + } + got := snapCodexHome("/snap/bin/codex", userHome) + if got != current { + t.Fatalf("expected %q when snap dir exists, got %q", current, got) + } + + if err := os.WriteFile(filepath.Join(current, "config.toml"), []byte(""), 0644); err != nil { + t.Fatal(err) + } + got = snapCodexHome("/snap/bin/codex", userHome) + if got != current { + t.Fatalf("expected %q when config exists, got %q", current, got) + } +} 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..130b95427 --- /dev/null +++ b/pkg/bridge/access/access.go @@ -0,0 +1,347 @@ +package access + +import ( + "context" + "errors" + "fmt" + "net/url" + "sort" + "strings" + "sync" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +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 + ActiveConsole(context.Context) (url, token string, err 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" + } + rollback, err := s.replaceCredential(ctx, profile.ID, session.Credential) + if err != nil { + return Profile{}, err + } + state, err := s.repository.Load(ctx) + if err != nil { + rollback() + return Profile{}, err + } + state.Profiles = upsertProfile(state.Profiles, profile) + state.ActiveProfileID = profile.ID + if err := s.repository.Save(ctx, state); err != nil { + rollback() + 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") + } + rollback, err := s.replaceCredential(ctx, profile.ID, token) + if err != nil { + return ConsoleProfile{}, err + } + state, err := s.repository.Load(ctx) + if err != nil { + rollback() + return ConsoleProfile{}, err + } + state.ConsoleProfiles = upsertConsole(state.ConsoleProfiles, profile) + state.ActiveConsoleID = profile.ID + if err := s.repository.Save(ctx, state); err != nil { + rollback() + 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) +} + +// 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 { + 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() } + +// replaceCredential overwrites a stored secret and returns a rollback that +// restores the previous value when the profile already existed. Deleting on +// every failed save would wipe credentials for profiles that remain in the +// registry after a registry write fails. +func (s *Service) replaceCredential(ctx context.Context, id, value string) (func(), error) { + previous, getErr := s.credentials.Get(ctx, id) + existed := getErr == nil + if err := s.credentials.Set(ctx, id, value); err != nil { + return nil, err + } + return func() { + if existed { + _ = s.credentials.Set(ctx, id, previous) + return + } + _ = s.credentials.Delete(ctx, id) + }, nil +} + +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) +} + +// 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 new file mode 100644 index 000000000..ba21c8d52 --- /dev/null +++ b/pkg/bridge/access/access_test.go @@ -0,0 +1,246 @@ +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 + saveErr error +} + +func (r *memoryAccessRepository) Load(context.Context) (State, error) { return r.state, nil } +func (r *memoryAccessRepository) Save(_ context.Context, state State) error { + if r.saveErr != nil { + return r.saveErr + } + 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 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 TestCompleteDeviceLoginSaveFailureRestoresExistingCredential(t *testing.T) { + profileID := stableID("app", "personal", "dev@example.com", "app.plural.sh") + repository := &memoryAccessRepository{ + state: State{ + Profiles: []Profile{{ID: profileID, Name: "personal", Email: "dev@example.com", Endpoint: "app.plural.sh"}}, + ActiveProfileID: profileID, + }, + saveErr: errors.New("disk full"), + } + credentials := &memoryCredentials{values: map[string]string{profileID: "old-token"}} + service := NewService(repository, credentials, bridge.NewAuthService(fakeAuthFactory{&fakeAuthClient{}}, time.Millisecond), nil) + if _, err := service.CompleteDeviceLogin(t.Context(), "personal", DeviceAuthorization{DeviceToken: "device"}, "app.plural.sh"); err == nil { + t.Fatal("CompleteDeviceLogin() expected save error") + } + if credentials.values[profileID] != "old-token" { + t.Fatalf("credential = %q, want restored old-token", credentials.values[profileID]) + } +} + +func TestCompleteDeviceLoginSaveFailureDropsNewCredential(t *testing.T) { + repository := &memoryAccessRepository{saveErr: errors.New("disk full")} + credentials := &memoryCredentials{} + service := NewService(repository, credentials, bridge.NewAuthService(fakeAuthFactory{&fakeAuthClient{}}, time.Millisecond), nil) + if _, err := service.CompleteDeviceLogin(t.Context(), "personal", DeviceAuthorization{DeviceToken: "device"}, "app.plural.sh"); err == nil { + t.Fatal("CompleteDeviceLogin() expected save error") + } + if len(credentials.values) != 0 { + t.Fatalf("new credential was not rolled back: %#v", credentials.values) + } +} + +func TestAddConsoleProfileSaveFailureRestoresExistingCredential(t *testing.T) { + profileID := stableID("console", "production", "https://console.example.com") + repository := &memoryAccessRepository{ + state: State{ + ConsoleProfiles: []ConsoleProfile{{ID: profileID, Name: "production", URL: "https://console.example.com"}}, + ActiveConsoleID: profileID, + }, + saveErr: errors.New("disk full"), + } + credentials := &memoryCredentials{values: map[string]string{profileID: "old-token"}} + service := NewService(repository, credentials, nil, nil) + if _, err := service.AddConsoleProfile(t.Context(), "production", "https://console.example.com", "new-token"); err == nil { + t.Fatal("AddConsoleProfile() expected save error") + } + if credentials.values[profileID] != "old-token" { + t.Fatalf("credential = %q, want restored old-token", credentials.values[profileID]) + } +} + +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..019b39ad0 --- /dev/null +++ b/pkg/bridge/access/local.go @@ -0,0 +1,236 @@ +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 + } + 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) { + 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 +} + +// 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 { + 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..9ac9a5d9a --- /dev/null +++ b/pkg/bridge/access/local_test.go @@ -0,0 +1,81 @@ +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 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 { + 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..5319905af --- /dev/null +++ b/pkg/bridge/access/plural_service_accounts_test.go @@ -0,0 +1,40 @@ +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: http.StatusOK, + 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/agents/agents.go b/pkg/bridge/agents/agents.go new file mode 100644 index 000000000..61e143dc7 --- /dev/null +++ b/pkg/bridge/agents/agents.go @@ -0,0 +1,208 @@ +// 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) + Resume(ctx context.Context, id, repoPath, prRef string) 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 + session Session + 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/agents/resume.go b/pkg/bridge/agents/resume.go new file mode 100644 index 000000000..5d6807894 --- /dev/null +++ b/pkg/bridge/agents/resume.go @@ -0,0 +1,89 @@ +package agents + +import ( + "context" + "errors" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + + pkgagents "github.com/pluralsh/plural-cli/pkg/agents" + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +// Session downloads and restores a Console agent run into a local checkout. +type Session interface { + Download(context.Context, *gqlclient.AgentRunMinimalFragment) (*pkgagents.SessionBundle, error) + RestoreAndResume(context.Context, *pkgagents.SessionBundle, string) error +} + +func (s *Service) sessions() Session { + if s.session == nil { + s.session = pkgagents.NewSessionService(pkgagents.WithSessionInteraction(pkgagents.AcceptingInteraction{})) + } + return s.session +} + +// Resume downloads the run's session and restores it into repoPath, then +// launches the provider resume command. +func (s *Service) Resume(ctx context.Context, id, repoPath, prRef string) error { + if err := ctx.Err(); err != nil { + return err + } + id = strings.TrimSpace(id) + repoPath = strings.TrimSpace(repoPath) + if id == "" { + return &bridge.Error{Code: bridge.ErrorInvalid, Err: errors.New("agent run id is required")} + } + if repoPath == "" { + return &bridge.Error{Code: bridge.ErrorInvalid, Err: errors.New("local clone path is required")} + } + client, err := s.client(ctx) + if err != nil { + return err + } + run, err := client.GetAgentRun(id) + if err != nil { + return err + } + if !resumable(run) { + return &bridge.Error{Code: bridge.ErrorUnavailable, Err: errors.New("agent run has no uploaded session")} + } + applyPullRequest(run, prRef) + bundle, err := s.sessions().Download(ctx, run) + if err != nil { + return err + } + return s.sessions().RestoreAndResume(ctx, bundle, repoPath) +} + +func applyPullRequest(run *gqlclient.AgentRunMinimalFragment, prRef string) { + prs := pullRequestsWithRef(run) + if len(prs) == 0 { + return + } + prRef = strings.TrimSpace(prRef) + if prRef != "" { + for _, pr := range prs { + if pr.GetRef() != nil && *pr.GetRef() == prRef { + run.PullRequests = []*gqlclient.AgentRunMinimalFragment_PullRequests{pr} + return + } + } + } + run.PullRequests = []*gqlclient.AgentRunMinimalFragment_PullRequests{prs[0]} +} + +func pullRequestsWithRef(run *gqlclient.AgentRunMinimalFragment) []*gqlclient.AgentRunMinimalFragment_PullRequests { + if run == nil { + return nil + } + prs := make([]*gqlclient.AgentRunMinimalFragment_PullRequests, 0, len(run.GetPullRequests())) + for _, pr := range run.GetPullRequests() { + if pr == nil || pr.GetRef() == nil || strings.TrimSpace(*pr.GetRef()) == "" { + continue + } + prs = append(prs, pr) + } + return prs +} diff --git a/pkg/bridge/agents/resume_test.go b/pkg/bridge/agents/resume_test.go new file mode 100644 index 000000000..ab791816d --- /dev/null +++ b/pkg/bridge/agents/resume_test.go @@ -0,0 +1,111 @@ +package agents + +import ( + "context" + "errors" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + + pkgagents "github.com/pluralsh/plural-cli/pkg/agents" + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeSession struct { + runID string + path string + err error +} + +func (f *fakeSession) Download(_ context.Context, run *gqlclient.AgentRunMinimalFragment) (*pkgagents.SessionBundle, error) { + if run != nil { + f.runID = run.ID + } + if f.err != nil { + return nil, f.err + } + return &pkgagents.SessionBundle{Run: run, Manifest: &pkgagents.SessionManifest{}}, nil +} + +func (f *fakeSession) RestoreAndResume(_ context.Context, _ *pkgagents.SessionBundle, path string) error { + f.path = path + return f.err +} + +func resumableRun(id, prRef string) *gqlclient.AgentRunMinimalFragment { + session := "https://example.com/session.tgz" + provider := gqlclient.AgentRuntimeTypeCodex + branch := "main" + run := &gqlclient.AgentRunMinimalFragment{ + ID: id, + Repository: "git@github.com:acme/repo.git", + Branch: &branch, + Prompt: "fix it", + Runtime: &gqlclient.AgentRunMinimalFragment_Runtime{Type: provider}, + Upload: &gqlclient.AgentRunMinimalFragment_Upload{Session: &session}, + } + if prRef != "" { + ref := prRef + run.PullRequests = []*gqlclient.AgentRunMinimalFragment_PullRequests{{ID: "pr-1", Ref: &ref}} + } + return run +} + +func TestResumeDownloadsAndRestoresSession(t *testing.T) { + session := &fakeSession{} + service := NewService(fakeResolver{}) + service.session = session + service.newClient = func(string, string) (API, error) { + return fakeAPI{runs: []*gqlclient.AgentRunMinimalFragment{resumableRun("run-1", "feat/fix")}}, nil + } + if err := service.Resume(t.Context(), "run-1", "/work/repo", "feat/fix"); err != nil { + t.Fatal(err) + } + if session.runID != "run-1" || session.path != "/work/repo" { + t.Fatalf("session = %#v", session) + } +} + +func TestResumeRequiresClonePath(t *testing.T) { + service := NewService(fakeResolver{}) + err := service.Resume(t.Context(), "run-1", " ", "") + if !bridge.IsCode(err, bridge.ErrorInvalid) { + t.Fatalf("err = %v", err) + } +} + +func TestResumeRejectsMissingSession(t *testing.T) { + service := NewService(fakeResolver{}) + service.newClient = func(string, string) (API, error) { + return fakeAPI{runs: []*gqlclient.AgentRunMinimalFragment{{ID: "missing"}}}, nil + } + err := service.Resume(t.Context(), "missing", "/work/repo", "") + if !bridge.IsCode(err, bridge.ErrorUnavailable) { + t.Fatalf("err = %v", err) + } +} + +func TestResumeReturnsSessionError(t *testing.T) { + session := &fakeSession{err: errors.New("not a git checkout")} + service := NewService(fakeResolver{}) + service.session = session + service.newClient = func(string, string) (API, error) { + return fakeAPI{runs: []*gqlclient.AgentRunMinimalFragment{resumableRun("run-1", "")}}, nil + } + if err := service.Resume(t.Context(), "run-1", "/work/repo", ""); err == nil || err.Error() != "not a git checkout" { + t.Fatalf("err = %v", err) + } +} + +func TestApplyPullRequestKeepsMatchingRef(t *testing.T) { + first, second := "feat/a", "feat/b" + run := resumableRun("run-1", first) + run.PullRequests = []*gqlclient.AgentRunMinimalFragment_PullRequests{ + {ID: "1", Ref: &first}, + {ID: "2", Ref: &second}, + } + applyPullRequest(run, "feat/b") + if len(run.PullRequests) != 1 || run.PullRequests[0].ID != "2" { + t.Fatalf("prs = %#v", run.PullRequests) + } +} diff --git a/pkg/bridge/ai/ai.go b/pkg/bridge/ai/ai.go new file mode 100644 index 000000000..e75401ea0 --- /dev/null +++ b/pkg/bridge/ai/ai.go @@ -0,0 +1,91 @@ +// Package ai exposes Plural App chat to presentation layers. +package ai + +import ( + "context" + "errors" + "strings" + + "github.com/pluralsh/plural-cli/pkg/api" + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/config" +) + +// Intro is the system prompt used by `plural ai` and the TUI chat screen. +const Intro = "What can we do to help you with Plural, using open source, or kubernetes?" + +const ( + RoleSystem = "system" + RoleUser = "user" + RoleAssistant = "assistant" +) + +var errNoApp = errors.New("connect a Plural App profile before chatting") + +// Message is one turn in an App-API chat history. +type Message struct { + Name string + Content string + Role string +} + +// Client is the TUI-facing chat surface. +type Client interface { + Chat(context.Context, []Message) (Message, error) +} + +// API is the App GraphQL chat method used by Service. +type API interface { + Chat(history []*api.ChatMessage) (*api.ChatMessage, error) +} + +// ClientFactory constructs an App chat client that honors caller cancellation. +type ClientFactory func(ctx context.Context) (API, error) + +// Service implements Client against the Plural App API. +type Service struct { + newClient ClientFactory +} + +// NewService chats with the active Plural App token from ~/.plural/config.yml. +func NewService() *Service { + return &Service{newClient: defaultClient} +} + +func defaultClient(ctx context.Context) (API, error) { + if !config.Exists() || strings.TrimSpace(config.Read().Token) == "" { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoApp} + } + conf := config.Read() + return api.FromConfigWithContext(ctx, &conf), nil +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s == nil || s.newClient == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoApp} + } + return s.newClient(ctx) +} + +// Chat sends history to Plural App and returns the assistant reply. +func (s *Service) Chat(ctx context.Context, history []Message) (Message, error) { + if err := ctx.Err(); err != nil { + return Message{}, err + } + client, err := s.client(ctx) + if err != nil { + return Message{}, err + } + hist := make([]*api.ChatMessage, len(history)) + for i, message := range history { + hist[i] = &api.ChatMessage{Name: message.Name, Content: message.Content, Role: message.Role} + } + reply, err := client.Chat(hist) + if err != nil { + return Message{}, err + } + if reply == nil { + return Message{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errors.New("empty chat reply")} + } + return Message{Name: reply.Name, Content: reply.Content, Role: reply.Role}, nil +} diff --git a/pkg/bridge/ai/ai_test.go b/pkg/bridge/ai/ai_test.go new file mode 100644 index 000000000..7c05ecd0f --- /dev/null +++ b/pkg/bridge/ai/ai_test.go @@ -0,0 +1,69 @@ +package ai + +import ( + "context" + "errors" + "testing" + + "github.com/pluralsh/plural-cli/pkg/api" + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeAPI struct { + history []*api.ChatMessage + reply *api.ChatMessage + err error +} + +func (f *fakeAPI) Chat(history []*api.ChatMessage) (*api.ChatMessage, error) { + f.history = history + return f.reply, f.err +} + +func TestChatMapsHistoryAndReply(t *testing.T) { + fake := &fakeAPI{reply: &api.ChatMessage{Role: RoleAssistant, Name: "plural", Content: "try plural up"}} + service := &Service{newClient: func(context.Context) (API, error) { return fake, nil }} + reply, err := service.Chat(t.Context(), []Message{ + {Role: RoleSystem, Content: Intro}, + {Role: RoleUser, Content: "how do I bootstrap?"}, + }) + if err != nil { + t.Fatal(err) + } + if reply.Content != "try plural up" || reply.Role != RoleAssistant { + t.Fatalf("reply = %#v", reply) + } + if len(fake.history) != 2 || fake.history[1].Content != "how do I bootstrap?" { + t.Fatalf("history = %#v", fake.history) + } +} + +func TestChatRequiresAppToken(t *testing.T) { + service := &Service{} + _, err := service.Chat(t.Context(), []Message{{Role: RoleUser, Content: "hi"}}) + if !bridge.IsCode(err, bridge.ErrorUnauthenticated) { + t.Fatalf("err = %v", err) + } +} + +func TestChatHonorsCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + service := &Service{newClient: func(context.Context) (API, error) { + t.Fatal("factory should not run after cancel") + return nil, nil + }} + if _, err := service.Chat(ctx, nil); !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v", err) + } +} + +func TestChatRejectsEmptyReply(t *testing.T) { + service := &Service{newClient: func(context.Context) (API, error) { + return &fakeAPI{}, nil + }} + _, err := service.Chat(t.Context(), nil) + if !bridge.IsCode(err, bridge.ErrorUnavailable) { + t.Fatalf("err = %v", err) + } +} 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/clusters/clusters.go b/pkg/bridge/clusters/clusters.go new file mode 100644 index 000000000..1ccfdb4b2 --- /dev/null +++ b/pkg/bridge/clusters/clusters.go @@ -0,0 +1,247 @@ +// 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" +) + +const defaultPageSize int64 = 10 + +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 +} + +// 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, 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) + 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 + 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.ListClusters() + if err != nil { + return Page{}, err + } + if result == nil || result.Clusters == nil { + return Page{}, 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 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 + } + 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 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 { + 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..39d7f9aa8 --- /dev/null +++ b/pkg/bridge/clusters/clusters_test.go @@ -0,0 +1,109 @@ +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 }, + pageSize: 50, + } + + 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") + 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 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"}, + 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/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/edge/edge.go b/pkg/bridge/edge/edge.go new file mode 100644 index 000000000..ec001bd25 --- /dev/null +++ b/pkg/bridge/edge/edge.go @@ -0,0 +1,129 @@ +// Package edge exposes Console-backed edge image build and flash for the TUI. +package edge + +import ( + "bufio" + "context" + "errors" + "io" + "strings" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" + pkgedge "github.com/pluralsh/plural-cli/pkg/edge" +) + +var errNoConsole = errors.New("connect a Console profile before building an edge image") + +type ConsoleResolver interface { + ActiveConsole(context.Context) (url, token string, err error) +} + +type API interface { + pkgedge.ConsoleAPI +} + +type ClientFactory func(token, url string) (API, error) + +type Loader interface { + BuildImage(ctx context.Context, options pkgedge.ImageOptions, log func(string)) error + Flash(ctx context.Context, options pkgedge.FlashOptions, log func(string)) error +} + +type Service struct { + resolve ConsoleResolver + newClient ClientFactory + flash func(pkgedge.FlashOptions) error +} + +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + flash: pkgedge.Flash, + } +} + +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) BuildImage(ctx context.Context, options pkgedge.ImageOptions, log func(string)) error { + if err := ctx.Err(); err != nil { + return err + } + var client pkgedge.ConsoleAPI + if strings.TrimSpace(options.CloudConfig) == "" { + api, err := s.client(ctx) + if err != nil { + return err + } + client = api + if options.ConsoleURL == "" && s.resolve != nil { + if url, _, err := s.resolve.ActiveConsole(ctx); err == nil { + options.ConsoleURL = url + } + } + } + svc := pkgedge.NewService(client) + if log != nil { + svc.Log = log + reader, writer := io.Pipe() + svc.Output = writer + done := make(chan struct{}) + go func() { + defer close(done) + scanLines(reader, log) + }() + defer func() { + _ = writer.Close() + <-done + }() + } + return svc.Build(options) +} + +func (s *Service) Flash(ctx context.Context, options pkgedge.FlashOptions, log func(string)) error { + if err := ctx.Err(); err != nil { + return err + } + if log != nil { + log("flashing " + options.Image + " onto " + options.Device) + reader, writer := io.Pipe() + options.Log = writer + done := make(chan struct{}) + go func() { + defer close(done) + scanLines(reader, log) + }() + defer func() { + _ = writer.Close() + <-done + }() + } + flash := s.flash + if flash == nil { + flash = pkgedge.Flash + } + return flash(options) +} + +func scanLines(r io.Reader, log func(string)) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := strings.TrimRight(scanner.Text(), "\r") + if line != "" { + log(line) + } + } +} diff --git a/pkg/bridge/edge/edge_test.go b/pkg/bridge/edge/edge_test.go new file mode 100644 index 000000000..69a03353b --- /dev/null +++ b/pkg/bridge/edge/edge_test.go @@ -0,0 +1,58 @@ +package edge + +import ( + "context" + "errors" + "strings" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + + pkgedge "github.com/pluralsh/plural-cli/pkg/edge" +) + +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 { + token string +} + +func (f fakeAPI) GetUser(string) (*gqlclient.UserFragment, error) { + return &gqlclient.UserFragment{ID: "user-1"}, nil +} +func (f fakeAPI) GetProject(string) (*gqlclient.ProjectFragment, error) { + return &gqlclient.ProjectFragment{ID: "proj-1"}, nil +} +func (f fakeAPI) CreateBootstrapToken(gqlclient.BootstrapTokenAttributes) (string, error) { + return f.token, nil +} + +func TestBuildImageRequiresConsoleWithoutCloudConfig(t *testing.T) { + service := NewService(fakeResolver{err: errors.New("no console")}) + err := service.BuildImage(t.Context(), pkgedge.ImageOptions{Password: "x"}, nil) + if err == nil || !strings.Contains(err.Error(), "no console") { + t.Fatalf("error = %v", err) + } +} + +func TestFlashDelegates(t *testing.T) { + var got pkgedge.FlashOptions + service := NewService(fakeResolver{}) + service.flash = func(options pkgedge.FlashOptions) error { + got = options + return nil + } + if err := service.Flash(t.Context(), pkgedge.FlashOptions{Image: "kairos.img", Device: "/dev/sda"}, nil); err != nil { + t.Fatal(err) + } + if got.Image != "kairos.img" || got.Device != "/dev/sda" { + t.Fatalf("flash options = %#v", got) + } +} 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/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/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/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/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/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/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/actions.go b/pkg/bridge/services/actions.go new file mode 100644 index 000000000..8874a3c01 --- /dev/null +++ b/pkg/bridge/services/actions.go @@ -0,0 +1,280 @@ +package services + +import ( + "context" + "fmt" + "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" + "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 resp.Close() + if err := fs.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 new file mode 100644 index 000000000..3df66e14c --- /dev/null +++ b/pkg/bridge/services/errors.go @@ -0,0 +1,12 @@ +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") + 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 new file mode 100644 index 000000000..41610505d --- /dev/null +++ b/pkg/bridge/services/services.go @@ -0,0 +1,396 @@ +// Package services exposes Console service list/get/mutation use cases to +// presentation layers without importing TUI code. +package services + +import ( + "context" + "fmt" + "sort" + "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 + +// 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 +} + +// ConfigEntry is one service configuration name/value pair. +type ConfigEntry struct { + Name string + Value string +} + +// Component is one deployed Kubernetes object on the service. +type Component struct { + ID string + Name string + Namespace string + Kind string + Version string + State string + Synced bool +} + +// Repository is the git repository backing a service, without credentials. +type Repository struct { + ID string + URL string + AuthMethod string + Health string + Error string +} + +// Detail is the credential-free detail payload for a service deployment. +// Fields match `plural cd services describe`. +type Detail struct { + Summary + Version string + Tarball string + DeletedAt string + DryRun bool + Templated bool + ClusterID string + ClusterName string + ClusterHandle string + RevisionID string + RevisionSHA string + RevisionRef string + KustomizePath string + Repository *Repository + Configuration []ConfigEntry + Components []Component + 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 +} + +// 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) 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), + }, + Version: service.Version, + Tarball: derefString(service.Tarball), + DeletedAt: derefString(service.DeletedAt), + DryRun: derefBool(service.DryRun, false), + Templated: derefBool(service.Templated, true), + } + if service.Git != nil { + detail.GitRef = service.Git.Ref + detail.GitFolder = service.Git.Folder + } + if service.Kustomize != nil { + detail.KustomizePath = service.Kustomize.Path + } + if service.Cluster != nil { + detail.ClusterID = service.Cluster.ID + detail.ClusterName = service.Cluster.Name + if service.Cluster.Handle != nil { + detail.ClusterHandle = *service.Cluster.Handle + } + } + if service.Revision != nil { + detail.RevisionID = service.Revision.ID + 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 + } + } + if service.Repository != nil { + repo := &Repository{ID: service.Repository.ID, URL: service.Repository.URL} + if service.Repository.AuthMethod != nil { + repo.AuthMethod = string(*service.Repository.AuthMethod) + } + if service.Repository.Health != nil { + repo.Health = string(*service.Repository.Health) + } + if service.Repository.Error != nil { + repo.Error = *service.Repository.Error + } + detail.Repository = repo + } + if len(service.Configuration) > 0 { + detail.Configuration = make([]ConfigEntry, 0, len(service.Configuration)) + for _, conf := range service.Configuration { + if conf == nil { + continue + } + detail.Configuration = append(detail.Configuration, ConfigEntry{Name: conf.Name, Value: conf.Value}) + } + sort.Slice(detail.Configuration, func(i, j int) bool { + return detail.Configuration[i].Name < detail.Configuration[j].Name + }) + } + detail.Components = make([]Component, 0, len(service.Components)) + for _, component := range service.Components { + if component == nil { + continue + } + item := Component{ + ID: component.ID, + Name: component.Name, + Kind: fmt.Sprint(component.Kind), + Synced: component.Synced, + } + if component.Namespace != nil { + item.Namespace = *component.Namespace + } + if component.Version != nil { + item.Version = *component.Version + } + if component.State != nil { + item.State = string(*component.State) + } + if item.Synced { + detail.Synced++ + } + detail.Components = append(detail.Components, item) + } + for _, item := range service.Errors { + if item == nil { + continue + } + detail.Errors = append(detail.Errors, ServiceError{Source: item.Source, Message: item.Message}) + } + return detail +} + +func derefString(value *string) string { + if value == nil { + return "" + } + return *value +} + +func derefBool(value *bool, fallback bool) bool { + if value == nil { + return fallback + } + return *value +} + +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..34700c703 --- /dev/null +++ b/pkg/bridge/services/services_test.go @@ -0,0 +1,202 @@ +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 (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" + 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 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" + tarball := "https://console.example.com/tarball/svc-1" + deleted := "2026-09-08T10:00:00Z" + ns := "default" + version := "apps/v1" + state := gqlclient.ComponentStateRunning + health := gqlclient.GitHealthPullable + auth := gqlclient.AuthMethodSSH + dryRun := false + templated := true + kustomize := "overlays/prod" + api := &fakeAPI{detail: &gqlclient.ServiceDeploymentExtended{ + ID: "svc-1", Name: "api", Namespace: "default", Version: "0.1.4", + Status: gqlclient.ServiceDeploymentStatusFailed, + Tarball: &tarball, + DeletedAt: &deleted, + DryRun: &dryRun, + Templated: &templated, + Git: &gqlclient.GitRefFragment{Ref: "main", Folder: "services/api"}, + Kustomize: &gqlclient.KustomizeFragment{Path: kustomize}, + Cluster: &gqlclient.BaseClusterFragment{Name: "prod", Handle: &handle}, + Revision: &gqlclient.RevisionFragment{ID: "rev-1", Sha: &sha, Git: &gqlclient.RevisionFragment_Git{Ref: "main"}}, + Repository: &gqlclient.GitRepositoryFragment{ + ID: "repo-1", URL: "https://github.com/acme/fleet.git", AuthMethod: &auth, Health: &health, + }, + Configuration: []*gqlclient.ServiceDeploymentExtended_Configuration{ + {Name: "cluster", Value: "prod"}, + {Name: "replicas", Value: "3"}, + }, + Components: []*gqlclient.ServiceDeploymentExtended_Components{ + {ID: "cmp-1", Name: "api", Namespace: &ns, Kind: "Deployment", Version: &version, State: &state, Synced: true}, + {ID: "cmp-2", Name: "api", Kind: "Service", 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.RevisionID != "rev-1" { + t.Fatalf("revision/cluster = %#v", detail) + } + if detail.Version != "0.1.4" || detail.Tarball == "" || !detail.Templated || detail.DryRun || detail.DeletedAt == "" { + t.Fatalf("identity = %#v", detail) + } + if detail.KustomizePath != "overlays/prod" || detail.Repository == nil || detail.Repository.URL == "" { + t.Fatalf("git = %#v", detail) + } + if len(detail.Configuration) != 2 || detail.Configuration[0].Name != "cluster" { + t.Fatalf("configuration = %#v", detail.Configuration) + } + if len(detail.Components) != 2 || detail.Synced != 1 || detail.Components[0].Kind != "Deployment" { + t.Fatalf("components = %#v", detail.Components) + } + if len(detail.Errors) != 1 || detail.Errors[0].Source != "sync" { + t.Fatalf("errors = %#v", detail.Errors) + } +} + +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/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 new file mode 100644 index 000000000..a0c428a79 --- /dev/null +++ b/pkg/bridge/stacks/stacks.go @@ -0,0 +1,251 @@ +// Package stacks exposes Console infrastructure stack list/get/gen-backend +// 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 +} + +// 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) + +// 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 +} + +// 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..02236b074 --- /dev/null +++ b/pkg/bridge/stacks/stacks_test.go @@ -0,0 +1,192 @@ +package stacks + +import ( + "context" + "os" + "path/filepath" + "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 { + stacks *gqlclient.ListInfrastructureStacks + listErr error + detail *gqlclient.InfrastructureStackFragment + getErr error + runs *gqlclient.ListStackRuns + runsErr 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 (f *fakeAPI) ListStackRuns(string) (*gqlclient.ListStackRuns, error) { + return f.runs, f.runsErr +} + +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) + } +} + +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/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/up/commit.go b/pkg/bridge/up/commit.go new file mode 100644 index 000000000..33818a043 --- /dev/null +++ b/pkg/bridge/up/commit.go @@ -0,0 +1,21 @@ +package up + +import ( + "strings" + + "github.com/AlecAivazis/survey/v2" + + "github.com/pluralsh/plural-cli/pkg/utils" +) + +// promptCommitMessage mirrors common.CommitMsg's interactive survey (no cli.Context). +func promptCommitMessage() string { + utils.Highlight("\n==> Enter a commit message to push your configuration\n\n") + var commit string + if err := survey.AskOne(&survey.Input{ + Message: "Enter a commit message (empty to not commit right now)", + }, &commit); err != nil { + return "" + } + return strings.TrimSpace(commit) +} diff --git a/pkg/bridge/up/configure.go b/pkg/bridge/up/configure.go new file mode 100644 index 000000000..4c01437a4 --- /dev/null +++ b/pkg/bridge/up/configure.go @@ -0,0 +1,49 @@ +package up + +import ( + "fmt" + "strings" + + "github.com/pluralsh/plural-cli/pkg/api" + "github.com/pluralsh/plural-cli/pkg/utils" +) + +const pluralDNSDomain = "onplural.sh" + +// BucketPrefixPrompt matches ProjectManifest.Configure (self-hosted). +const BucketPrefixPrompt = "Enter a unique, memorable string to use for bucket naming, e.g. an abbreviation for your company:" + +// PluralSubdomainPrompt matches ProjectManifest.ConfigureNetwork. +const PluralSubdomainPrompt = "Enter subdomain of onplural.sh domain that you want to use:" + +// ValidateBucketPrefix mirrors Configure's bucket-name validator. +func ValidateBucketPrefix(val string) error { + return utils.ValidateRegex(val, "[a-z][0-9\\-a-z]+", "bucket name can only contain alphanumeric characters or hyphens") +} + +// PluralDomain builds subdomain.onplural.sh (or returns a full onplural.sh name). +func PluralDomain(subdomain string) string { + subdomain = strings.TrimSpace(subdomain) + if strings.HasSuffix(subdomain, pluralDNSDomain) { + return subdomain + } + return subdomain + "." + pluralDNSDomain +} + +// ValidatePluralSubdomain checks DNS shape for the Plural DNS subdomain prompt. +func ValidatePluralSubdomain(subdomain string) error { + return utils.ValidateDns(PluralDomain(subdomain)) +} + +// RegisterPluralDomain creates the Plural DNS domain (same as ConfigureNetwork). +func RegisterPluralDomain(subdomain string) (string, error) { + d := PluralDomain(subdomain) + if err := utils.ValidateDns(d); err != nil { + return "", err + } + client := api.NewClient() + if err := client.CreateDomain(d); err != nil { + return "", fmt.Errorf("domain %s is taken or your user doesn't have sufficient permissions to create domains", subdomain) + } + return d, nil +} diff --git a/pkg/bridge/up/configure_test.go b/pkg/bridge/up/configure_test.go new file mode 100644 index 000000000..b344dd560 --- /dev/null +++ b/pkg/bridge/up/configure_test.go @@ -0,0 +1,30 @@ +package up + +import "testing" + +func TestValidateBucketPrefix(t *testing.T) { + if err := ValidateBucketPrefix("acme"); err != nil { + t.Fatalf("acme: %v", err) + } + if err := ValidateBucketPrefix("Acme"); err == nil { + t.Fatal("expected uppercase rejection") + } + if err := ValidateBucketPrefix("1bad"); err == nil { + t.Fatal("expected leading digit rejection") + } +} + +func TestPluralDomain(t *testing.T) { + if got := PluralDomain("demo"); got != "demo.onplural.sh" { + t.Fatalf("got %q", got) + } + if got := PluralDomain("demo.onplural.sh"); got != "demo.onplural.sh" { + t.Fatalf("full got %q", got) + } +} + +func TestValidatePluralSubdomain(t *testing.T) { + if err := ValidatePluralSubdomain("demo"); err != nil { + t.Fatalf("demo: %v", err) + } +} 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/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..5acd659e2 --- /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" || f.ID == "dry-run" || f.ID == "cloud-dry-run" + if f.NeedsProvider() != want { + t.Fatalf("%s NeedsProvider=%v want %v", f.ID, f.NeedsProvider(), want) + } + } +} 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/runner.go b/pkg/bridge/up/runner.go new file mode 100644 index 000000000..6584445db --- /dev/null +++ b/pkg/bridge/up/runner.go @@ -0,0 +1,457 @@ +package up + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/fatih/color" + "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 + BucketPrefix string // self-hosted Configure bucket naming + PluralDNS string // self-hosted subdomain.onplural.sh (full domain) +} + +// 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). +// Git commit runs inside Deploy at the "commit" checkpoint (after mgmt terraform, +// before apps) — matching plural up. Set PromptCommit to survey during that step. +type DeployInput struct { + Cloud bool + CloudCluster string + ImportClusterID string + IgnorePreflights bool + CommitMsg string // if set, used at commit checkpoint; empty + !PromptCommit skips + PromptCommit bool // survey (or CommitPrompt) at commit checkpoint after mgmt terraform + CommitPrompt func() string // optional; when set with PromptCommit, called instead of survey + CommittedMsg *string // optional out: final commit message used (may be empty if skipped) + Output io.Writer // optional; terraform + highlight output (TUI log capture) +} + +// DestroyInput tears down the management cluster (plural down). +type DestroyInput struct { + Cloud bool + Output io.Writer // optional; terraform output (TUI log capture) +} + +// RunInput is the Plan → Flush + Generate pipeline. +type RunInput struct { + Flush FlushInput + Generate GenerateInput + SkipFlush bool // true when workspace.yaml already exists (CLI ensureWorkspace path) + Output io.Writer // optional; generation-related stdout (TUI log capture) +} + +// RunResult carries values needed for a later Deploy step. +type RunResult struct { + ImportClusterID string +} + +// Progress reports a human-readable step while Run/Deploy/Destroy executes. +type ProgressFunc func(step string) + +// Runner executes Flush + Generate, Deploy, and Destroy. +type Runner interface { + Run(ctx context.Context, in RunInput, progress ProgressFunc) (RunResult, error) + Deploy(ctx context.Context, in DeployInput, progress ProgressFunc) error + Destroy(ctx context.Context, in DestroyInput, progress ProgressFunc) error +} + +// LiveRunner writes workspace.yaml and runs up.Build / Generate / Deploy. +type LiveRunner struct{} + +// DefaultRunner returns the live Flush+Generate+Deploy+Destroy 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) { + return withCommandOutput(in.Output, func() (RunResult, error) { + report := progress + if report == nil { + report = func(string) {} + } + var result RunResult + + if in.SkipFlush { + report("Skipping workspace.yaml write (already initialized)…") + } else { + 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 { + return withCommandOutputErr(in.Output, func() 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 == "" && in.PromptCommit { + report("Commit checkpoint — enter a commit message…") + if in.CommitPrompt != nil { + msg = strings.TrimSpace(in.CommitPrompt()) + } else { + msg = promptCommitMessage() + } + } + if in.CommittedMsg != nil { + *in.CommittedMsg = msg + } + 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) + }) + }) +} + +// Destroy tears down the management cluster (plural down). +func (LiveRunner) Destroy(ctx context.Context, in DestroyInput, progress ProgressFunc) error { + _ = ctx + return withCommandOutputErr(in.Output, func() error { + report := func(step string) { + if progress != nil { + progress(step) + } + } + report("Building destroy context…") + upCtx, err := pkgup.Build(in.Cloud) + if err != nil { + return err + } + report("Destroying management cluster terraform…") + return upCtx.Destroy() + }) +} + +func withCommandOutput[T any](w io.Writer, fn func() (T, error)) (T, error) { + if w == nil { + return fn() + } + prevOut, prevErr := color.Output, color.Error + pkgup.SetCommandOutput(w, w) + color.Output = w + color.Error = w + defer func() { + pkgup.SetCommandOutput(nil, nil) + color.Output = prevOut + color.Error = prevErr + }() + return fn() +} + +func withCommandOutputErr(w io.Writer, fn func() error) error { + _, err := withCommandOutput(w, func() (struct{}, error) { + return struct{}{}, fn() + }) + return err +} + +// 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 + } + pm.AppDomain = strings.TrimSpace(in.AppDomain) + pm.AppDomainConfigured = true + return writeWorkspaceSilent(pm, in.Cloud, cluster, in.BucketPrefix, in.PluralDNS) +} + +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 surveys. +// Self-hosted requires BucketPrefix (+ optional PluralDNS from the TUI prompts). +func writeWorkspaceSilent(pm *manifest.ProjectManifest, cloud bool, cluster, bucketPrefix, pluralDNS string) error { + if cloud { + pm.BucketPrefix = cluster + pm.Bucket = fmt.Sprintf("plrl-cloud-%s-%s", cluster, algorithms.String(4)) + } else { + prefix := strings.TrimSpace(bucketPrefix) + if prefix == "" { + return fmt.Errorf("bucket naming prefix is required for self-hosted up") + } + if err := ValidateBucketPrefix(prefix); err != nil { + return err + } + pm.BucketPrefix = prefix + pm.Bucket = fmt.Sprintf("%s-tf-state", prefix) + if d := strings.TrimSpace(pluralDNS); d != "" { + pm.Network = &manifest.NetworkConfig{Subdomain: d, PluralDns: true} + } + } + 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..9ce413b43 --- /dev/null +++ b/pkg/bridge/up/runner_test.go @@ -0,0 +1,195 @@ +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.AppDomainConfigured { + t.Fatal("expected AppDomainConfigured") + } + 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 TestFlushWorkspaceSelfHostedRequiresPrefix(t *testing.T) { + err := FlushWorkspace(context.Background(), FlushInput{ + ProviderID: api.ProviderAWS, + Values: map[string]string{"cluster": "acme", "region": "eu-west-1"}, + Cloud: false, + }) + if err == nil || !strings.Contains(err.Error(), "bucket naming prefix") { + t.Fatalf("expected prefix required, got %v", err) + } +} + +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, + BucketPrefix: "acme", + PluralDNS: "acme.onplural.sh", + }); 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" || pm.BucketPrefix != "acme" { + t.Fatalf("self-hosted bucket = %q prefix=%q", pm.Bucket, pm.BucketPrefix) + } + if pm.Network == nil || pm.Network.Subdomain != "acme.onplural.sh" || !pm.Network.PluralDns { + t.Fatalf("network = %#v", pm.Network) + } +} + +func TestFlushWorkspacePersistsSkippedDomain(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": "demo", "region": "us-east-2"}, + Cloud: true, + }); err != nil { + t.Fatalf("FlushWorkspace: %v", err) + } + pm, err := manifest.ReadProject(filepath.Join(dir, "workspace.yaml")) + if err != nil { + t.Fatal(err) + } + if !pm.AppDomainConfigured { + t.Fatal("expected AppDomainConfigured after skipped domain") + } + if pm.AppDomain != "" { + t.Fatalf("appDomain = %q", pm.AppDomain) + } +} + +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) + } + if err := f.Destroy(context.Background(), DestroyInput{Cloud: true}, nil); err != nil { + t.Fatal(err) + } + if len(f.destroys) != 1 || !f.destroys[0].Cloud { + t.Fatalf("destroys = %#v", f.destroys) + } +} + +type fakeRunner struct { + calls []RunInput + deploys []DeployInput + destroys []DestroyInput + progress []string + err error + deployErr error + destroyErr 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 +} + +func (f *fakeRunner) Destroy(_ context.Context, in DestroyInput, progress ProgressFunc) error { + f.destroys = append(f.destroys, in) + if progress != nil { + progress("Destroying…") + } + return f.destroyErr +} diff --git a/pkg/bridge/up/scm.go b/pkg/bridge/up/scm.go new file mode 100644 index 000000000..4330d1117 --- /dev/null +++ b/pkg/bridge/up/scm.go @@ -0,0 +1,35 @@ +package up + +import ( + "fmt" + + "github.com/pluralsh/plural-cli/pkg/scm" +) + +// 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"}, + } +} + +// SetupSCM runs device login + create repo + clone for providerID (github/gitlab/bitbucket). +// Intended to run under tea.Exec so the TUI releases the terminal for oauth/surveys. +func SetupSCM(providerID string) (repoName string, err error) { + if providerID == "" { + return "", fmt.Errorf("scm provider is required") + } + return scm.SetupProvider(providerID) +} + +// 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..3d0b1117d --- /dev/null +++ b/pkg/bridge/up/up.go @@ -0,0 +1,119 @@ +// 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 provider survey (CLI GetProvider +// via HandleInitWithProject). Cloud / cloud-dry-run run that survey after Console +// instance pick; self-hosted / dry-run run it directly. +func (f Flow) NeedsProvider() bool { + switch f.ID { + case "self-hosted", "cloud", "dry-run", "cloud-dry-run": + return true + default: + return false + } +} + +// 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) · then provider survey", + Cloud: true, + }, + { + ID: "dry-run", + Title: "Dry-run", + Blurb: "generate repo only (--dry-run) · no deploy", + DryRun: true, + }, + { + ID: "cloud-dry-run", + Title: "Cloud · dry-run", + Blurb: "Plural Cloud generate only (--cloud --dry-run)", + 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..5b2b4abe3 --- /dev/null +++ b/pkg/bridge/up/up_test.go @@ -0,0 +1,79 @@ +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") + } + 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 need provider", f.ID) + } + if !f.DryRun { + t.Fatalf("%s should be dry-run", 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/bridge/up/workspace.go b/pkg/bridge/up/workspace.go new file mode 100644 index 000000000..7ad9835e8 --- /dev/null +++ b/pkg/bridge/up/workspace.go @@ -0,0 +1,107 @@ +package up + +import ( + "fmt" + "os" + "strings" + + "github.com/pluralsh/plural-cli/pkg/api" + "github.com/pluralsh/plural-cli/pkg/common" + "github.com/pluralsh/plural-cli/pkg/manifest" + "github.com/pluralsh/plural-cli/pkg/utils" + "github.com/pluralsh/plural-cli/pkg/utils/git" +) + +// ExistingWorkspace is the subset of workspace.yaml the TUI needs when skipping init. +type ExistingWorkspace struct { + ProviderID string + Cluster string + Region string + Project string + BucketPrefix string + PluralDNS string + AppDomain string + AppDomainConfigured bool +} + +// HasWorkspace reports whether ./workspace.yaml (or project-root workspace.yaml) exists. +// Mirrors HandleInitWithProject's early return. +func HasWorkspace() bool { + return utils.Exists("./workspace.yaml") || utils.Exists(manifest.ProjectManifestPath()) +} + +// LoadExistingWorkspace reads the project manifest for the skip-init path. +func LoadExistingWorkspace() (ExistingWorkspace, error) { + pm, err := manifest.FetchProject() + if err != nil { + return ExistingWorkspace{}, err + } + ws := ExistingWorkspace{ + ProviderID: api.NormalizeProvider(pm.Provider), + Cluster: strings.TrimSpace(pm.Cluster), + Region: strings.TrimSpace(pm.Region), + Project: strings.TrimSpace(pm.Project), + BucketPrefix: strings.TrimSpace(pm.BucketPrefix), + AppDomain: strings.TrimSpace(pm.AppDomain), + AppDomainConfigured: manifest.AppDomainAlreadyConfigured(pm), + } + if pm.Network != nil { + ws.PluralDNS = strings.TrimSpace(pm.Network.Subdomain) + } + if ws.ProviderID == "" { + return ExistingWorkspace{}, fmt.Errorf("workspace.yaml is missing provider") + } + if ws.Cluster == "" { + return ExistingWorkspace{}, fmt.Errorf("workspace.yaml is missing cluster") + } + return ws, nil +} + +// PersistAppDomain records the domain prompt answer on an existing workspace.yaml. +func PersistAppDomain(domain string) error { + pm, err := manifest.FetchProject() + if err != nil { + return err + } + return pm.PersistAppDomain(domain) +} + +// EnsureExistingWorkspace mirrors Plural.ensureWorkspace: Plural DNS check, branch context, gitignore. +// Does not print CLI Highlight lines — the TUI shows its own status. +func EnsureExistingWorkspace() error { + proj, err := manifest.FetchProject() + if err != nil { + return err + } + + if proj.Network != nil && proj.Network.PluralDns { + client := api.NewClient() + if err := client.CreateDomain(proj.Network.Subdomain); err != nil { + return err + } + } + + branch, err := git.CurrentBranch() + if err != nil { + return err + } + if proj.Context == nil { + proj.Context = map[string]interface{}{} + } + proj.Context["Branch"] = branch + if err := proj.Flush(); err != nil { + return err + } + if err := common.EnsureGitIgnore(); err != nil { + return err + } + return nil +} + +// WorkspacePathForTest returns the path Write would use in the current directory. +func WorkspacePathForTest() string { + if _, err := os.Stat("./workspace.yaml"); err == nil { + return "./workspace.yaml" + } + return manifest.ProjectManifestPath() +} diff --git a/pkg/bridge/up/workspace_test.go b/pkg/bridge/up/workspace_test.go new file mode 100644 index 000000000..39fbe514d --- /dev/null +++ b/pkg/bridge/up/workspace_test.go @@ -0,0 +1,88 @@ +package up + +import ( + "os" + "path/filepath" + "testing" + + "github.com/pluralsh/plural-cli/pkg/api" + "github.com/pluralsh/plural-cli/pkg/manifest" +) + +func TestHasWorkspace(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 HasWorkspace() { + t.Fatal("expected no workspace") + } + pm := &manifest.ProjectManifest{Cluster: "demo", Provider: api.ProviderAWS, Region: "us-east-2"} + if err := pm.Write(filepath.Join(dir, "workspace.yaml")); err != nil { + t.Fatal(err) + } + if !HasWorkspace() { + t.Fatal("expected workspace present") + } + ws, err := LoadExistingWorkspace() + if err != nil { + t.Fatal(err) + } + if ws.Cluster != "demo" || ws.ProviderID != api.ProviderAWS { + t.Fatalf("ws = %#v", ws) + } +} + +func TestLoadExistingWorkspaceAppDomain(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) }) + + pm := &manifest.ProjectManifest{ + Cluster: "demo", + Provider: api.ProviderAWS, + Region: "us-east-2", + AppDomain: "apps.example.com", + AppDomainConfigured: true, + } + if err := pm.Write(filepath.Join(dir, "workspace.yaml")); err != nil { + t.Fatal(err) + } + ws, err := LoadExistingWorkspace() + if err != nil { + t.Fatal(err) + } + if ws.AppDomain != "apps.example.com" || !ws.AppDomainConfigured { + t.Fatalf("ws = %#v", ws) + } +} + +func TestPersistAppDomain(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) }) + + pm := &manifest.ProjectManifest{Cluster: "demo", Provider: api.ProviderAWS, Region: "us-east-2"} + if err := pm.Write(filepath.Join(dir, "workspace.yaml")); err != nil { + t.Fatal(err) + } + if err := PersistAppDomain(""); err != nil { + t.Fatal(err) + } + loaded, err := manifest.ReadProject(filepath.Join(dir, "workspace.yaml")) + if err != nil { + t.Fatal(err) + } + if !loaded.AppDomainConfigured { + t.Fatal("expected AppDomainConfigured") + } +} 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/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/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/pkg/console/console.go b/pkg/console/console.go index 0cc271406..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 @@ -41,9 +50,12 @@ 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) + 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) @@ -53,6 +65,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) @@ -63,12 +76,19 @@ 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) CreateBootstrapToken(attributes consoleclient.BootstrapTokenAttributes) (string, error) CreateClusterRegistration(attributes consoleclient.ClusterRegistrationCreateAttributes) (*consoleclient.ClusterRegistrationFragment, error) 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/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/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/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/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/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/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/edge/devices.go b/pkg/edge/devices.go new file mode 100644 index 000000000..3e6c7bf79 --- /dev/null +++ b/pkg/edge/devices.go @@ -0,0 +1,193 @@ +package edge + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" +) + +var ( + scsiPartition = regexp.MustCompile(`^(sd|vd|hd|xvd)[a-z]+[0-9]+$`) + nvmePartition = regexp.MustCompile(`p[0-9]+$`) +) + +// FlashDevice is a whole-disk USB (or USB-attached) block device suitable for flash. +type FlashDevice struct { + Path string + Name string + Model string + Size uint64 + NeedsRoot bool +} + +// Label is the TUI/CLI summary: path, size, and model. +func (d FlashDevice) Label() string { + parts := []string{d.Path} + if d.Size > 0 { + parts = append(parts, formatBytes(d.Size)) + } + if model := strings.TrimSpace(d.Model); model != "" { + parts = append(parts, model) + } + return strings.Join(parts, " ") +} + +// ListFlashDevices returns USB whole disks, excluding the system disk. +func ListFlashDevices() ([]FlashDevice, error) { + if runtime.GOOS != "linux" { + return nil, nil + } + return deviceScan{}.list() +} + +type deviceScan struct { + sysBlock string + mounts string +} + +func (s deviceScan) list() ([]FlashDevice, error) { + sysBlock := s.sysBlock + if sysBlock == "" { + sysBlock = "/sys/block" + } + entries, err := os.ReadDir(sysBlock) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + system := s.systemDisks() + var devices []FlashDevice + for _, entry := range entries { + name := entry.Name() + if !isFlashCandidate(name) || system[name] { + continue + } + blockDir := filepath.Join(sysBlock, name) + if !isUSBBlock(blockDir) { + continue + } + path := "/dev/" + name + devices = append(devices, FlashDevice{ + Path: path, + Name: name, + Model: strings.TrimSpace(readSysFile(filepath.Join(blockDir, "device", "model"))), + Size: sysSize(blockDir), + NeedsRoot: s.sysBlock == "" && !DeviceWritable(path), + }) + } + return devices, nil +} + +func isFlashCandidate(name string) bool { + prefixes := []string{"loop", "ram", "sr", "fd", "dm-", "zram", "md", "nbd"} + for _, prefix := range prefixes { + if strings.HasPrefix(name, prefix) { + return false + } + } + return !isPartitionName(name) +} + +func isPartitionName(name string) bool { + if strings.HasPrefix(name, "nvme") || strings.HasPrefix(name, "mmcblk") { + return nvmePartition.MatchString(name) + } + return scsiPartition.MatchString(name) +} + +func isUSBBlock(blockDir string) bool { + resolved, err := filepath.EvalSymlinks(blockDir) + if err != nil { + resolved = blockDir + } + return strings.Contains(strings.ToLower(filepath.ToSlash(resolved)), "/usb") +} + +func sysSize(blockDir string) uint64 { + raw := strings.TrimSpace(readSysFile(filepath.Join(blockDir, "size"))) + sectors, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return 0 + } + return sectors * 512 +} + +func readSysFile(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return string(data) +} + +func (s deviceScan) systemDisks() map[string]bool { + path := s.mounts + if path == "" { + path = "/proc/mounts" + } + file, err := os.Open(path) + if err != nil { + return nil + } + defer file.Close() + + out := map[string]bool{} + scanner := bufio.NewScanner(file) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 2 { + continue + } + source, mount := fields[0], fields[1] + if mount != "/" && mount != "/boot" && mount != "/boot/efi" { + continue + } + if disk := diskName(source); disk != "" { + out[disk] = true + } + } + return out +} + +func diskName(source string) string { + if !strings.HasPrefix(source, "/dev/") { + return "" + } + name := strings.TrimPrefix(source, "/dev/") + if strings.Contains(name, "/") { + return "" + } + if !isPartitionName(name) { + return name + } + if strings.HasPrefix(name, "nvme") || strings.HasPrefix(name, "mmcblk") { + if i := strings.LastIndex(name, "p"); i > 0 { + return name[:i] + } + } + i := len(name) + for i > 0 && name[i-1] >= '0' && name[i-1] <= '9' { + i-- + } + return name[:i] +} + +func formatBytes(n uint64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := uint64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp]) +} diff --git a/pkg/edge/devices_test.go b/pkg/edge/devices_test.go new file mode 100644 index 000000000..923eeed17 --- /dev/null +++ b/pkg/edge/devices_test.go @@ -0,0 +1,100 @@ +package edge + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestListFlashDevicesFindsUSBAndSkipsSystemDisk(t *testing.T) { + root := t.TempDir() + sysBlock := filepath.Join(root, "sys", "block") + if err := os.MkdirAll(sysBlock, 0o755); err != nil { + t.Fatal(err) + } + + internal := filepath.Join(root, "sys", "devices", "pci0000:00", "ata1", "block", "sda") + usb := filepath.Join(root, "sys", "devices", "pci0000:00", "usb2", "2-3", "block", "sdb") + part := filepath.Join(root, "sys", "devices", "pci0000:00", "usb2", "2-3", "block", "sdb1") + loop := filepath.Join(root, "sys", "devices", "virtual", "block", "loop0") + for _, dir := range []string{internal, usb, part, loop} { + if err := os.MkdirAll(filepath.Join(dir, "device"), 0o755); err != nil { + t.Fatal(err) + } + } + write := func(path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write(filepath.Join(internal, "size"), "1953525168\n") + write(filepath.Join(internal, "device", "model"), "Samsung SSD\n") + write(filepath.Join(usb, "size"), "31266816\n") + write(filepath.Join(usb, "device", "model"), "SanDisk Ultra\n") + write(filepath.Join(part, "size"), "31266816\n") + write(filepath.Join(loop, "size"), "2048\n") + + link := func(name, target string) { + t.Helper() + if err := os.Symlink(target, filepath.Join(sysBlock, name)); err != nil { + t.Fatal(err) + } + } + link("sda", internal) + link("sdb", usb) + link("sdb1", part) + link("loop0", loop) + + mounts := filepath.Join(root, "proc", "mounts") + if err := os.MkdirAll(filepath.Dir(mounts), 0o755); err != nil { + t.Fatal(err) + } + write(mounts, "/dev/sda2 / ext4 rw 0 0\n/dev/sda1 /boot/efi vfat rw 0 0\n") + + got, err := deviceScan{sysBlock: sysBlock, mounts: mounts}.list() + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Path != "/dev/sdb" || got[0].Model != "SanDisk Ultra" { + t.Fatalf("devices = %#v", got) + } + if got[0].Size != 31266816*512 { + t.Fatalf("size = %d", got[0].Size) + } + label := got[0].Label() + if !strings.Contains(label, "/dev/sdb") || !strings.Contains(label, "SanDisk Ultra") || !strings.Contains(label, "14.9 GB") { + t.Fatalf("label = %q", label) + } +} + +func TestDiskNameStripsPartitions(t *testing.T) { + cases := map[string]string{ + "/dev/sda1": "sda", + "/dev/nvme0n1p2": "nvme0n1", + "/dev/mmcblk0p1": "mmcblk0", + "/dev/mapper/root": "", + "/dev/sdb": "sdb", + } + for in, want := range cases { + if got := diskName(in); got != want { + t.Fatalf("%s -> %q, want %q", in, got, want) + } + } +} + +func TestIsFlashCandidateSkipsVirtualAndPartitions(t *testing.T) { + skip := []string{"loop0", "sda1", "nvme0n1p1", "mmcblk0p1", "zram0"} + for _, name := range skip { + if isFlashCandidate(name) { + t.Fatalf("expected skip %s", name) + } + } + keep := []string{"sda", "sdb", "nvme0n1", "mmcblk0"} + for _, name := range keep { + if !isFlashCandidate(name) { + t.Fatalf("expected keep %s", name) + } + } +} diff --git a/pkg/edge/flash.go b/pkg/edge/flash.go new file mode 100644 index 000000000..672c83503 --- /dev/null +++ b/pkg/edge/flash.go @@ -0,0 +1,244 @@ +package edge + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +// ProgressFunc reports bytes written while flashing. +type ProgressFunc func(written, total int64) + +// FlashOptions are the CLI flags for plural edge flash. +type FlashOptions struct { + Image string + Device string + Progress io.Writer + Log io.Writer + OnProgress ProgressFunc +} + +var ( + openFlashDevice = func(path string) (*os.File, error) { + return os.OpenFile(path, os.O_WRONLY, 0644) + } + elevateFlash = flashWithSudo +) + +// DeviceWritable reports whether the current user can open the device for writing. +func DeviceWritable(path string) bool { + file, err := openFlashDevice(path) + if err != nil { + return false + } + _ = file.Close() + return true +} + +func isPermissionErr(err error) bool { + return errors.Is(err, os.ErrPermission) || errors.Is(err, syscall.EACCES) +} + +func imageSize(path string) int64 { + info, err := os.Stat(path) + if err != nil { + return 0 + } + return info.Size() +} + +// Flash writes an image file onto a storage device. +func Flash(options FlashOptions) error { + image := options.Image + device := options.Device + if image == "" { + return fmt.Errorf("image file path is required") + } + if device == "" { + return fmt.Errorf("storage device path is required") + } + + out, err := openFlashDevice(device) + if err != nil { + if isPermissionErr(err) { + return elevateFlash(options, err) + } + return fmt.Errorf("could not open device: %w", err) + } + defer out.Close() + + in, err := os.Open(image) + if err != nil { + return fmt.Errorf("could not open image: %w", err) + } + defer in.Close() + + total := imageSize(image) + counter := &countWriter{total: total, out: options.Progress, fn: options.OnProgress} + _, err = io.Copy(io.MultiWriter(out, counter), in) + if err == nil && options.OnProgress != nil { + options.OnProgress(counter.written, total) + } + return err +} + +type countWriter struct { + total, written, lastReported int64 + lastTime time.Time + out io.Writer + fn ProgressFunc +} + +func (w *countWriter) Write(p []byte) (int, error) { + n := len(p) + if w.out != nil { + var err error + n, err = w.out.Write(p) + if err != nil { + return n, err + } + } + w.written += int64(n) + if w.fn == nil { + return n, nil + } + now := time.Now() + if w.written-w.lastReported >= 1<<20 || now.Sub(w.lastTime) >= 100*time.Millisecond { + w.lastReported = w.written + w.lastTime = now + w.fn(w.written, w.total) + } + return n, nil +} + +func flashLog(options FlashOptions, line string) { + w := options.Log + if w == nil { + return + } + _, _ = io.WriteString(w, line+"\n") +} + +func flashWithSudo(options FlashOptions, original error) error { + flashLog(options, "device is not writable; retrying with sudo/pkexec") + parser := newDDProgress(imageSize(options.Image), options) + if err := runDD("sudo", []string{"-n", "dd"}, options.Image, options.Device, parser); err == nil { + parser.Close() + return nil + } + if err := runDD("pkexec", []string{"dd"}, options.Image, options.Device, parser); err == nil { + parser.Close() + return nil + } + parser.Close() + return fmt.Errorf("could not open device: %w\nneed root to write %s — retry with: sudo plural tui", original, options.Device) +} + +func runDD(name string, prefix []string, image, device string, output io.Writer) error { + args := append(append([]string{}, prefix...), "if="+image, "of="+device, "bs=4M", "conv=fsync", "status=progress") + cmd := exec.Command(name, args...) + cmd.Stdout = output + cmd.Stderr = output + return cmd.Run() +} + +type ddProgress struct { + total int64 + buf strings.Builder + fn ProgressFunc + log io.Writer + raw io.Writer +} + +func newDDProgress(total int64, options FlashOptions) *ddProgress { + raw := io.Writer(nil) + if options.Log == nil { + raw = os.Stderr + } + return &ddProgress{total: total, fn: options.OnProgress, log: options.Log, raw: raw} +} + +func (w *ddProgress) Write(p []byte) (int, error) { + for _, b := range p { + if b == '\r' || b == '\n' { + w.flush() + continue + } + w.buf.WriteByte(b) + } + return len(p), nil +} + +func (w *ddProgress) Close() { + w.flush() +} + +func (w *ddProgress) flush() { + line := strings.TrimSpace(w.buf.String()) + w.buf.Reset() + if line == "" { + return + } + if written, ok := parseDDProgress(line); ok { + if w.fn != nil { + w.fn(written, w.total) + } + if w.raw != nil { + _, _ = io.WriteString(w.raw, "\r"+line) + } + return + } + if w.log != nil { + _, _ = io.WriteString(w.log, line+"\n") + } else if w.raw != nil { + _, _ = io.WriteString(w.raw, line+"\n") + } +} + +func parseDDProgress(line string) (int64, bool) { + line = strings.TrimSpace(line) + fields := strings.Fields(line) + if len(fields) < 3 || fields[1] != "bytes" || !strings.Contains(line, "copied") { + return 0, false + } + n, err := strconv.ParseInt(strings.ReplaceAll(fields[0], ",", ""), 10, 64) + if err != nil || n < 0 { + return 0, false + } + return n, true +} + +// DefaultFlashImage returns the absolute path of image/kairos.img in dir +// (or the working directory) when that file exists. +func DefaultFlashImage(dir string) string { + if dir == "" { + var err error + dir, err = os.Getwd() + if err != nil { + return "" + } + } + candidates := []string{ + filepath.Join(dir, "image", "kairos.img"), + filepath.Join(dir, "kairos.img"), + } + for _, path := range candidates { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + continue + } + abs, err := filepath.Abs(path) + if err != nil { + return path + } + return abs + } + return "" +} diff --git a/pkg/edge/flash_test.go b/pkg/edge/flash_test.go new file mode 100644 index 000000000..fa3bb3624 --- /dev/null +++ b/pkg/edge/flash_test.go @@ -0,0 +1,166 @@ +package edge + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFlashCopiesImageOntoDevice(t *testing.T) { + dir := t.TempDir() + image := filepath.Join(dir, "kairos.img") + device := filepath.Join(dir, "mmcblk0") + if err := os.WriteFile(image, []byte("edge-image"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(device, []byte("old"), 0644); err != nil { + t.Fatal(err) + } + + if err := Flash(FlashOptions{Image: image, Device: device}); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(device) + if err != nil { + t.Fatal(err) + } + if string(got) != "edge-image" { + t.Fatalf("device = %q", got) + } +} + +func TestFlashRequiresPaths(t *testing.T) { + if err := Flash(FlashOptions{Device: "/dev/sda"}); err == nil { + t.Fatal("expected image path error") + } + if err := Flash(FlashOptions{Image: "kairos.img"}); err == nil { + t.Fatal("expected device path error") + } +} + +func TestDefaultFlashImageFindsKairosInImageDir(t *testing.T) { + dir := t.TempDir() + if got := DefaultFlashImage(dir); got != "" { + t.Fatalf("empty dir = %q", got) + } + nested := filepath.Join(dir, "image") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(nested, "kairos.img") + if err := os.WriteFile(path, []byte("img"), 0o644); err != nil { + t.Fatal(err) + } + got := DefaultFlashImage(dir) + abs, err := filepath.Abs(path) + if err != nil { + t.Fatal(err) + } + if got != abs { + t.Fatalf("got %q want %q", got, abs) + } +} + +func TestFlashElevatesOnPermissionDenied(t *testing.T) { + origOpen, origElevate := openFlashDevice, elevateFlash + t.Cleanup(func() { + openFlashDevice, elevateFlash = origOpen, origElevate + }) + openFlashDevice = func(string) (*os.File, error) { return nil, os.ErrPermission } + called := false + elevateFlash = func(options FlashOptions, err error) error { + called = true + if options.Device != "/dev/sdb" || !isPermissionErr(err) { + t.Fatalf("elevate options=%#v err=%v", options, err) + } + return nil + } + if err := Flash(FlashOptions{Image: "kairos.img", Device: "/dev/sdb"}); err != nil { + t.Fatal(err) + } + if !called { + t.Fatal("expected sudo/pkexec retry") + } +} + +func TestFlashPermissionHintWhenElevateFails(t *testing.T) { + origOpen, origElevate := openFlashDevice, elevateFlash + t.Cleanup(func() { + openFlashDevice, elevateFlash = origOpen, origElevate + }) + openFlashDevice = func(string) (*os.File, error) { return nil, os.ErrPermission } + elevateFlash = func(options FlashOptions, err error) error { + return fmt.Errorf("could not open device: %w\nneed root to write %s — retry with: sudo plural tui", err, options.Device) + } + err := Flash(FlashOptions{Image: "kairos.img", Device: "/dev/sdb"}) + if err == nil || !strings.Contains(err.Error(), "sudo plural tui") { + t.Fatalf("error = %v", err) + } +} + +func TestParseDDProgress(t *testing.T) { + n, ok := parseDDProgress("6606028800 bytes (6,6 GB, 6,2 GiB) copied, 3 s, 2,2 GB/s") + if !ok || n != 6606028800 { + t.Fatalf("got %d ok=%v", n, ok) + } + n, ok = parseDDProgress(" 7780433920 bytes (7,8 GB, 7,2 GiB) copied, 64 s, 121 MB/s") + if !ok || n != 7780433920 { + t.Fatalf("got %d ok=%v", n, ok) + } + if _, ok := parseDDProgress("sudo: a password is required"); ok { + t.Fatal("log line should not parse as progress") + } +} + +func TestFlashReportsProgress(t *testing.T) { + dir := t.TempDir() + image := filepath.Join(dir, "kairos.img") + device := filepath.Join(dir, "mmcblk0") + payload := bytes.Repeat([]byte("x"), 2<<20) + if err := os.WriteFile(image, payload, 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(device, []byte("old"), 0644); err != nil { + t.Fatal(err) + } + var last int64 + if err := Flash(FlashOptions{ + Image: image, + Device: device, + OnProgress: func(written, total int64) { + last = written + if total != int64(len(payload)) { + t.Fatalf("total = %d", total) + } + }, + }); err != nil { + t.Fatal(err) + } + if last != int64(len(payload)) { + t.Fatalf("written = %d", last) + } +} + +func TestDDProgressKeepsLogsAndParsesBytes(t *testing.T) { + var logs strings.Builder + var got int64 + parser := &ddProgress{ + total: 10 << 30, + log: &logs, + fn: func(written, _ int64) { got = written }, + } + _, _ = parser.Write([]byte("sudo: a password is required\n6606028800 bytes (6,6 GB, 6,2 GiB) copied, 3 s, 2,2 GB/s\r")) + parser.Close() + if got != 6606028800 { + t.Fatalf("written = %d", got) + } + if !strings.Contains(logs.String(), "sudo: a password is required") { + t.Fatalf("logs = %q", logs.String()) + } + if strings.Contains(logs.String(), "copied") { + t.Fatalf("progress leaked into logs: %q", logs.String()) + } +} diff --git a/pkg/edge/image.go b/pkg/edge/image.go new file mode 100644 index 000000000..a60c9b6ba --- /dev/null +++ b/pkg/edge/image.go @@ -0,0 +1,331 @@ +// Package edge implements Raspberry Pi image build and flash, shared by CLI and TUI. +package edge + +import ( + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/utils" +) + +const ( + cloudConfigURL = "https://raw.githubusercontent.com/pluralsh/edge/main/cloud-config.yaml" + pluralConfigURL = "https://raw.githubusercontent.com/pluralsh/edge/main/plural-config.yaml" + buildDir = "build" + cloudConfigFile = "cloud-config.yaml" + volumeName = "edge-rootfs" + volumeMountPath = "/rootfs" + volumeMount = "source=edge-rootfs,target=/rootfs" + wifiConfigTemplate = ` +stages: + boot: + - name: Setup Wi-Fi + commands: + - connmanctl enable wifi + - wpa_passphrase '@WIFI_SSID@' '@WIFI_PASSWORD@' > /etc/wpa_supplicant/wpa_supplicant.conf + - wpa_supplicant -B -i wlan0 -c /etc/wpa_supplicant/wpa_supplicant.conf + - udhcpc -i wlan0 &` + defaults = `#cloud-config +stages: + boot: + - name: Delete default Kairos user + commands: + - deluser --remove-home kairos` + dockerfile = "FROM scratch\nWORKDIR /build\nCOPY kairos.img /build" +) + +// Configuration is the plural-config.yaml used to build an edge image. +type Configuration struct { + Image string `json:"image"` + AurorabootImage string `json:"aurorabootImage"` + CraneImage string `json:"craneImage"` + Bundles map[string]string `json:"bundles"` +} + +// ImageOptions are the CLI flags for plural edge image. +type ImageOptions struct { + OutputDir string + Project string + User string + PluralConfig string + CloudConfig string + Username string + Password string + WifiSSID string + WifiPassword string + Model string + OCIURL string + ConsoleURL string + WorkingDir string +} + +// ConsoleAPI is the Console subset used to mint a bootstrap token. +type ConsoleAPI interface { + GetUser(email string) (*gqlclient.UserFragment, error) + GetProject(name string) (*gqlclient.ProjectFragment, error) + CreateBootstrapToken(attributes gqlclient.BootstrapTokenAttributes) (string, error) +} + +// Service builds edge images using Console and local Docker. +type Service struct { + Client ConsoleAPI + Exec func(name string, args ...string) error + Log func(string) + LoadConfig func(override string) (*Configuration, error) + FetchCloudConfig func() (string, error) + Output io.Writer +} + +// NewService constructs an image builder. A nil client is allowed when CloudConfig is set. +func NewService(client ConsoleAPI) *Service { + return &Service{Client: client} +} + +func (s *Service) log(msg string) { + if s != nil && s.Log != nil { + s.Log(msg) + return + } + utils.Highlight("%s\n", msg) +} + +func (s *Service) exec(name string, args ...string) error { + if s != nil && s.Exec != nil { + return s.Exec(name, args...) + } + cmd := exec.Command(name, args...) + var stdout, stderr io.Writer = os.Stdout, os.Stderr + if s != nil && s.Output != nil { + stdout, stderr = s.Output, s.Output + } + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() +} + +func (s *Service) loadConfig(override string) (*Configuration, error) { + if s != nil && s.LoadConfig != nil { + return s.LoadConfig(override) + } + var config *Configuration + var err error + if override != "" { + err = utils.YamlFile(override, &config) + } else { + err = utils.RemoteYamlFile(pluralConfigURL, &config) + } + return config, err +} + +func (s *Service) fetchCloudConfig() (string, error) { + if s != nil && s.FetchCloudConfig != nil { + return s.FetchCloudConfig() + } + response, err := http.Get(cloudConfigURL) + if err != nil { + return "", err + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + return string(body), nil +} + +// Build prepares a Raspberry Pi image the same way as plural edge image. +func (s *Service) Build(options ImageOptions) error { + if options.Model == "" { + options.Model = "rpi5" + } + if options.OutputDir == "" { + options.OutputDir = "image" + } + if options.Username == "" { + options.Username = "plural" + } + if options.Project == "" { + options.Project = "default" + } + + workingDir := options.WorkingDir + if workingDir == "" { + var err error + workingDir, err = os.Getwd() + if err != nil { + return err + } + } + + s.log("reading configuration") + config, err := s.loadConfig(options.PluralConfig) + if err != nil { + return err + } + + s.log("preparing output directory") + outputDirPath := options.OutputDir + if !filepath.IsAbs(outputDirPath) { + outputDirPath = filepath.Join(workingDir, options.OutputDir) + } + if err = os.MkdirAll(outputDirPath, os.ModePerm); err != nil { + return err + } + + buildDirPath := filepath.Join(outputDirPath, buildDir) + if err = os.MkdirAll(buildDirPath, os.ModePerm); err != nil { + return err + } + defer func() { + _ = os.RemoveAll(buildDirPath) + }() + + s.log("writing configuration") + cloudConfigPath := filepath.Join(outputDirPath, cloudConfigFile) + if err = s.writeCloudConfig(options, cloudConfigPath); err != nil { + return err + } + + s.log("overwriting default configuration to remove default user") + defaultsPath := filepath.Join(outputDirPath, "defaults.yaml") + if err := utils.WriteFile(defaultsPath, []byte(defaults)); err != nil { + return err + } + defer func() { + _ = os.Remove(defaultsPath) + }() + + s.log("preparing " + volumeName + " volume") + if err = s.exec("docker", "volume", "create", volumeName); err != nil { + return err + } + defer func() { + s.log("removing " + volumeName + " volume") + _ = s.exec("docker", "volume", "rm", volumeName) + }() + + for bundle, image := range config.Bundles { + s.log("writing " + bundle + " bundle") + if err = s.exec( + "docker", "run", "-i", "--rm", "--user", "root", "--mount", volumeMount, + config.CraneImage, "--platform=linux/arm64", "pull", image, fmt.Sprintf("%s/%s.tar", volumeMountPath, bundle)); err != nil { + return err + } + } + + s.log("unpacking image contents") + if err = s.exec("docker", "run", "-i", "--rm", "--privileged", "--mount", volumeMount, + "quay.io/luet/base", "util", "unpack", config.Image, volumeMountPath); err != nil { + return err + } + + s.log("building image") + if err = s.exec("docker", "run", "-v", "/var/run/docker.sock:/var/run/docker.sock", + "-v", buildDirPath+":/tmp/build", + "-v", cloudConfigPath+":/cloud-config.yaml", + "-v", defaultsPath+":/defaults.yaml", + "--mount", volumeMount, + "--privileged", "-i", "--rm", + "--entrypoint=/build-arm-image.sh", config.AurorabootImage, + "--model", options.Model, + "--directory", volumeMountPath, + "--config", "/cloud-config.yaml", "/tmp/build/kairos.img"); err != nil { + return err + } + + if options.OCIURL != "" { + dockerfilePath := filepath.Join(buildDirPath, "Dockerfile") + if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0644); err != nil { + return fmt.Errorf("cannot create Dockerfile: %w", err) + } + if err = s.exec("docker", "build", "-t", options.OCIURL, "-f", dockerfilePath, buildDirPath); err != nil { + return err + } + if err = s.exec("docker", "push", options.OCIURL); err != nil { + return err + } + s.log("image pushed successfully to " + options.OCIURL) + } + + if err = utils.CopyDir(buildDirPath, outputDirPath); err != nil { + return fmt.Errorf("cannot move output files: %w", err) + } + + s.log("image saved to " + options.OutputDir + " directory") + return nil +} + +func (s *Service) writeCloudConfig(options ImageOptions, path string) error { + if options.CloudConfig != "" { + return utils.CopyFile(options.CloudConfig, path) + } + + token, err := s.createBootstrapToken(options.Project, options.User) + if err != nil { + return err + } + if options.ConsoleURL == "" { + return fmt.Errorf("url cannot be empty when cloud config is not specified") + } + if token == "" { + return fmt.Errorf("token cannot be empty when cloud config is not specified") + } + if options.Username == "" { + return fmt.Errorf("username cannot be empty when cloud config is not specified") + } + if options.Password == "" { + return fmt.Errorf("password cannot be empty when cloud config is not specified") + } + + template, err := s.fetchCloudConfig() + if err != nil { + return err + } + template = strings.ReplaceAll(template, "@URL@", options.ConsoleURL) + template = strings.ReplaceAll(template, "@TOKEN@", token) + template = strings.ReplaceAll(template, "@USERNAME@", options.Username) + template = strings.ReplaceAll(template, "@PASSWORD@", options.Password) + + if options.WifiSSID != "" && options.WifiPassword != "" { + wifiConfig := strings.ReplaceAll(wifiConfigTemplate, "@WIFI_SSID@", options.WifiSSID) + wifiConfig = strings.ReplaceAll(wifiConfig, "@WIFI_PASSWORD@", options.WifiPassword) + template += "\n" + wifiConfig + } + + return os.WriteFile(path, []byte(template), 0644) +} + +func (s *Service) createBootstrapToken(project, user string) (string, error) { + if s == nil || s.Client == nil { + return "", fmt.Errorf("console client is not configured") + } + attributes := gqlclient.BootstrapTokenAttributes{} + if user != "" { + usr, err := s.Client.GetUser(user) + if err != nil { + return "", err + } + if usr == nil { + return "", fmt.Errorf("cannot find %s user", user) + } + attributes.UserID = &usr.ID + } + + proj, err := s.Client.GetProject(project) + if err != nil { + return "", err + } + if proj == nil { + return "", fmt.Errorf("cannot find %s project", project) + } + attributes.ProjectID = proj.ID + + return s.Client.CreateBootstrapToken(attributes) +} diff --git a/pkg/edge/image_test.go b/pkg/edge/image_test.go new file mode 100644 index 000000000..8bcdfff53 --- /dev/null +++ b/pkg/edge/image_test.go @@ -0,0 +1,157 @@ +package edge + +import ( + "os" + "path/filepath" + "strings" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" +) + +type fakeConsole struct { + userID string + projectID string + token string + userErr error + projErr error + tokenErr error + userEmail string + project string +} + +func (f *fakeConsole) GetUser(email string) (*gqlclient.UserFragment, error) { + f.userEmail = email + if f.userErr != nil { + return nil, f.userErr + } + return &gqlclient.UserFragment{ID: f.userID}, nil +} + +func (f *fakeConsole) GetProject(name string) (*gqlclient.ProjectFragment, error) { + f.project = name + if f.projErr != nil { + return nil, f.projErr + } + return &gqlclient.ProjectFragment{ID: f.projectID}, nil +} + +func (f *fakeConsole) CreateBootstrapToken(gqlclient.BootstrapTokenAttributes) (string, error) { + if f.tokenErr != nil { + return "", f.tokenErr + } + return f.token, nil +} + +func TestBuildUsesCloudConfigOverrideWithoutConsole(t *testing.T) { + dir := t.TempDir() + cloud := filepath.Join(dir, "cloud.yaml") + if err := os.WriteFile(cloud, []byte("#cloud-config\n"), 0644); err != nil { + t.Fatal(err) + } + var calls []string + svc := &Service{ + LoadConfig: func(string) (*Configuration, error) { + return &Configuration{ + Image: "kairos:img", + AurorabootImage: "auroraboot:img", + CraneImage: "crane:img", + Bundles: map[string]string{"k3s": "k3s:img"}, + }, nil + }, + Exec: func(name string, args ...string) error { + calls = append(calls, name+" "+strings.Join(args, " ")) + if len(args) >= 2 && args[0] == "run" && strings.Contains(strings.Join(args, " "), "/tmp/build/kairos.img") { + buildDir := filepath.Join(dir, "image", "build") + return os.WriteFile(filepath.Join(buildDir, "kairos.img"), []byte("built"), 0644) + } + return nil + }, + Log: func(string) {}, + } + + if err := svc.Build(ImageOptions{ + OutputDir: "image", + WorkingDir: dir, + CloudConfig: cloud, + Model: "rpi5", + }); err != nil { + t.Fatal(err) + } + joined := strings.Join(calls, "\n") + if !strings.Contains(joined, "docker volume create edge-rootfs") { + t.Fatalf("missing volume create:\n%s", joined) + } + if !strings.Contains(joined, "util unpack kairos:img") { + t.Fatalf("missing unpack:\n%s", joined) + } + if !strings.Contains(joined, "--model rpi5") { + t.Fatalf("missing model:\n%s", joined) + } + if !strings.Contains(joined, "docker volume rm edge-rootfs") { + t.Fatalf("missing volume cleanup:\n%s", joined) + } + got, err := os.ReadFile(filepath.Join(dir, "image", "kairos.img")) + if err != nil { + t.Fatal(err) + } + if string(got) != "built" { + t.Fatalf("copied image = %q", got) + } +} + +func TestBuildTemplatesCloudConfigFromConsole(t *testing.T) { + dir := t.TempDir() + console := &fakeConsole{userID: "user-1", projectID: "proj-1", token: "boot-token"} + svc := &Service{ + Client: console, + LoadConfig: func(string) (*Configuration, error) { + return &Configuration{Image: "kairos:img", AurorabootImage: "auroraboot:img", CraneImage: "crane:img"}, nil + }, + FetchCloudConfig: func() (string, error) { + return "url=@URL@ token=@TOKEN@ user=@USERNAME@ pass=@PASSWORD@", nil + }, + Exec: func(name string, args ...string) error { + if len(args) >= 2 && args[0] == "run" && strings.Contains(strings.Join(args, " "), "/tmp/build/kairos.img") { + return os.WriteFile(filepath.Join(dir, "image", "build", "kairos.img"), []byte("built"), 0644) + } + return nil + }, + Log: func(string) {}, + } + + if err := svc.Build(ImageOptions{ + OutputDir: "image", + WorkingDir: dir, + Project: "default", + User: "ops@example.com", + Username: "plural", + Password: "secret", + ConsoleURL: "https://console.example.com", + }); err != nil { + t.Fatal(err) + } + if console.userEmail != "ops@example.com" || console.project != "default" { + t.Fatalf("console lookup user=%q project=%q", console.userEmail, console.project) + } + got, err := os.ReadFile(filepath.Join(dir, "image", "cloud-config.yaml")) + if err != nil { + t.Fatal(err) + } + want := "url=https://console.example.com token=boot-token user=plural pass=secret" + if string(got) != want { + t.Fatalf("cloud-config = %q", got) + } +} + +func TestBuildRequiresPasswordWithoutCloudConfig(t *testing.T) { + svc := &Service{ + Client: &fakeConsole{projectID: "proj-1", token: "t"}, + LoadConfig: func(string) (*Configuration, error) { return &Configuration{Image: "kairos:img"}, nil }, + Log: func(string) {}, + } + err := svc.Build(ImageOptions{WorkingDir: t.TempDir(), ConsoleURL: "https://console.example.com"}) + if err == nil || !strings.Contains(err.Error(), "password cannot be empty") { + t.Fatalf("error = %v", err) + } +} diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go index 0c666ff0e..8327306d8 100644 --- a/pkg/manifest/manifest.go +++ b/pkg/manifest/manifest.go @@ -76,6 +76,22 @@ func (pm *ProjectManifest) Flush() error { return pm.Write(ProjectManifestPath()) } +// AppDomainAlreadyConfigured reports whether the app-domain prompt was already answered. +// Older manifests that only set AppDomain are treated as configured. +func AppDomainAlreadyConfigured(pm *ProjectManifest) bool { + if pm == nil { + return false + } + return pm.AppDomainConfigured || pm.AppDomain != "" +} + +// PersistAppDomain records the domain choice (including skip) and flushes workspace.yaml. +func (pm *ProjectManifest) PersistAppDomain(domain string) error { + pm.AppDomain = domain + pm.AppDomainConfigured = true + return pm.Flush() +} + func (man *Manifest) Write(path string) error { versioned := &VersionedManifest{ ApiVersion: "plural.sh/v1alpha1", diff --git a/pkg/manifest/types.go b/pkg/manifest/types.go index 87f08c263..3ecce80cb 100644 --- a/pkg/manifest/types.go +++ b/pkg/manifest/types.go @@ -50,47 +50,50 @@ type NetworkConfig struct { } type ProjectManifest struct { - Cluster string - Bucket string - Project string - Provider string - Region string - Owner *Owner - Network *NetworkConfig - Checkpoint string `yaml:"checkpoint,omitempty"` - AvailabilityZones []string - BucketPrefix string `yaml:"bucketPrefix"` - Context map[string]interface{} - AppDomain string `yaml:"appDomain,omitempty"` + Cluster string + Bucket string + Project string + Provider string + Region string + Owner *Owner + Network *NetworkConfig + Checkpoint string `yaml:"checkpoint,omitempty"` + AvailabilityZones []string + BucketPrefix string `yaml:"bucketPrefix"` + Context map[string]interface{} + AppDomain string `yaml:"appDomain,omitempty"` + AppDomainConfigured bool `yaml:"appDomainConfigured,omitempty"` } func (pm *ProjectManifest) MarshalJSON() ([]byte, error) { json := jsoniter.ConfigCompatibleWithStandardLibrary return json.Marshal(&struct { - Cluster string `json:"cluster"` - Bucket string `json:"bucket"` - Project string `json:"project"` - Provider string `json:"provider"` - Region string `json:"region"` - Owner *Owner `json:"owner"` - Network *NetworkConfig `json:"network"` - AvailabilityZones []string `json:"availabilityZones"` - BucketPrefix string `yaml:"bucketPrefix" json:"bucketPrefix"` - Context map[string]interface{} `json:"context"` - AppDomain string `json:"appDomain,omitempty"` + Cluster string `json:"cluster"` + Bucket string `json:"bucket"` + Project string `json:"project"` + Provider string `json:"provider"` + Region string `json:"region"` + Owner *Owner `json:"owner"` + Network *NetworkConfig `json:"network"` + AvailabilityZones []string `json:"availabilityZones"` + BucketPrefix string `yaml:"bucketPrefix" json:"bucketPrefix"` + Context map[string]interface{} `json:"context"` + AppDomain string `json:"appDomain,omitempty"` + AppDomainConfigured bool `json:"appDomainConfigured,omitempty"` }{ - Cluster: pm.Cluster, - Bucket: pm.Bucket, - Project: pm.Project, - Provider: pm.Provider, - Region: pm.Region, - Owner: pm.Owner, - Network: pm.Network, - AvailabilityZones: pm.AvailabilityZones, - BucketPrefix: pm.BucketPrefix, - Context: pm.Context, - AppDomain: pm.AppDomain, + Cluster: pm.Cluster, + Bucket: pm.Bucket, + Project: pm.Project, + Provider: pm.Provider, + Region: pm.Region, + Owner: pm.Owner, + Network: pm.Network, + AvailabilityZones: pm.AvailabilityZones, + BucketPrefix: pm.BucketPrefix, + Context: pm.Context, + AppDomain: pm.AppDomain, + AppDomainConfigured: pm.AppDomainConfigured, }) } 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..e2d3563a4 --- /dev/null +++ b/pkg/provider/setup_azure.go @@ -0,0 +1,83 @@ +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) { + if fieldKey != "location" && fieldKey != "resourceGroup" && fieldKey != "storageAccount" { + return nil, nil + } + 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) + default: + return AzureStorageAccountChoices(ctx, clients.Accounts) + } +} + +// 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/pkg/scm/provider.go b/pkg/scm/provider.go index 1805a2f56..f85154e4c 100644 --- a/pkg/scm/provider.go +++ b/pkg/scm/provider.go @@ -27,7 +27,12 @@ func Setup() (string, error) { if err := survey.AskOne(prompt, &provider, survey.WithValidator(survey.Required)); err != nil { return "", err } + return SetupProvider(provider) +} +// SetupProvider runs scm auth + create repo + clone for a chosen provider id +// (github / gitlab / bitbucket). Used by the TUI after the SCM select screen. +func SetupProvider(provider string) (string, error) { var prov Provider switch provider { case "github": @@ -37,7 +42,7 @@ func Setup() (string, error) { case "bitbucket": prov = &Bitbucket{} default: - return "", nil + return "", fmt.Errorf("unknown scm provider %q", provider) } if err := prov.Init(); 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 520d16e89..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() @@ -721,6 +752,66 @@ 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) + + 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) @@ -751,6 +842,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) @@ -811,6 +932,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) @@ -871,6 +1022,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) @@ -901,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) @@ -1021,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() @@ -1081,7 +1292,67 @@ func (_m *ConsoleClient) ListNotificationSinks(after *string, first *int64) (*cl return r0, r1 } -// ListProviders provides a mock function with no fields +// 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 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 +} + +// 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 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 +} + +// ListProviders provides a mock function with given fields: func (_m *ConsoleClient) ListProviders() (*client.ListProviders, error) { ret := _m.Called() @@ -1111,7 +1382,7 @@ func (_m *ConsoleClient) ListProviders() (*client.ListProviders, 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() @@ -1171,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() @@ -1201,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() @@ -1291,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() @@ -1429,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/pkg/up/deploy.go b/pkg/up/deploy.go index c432732fb..aa14fff08 100644 --- a/pkg/up/deploy.go +++ b/pkg/up/deploy.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" "os/exec" "time" @@ -49,7 +48,7 @@ func (c *Context) runCheckpoint(current, checkpoint string, fn func() error) err if current == "" || priorities[checkpoint] > priorities[current] { err := fn() if err == nil { - c.Manifest.Checkpoint = checkpoint + return c.completeCheckpoint(checkpoint) } return err } @@ -59,6 +58,11 @@ func (c *Context) runCheckpoint(current, checkpoint string, fn func() error) err return nil } +func (c *Context) completeCheckpoint(checkpoint string) error { + c.Manifest.Checkpoint = checkpoint + return c.Manifest.Flush() +} + func (c *Context) Deploy(commit func() error) error { if c.Provider.Name() == api.BYOK && c.Cloud { return nil @@ -222,8 +226,9 @@ func (tf *terraformCmd) run() (err error) { args := append([]string{tf.cmd}, tf.args...) cmd := exec.Command("terraform", args...) cmd.Dir = tf.dir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + stdout, stderr := commandOutput() + cmd.Stdout = stdout + cmd.Stderr = stderr err = cmd.Run() if err == nil { return diff --git a/pkg/up/deploy_test.go b/pkg/up/deploy_test.go new file mode 100644 index 000000000..46acf91ac --- /dev/null +++ b/pkg/up/deploy_test.go @@ -0,0 +1,119 @@ +package up + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/pluralsh/plural-cli/pkg/manifest" +) + +func TestRunCheckpointAdvancesAndFlushes(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + man := &manifest.ProjectManifest{Cluster: "test"} + if err := man.Write(filepath.Join(dir, "workspace.yaml")); err != nil { + t.Fatal(err) + } + + ctx := &Context{Manifest: man} + if err := ctx.runCheckpoint("", "init", func() error { return nil }); err != nil { + t.Fatal(err) + } + + if man.Checkpoint != "init" { + t.Fatalf("Checkpoint = %q, want init", man.Checkpoint) + } + + loaded, err := manifest.ReadProject(filepath.Join(dir, "workspace.yaml")) + if err != nil { + t.Fatal(err) + } + if loaded.Checkpoint != "init" { + t.Fatalf("flushed Checkpoint = %q, want init", loaded.Checkpoint) + } +} + +func TestRunCheckpointDoesNotAdvanceOnError(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + man := &manifest.ProjectManifest{Cluster: "test"} + if err := man.Write(filepath.Join(dir, "workspace.yaml")); err != nil { + t.Fatal(err) + } + + ctx := &Context{Manifest: man} + err := ctx.runCheckpoint("", "init", func() error { + return errors.New("terraform failed") + }) + if err == nil { + t.Fatal("expected error") + } + + if man.Checkpoint != "" { + t.Fatalf("Checkpoint = %q, want empty", man.Checkpoint) + } + + loaded, err := manifest.ReadProject(filepath.Join(dir, "workspace.yaml")) + if err != nil { + t.Fatal(err) + } + if loaded.Checkpoint != "" { + t.Fatalf("flushed Checkpoint = %q, want empty", loaded.Checkpoint) + } +} + +func TestRunCheckpointSkipsCompleted(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + man := &manifest.ProjectManifest{Cluster: "test", Checkpoint: "init"} + if err := man.Write(filepath.Join(dir, "workspace.yaml")); err != nil { + t.Fatal(err) + } + + called := false + ctx := &Context{Manifest: man} + if err := ctx.runCheckpoint(man.Checkpoint, "init", func() error { + called = true + return nil + }); err != nil { + t.Fatal(err) + } + + if called { + t.Fatal("expected completed checkpoint to be skipped") + } + if man.Checkpoint != "init" { + t.Fatalf("Checkpoint = %q, want init", man.Checkpoint) + } +} + +func TestRunCheckpointRunsLaterPhase(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + man := &manifest.ProjectManifest{Cluster: "test", Checkpoint: "init"} + if err := man.Write(filepath.Join(dir, "workspace.yaml")); err != nil { + t.Fatal(err) + } + + ctx := &Context{Manifest: man} + if err := ctx.runCheckpoint(man.Checkpoint, "commit", func() error { return nil }); err != nil { + t.Fatal(err) + } + + if man.Checkpoint != "commit" { + t.Fatalf("Checkpoint = %q, want commit", man.Checkpoint) + } + + loaded, err := manifest.ReadProject(filepath.Join(dir, "workspace.yaml")) + if err != nil { + t.Fatal(err) + } + if loaded.Checkpoint != "commit" { + t.Fatalf("flushed Checkpoint = %q, want commit", loaded.Checkpoint) + } +} diff --git a/pkg/up/generate.go b/pkg/up/generate.go index 6019d2dc5..eb2774cfb 100644 --- a/pkg/up/generate.go +++ b/pkg/up/generate.go @@ -57,7 +57,7 @@ func (c *Context) Generate(gitRef string) (dir string, err error) { for _, tpl := range tpls { if utils.Exists(tpl.to) && !tpl.overwrite { - fmt.Printf("%s already exists, skipping for now...\n", tpl.to) + _, _ = fmt.Fprintf(commandStdoutWriter(), "%s already exists, skipping for now...\n", tpl.to) continue } diff --git a/pkg/up/output.go b/pkg/up/output.go new file mode 100644 index 000000000..ccdfaa417 --- /dev/null +++ b/pkg/up/output.go @@ -0,0 +1,42 @@ +package up + +import ( + "io" + "os" + "sync" +) + +var ( + commandOutputMu sync.RWMutex + commandStdout io.Writer = os.Stdout + commandStderr io.Writer = os.Stderr +) + +// SetCommandOutput redirects terraform (and related) command stdout/stderr. +// Pass nil writers to restore os.Stdout / os.Stderr. Safe for concurrent use. +func SetCommandOutput(stdout, stderr io.Writer) { + commandOutputMu.Lock() + defer commandOutputMu.Unlock() + if stdout == nil { + commandStdout = os.Stdout + } else { + commandStdout = stdout + } + if stderr == nil { + commandStderr = os.Stderr + } else { + commandStderr = stderr + } +} + +func commandOutput() (stdout, stderr io.Writer) { + commandOutputMu.RLock() + defer commandOutputMu.RUnlock() + return commandStdout, commandStderr +} + +// commandStdoutWriter is a convenience for fmt.Fprint-style generation messages. +func commandStdoutWriter() io.Writer { + stdout, _ := commandOutput() + return stdout +} diff --git a/pkg/up/prune.go b/pkg/up/prune.go index 24d8be71c..69193908e 100644 --- a/pkg/up/prune.go +++ b/pkg/up/prune.go @@ -68,8 +68,9 @@ func (c *Context) pruneCloud() error { func stateRm(dir, field string) error { cmd := exec.Command("terraform", "state", "rm", field) cmd.Dir = dir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + stdout, stderr := commandOutput() + cmd.Stdout = stdout + cmd.Stderr = stderr return cmd.Run() } diff --git a/cmd/command/workbenches/pr_followup_service.go b/pkg/workbenches/followup.go similarity index 73% rename from cmd/command/workbenches/pr_followup_service.go rename to pkg/workbenches/followup.go index 51c576dc5..ef5cfcbe5 100644 --- a/cmd/command/workbenches/pr_followup_service.go +++ b/pkg/workbenches/followup.go @@ -1,3 +1,4 @@ +// Package workbenches implements Console workbench PR follow-up, shared by CLI and TUI. package workbenches import ( @@ -5,20 +6,28 @@ import ( "strings" "time" - "github.com/pluralsh/plural-cli/pkg/console" + consoleclient "github.com/pluralsh/console/go/client" ) const pullRequestNotFoundError = "pull request not found" +// Enqueuer queues a follow-up prompt against the workbench job for a pull request. +type Enqueuer interface { + EnqueueWorkbenchPRFollowup(url, prompt string, deferBy time.Duration) (*consoleclient.EnqueueWorkbenchPrFollowup_EnqueueWorkbenchPrFollowup, error) +} + +// PullRequestURLResolver turns CLI pull-request flags into a canonical URL. type PullRequestURLResolver interface { Resolve(options PullRequestOptions) (string, error) } +// PRFollowupService queues a follow-up prompt for the workbench job on a PR. type PRFollowupService struct { - client console.ConsoleClient + client Enqueuer resolver PullRequestURLResolver } +// PRFollowupOptions are the inputs for Create. type PRFollowupOptions struct { Prompt string DeferBy time.Duration @@ -26,6 +35,7 @@ type PRFollowupOptions struct { SkipMissing bool } +// PRFollowupResult is the queued follow-up returned by Console. type PRFollowupResult struct { PromptID string `json:"promptId"` PullRequestURL string `json:"pullRequestUrl"` @@ -33,10 +43,12 @@ type PRFollowupResult struct { Skipped bool `json:"skipped"` } -func NewPRFollowupService(client console.ConsoleClient, resolver PullRequestURLResolver) *PRFollowupService { +// NewPRFollowupService constructs a follow-up service. +func NewPRFollowupService(client Enqueuer, resolver PullRequestURLResolver) *PRFollowupService { return &PRFollowupService{client: client, resolver: resolver} } +// Create resolves the pull request and queues the follow-up prompt. func (s *PRFollowupService) Create(options PRFollowupOptions) (PRFollowupResult, error) { if strings.TrimSpace(options.Prompt) == "" { return PRFollowupResult{}, fmt.Errorf("prompt cannot be empty") diff --git a/cmd/command/workbenches/pull_request_provider.go b/pkg/workbenches/provider.go similarity index 92% rename from cmd/command/workbenches/pull_request_provider.go rename to pkg/workbenches/provider.go index 117610316..61c1c7619 100644 --- a/cmd/command/workbenches/pull_request_provider.go +++ b/pkg/workbenches/provider.go @@ -5,6 +5,7 @@ import ( "strings" ) +// ProviderName identifies a source-control host used to build pull request URLs. type ProviderName string const ( @@ -14,6 +15,7 @@ const ( ProviderBitbucket ProviderName = "bitbucket" ) +// PullRequestProvider extracts a pull request number from a commit subject. type PullRequestProvider interface { Name() ProviderName Supports(host string) bool diff --git a/cmd/command/workbenches/pull_request_resolver.go b/pkg/workbenches/pull_request.go similarity index 93% rename from cmd/command/workbenches/pull_request_resolver.go rename to pkg/workbenches/pull_request.go index c93338076..397c49eb4 100644 --- a/cmd/command/workbenches/pull_request_resolver.go +++ b/pkg/workbenches/pull_request.go @@ -8,6 +8,7 @@ import ( "github.com/samber/lo" ) +// PullRequestOptions are the CLI flags used to identify a pull request. type PullRequestOptions struct { URL string Commit string @@ -15,6 +16,7 @@ type PullRequestOptions struct { Provider string } +// PullRequestResolver infers a pull request URL from git metadata or an explicit URL. type PullRequestResolver struct { repository PullRequestRepository providers []PullRequestProvider @@ -25,6 +27,7 @@ type repositoryAddress struct { host string } +// NewPullRequestResolver builds a resolver. A nil repository uses git. func NewPullRequestResolver(repository PullRequestRepository) *PullRequestResolver { if repository == nil { repository = GitPullRequestRepository{} @@ -36,6 +39,7 @@ func NewPullRequestResolver(repository PullRequestRepository) *PullRequestResolv } } +// Resolve returns an explicit PR URL or infers one from git HEAD / origin. func (r *PullRequestResolver) Resolve(options PullRequestOptions) (string, error) { if options.URL != "" && options.Commit != "" { return "", fmt.Errorf("url and commit cannot be used together") diff --git a/cmd/command/workbenches/pull_request_resolver_test.go b/pkg/workbenches/pull_request_test.go similarity index 100% rename from cmd/command/workbenches/pull_request_resolver_test.go rename to pkg/workbenches/pull_request_test.go diff --git a/cmd/command/workbenches/pull_request_repository.go b/pkg/workbenches/repository.go similarity index 76% rename from cmd/command/workbenches/pull_request_repository.go rename to pkg/workbenches/repository.go index e84bcac19..4ae2f208c 100644 --- a/cmd/command/workbenches/pull_request_repository.go +++ b/pkg/workbenches/repository.go @@ -2,11 +2,13 @@ package workbenches import gitutils "github.com/pluralsh/plural-cli/pkg/utils/git" +// PullRequestRepository reads the local git checkout used to infer a PR URL. type PullRequestRepository interface { CommitSubject(ref string) (string, error) RemoteURL() (string, error) } +// GitPullRequestRepository implements PullRequestRepository with git. type GitPullRequestRepository struct{} func (GitPullRequestRepository) CommitSubject(ref string) (string, error) { diff --git a/tui/app/model.go b/tui/app/model.go new file mode 100644 index 000000000..cee3242e8 --- /dev/null +++ b/tui/app/model.go @@ -0,0 +1,236 @@ +// 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" + agentsbridge "github.com/pluralsh/plural-cli/pkg/bridge/agents" + aibridge "github.com/pluralsh/plural-cli/pkg/bridge/ai" + clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + edgebridge "github.com/pluralsh/plural-cli/pkg/bridge/edge" + 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" + 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" + downscreen "github.com/pluralsh/plural-cli/tui/screens/down" + edgescreen "github.com/pluralsh/plural-cli/tui/screens/edge" + 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" + 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" +) + +// 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 + Notifications notificationsbridge.Loader + Providers providersbridge.Loader + Stacks stacksbridge.Loader + PullRequests pullrequestsbridge.Loader + Agents agentsbridge.Loader + Workbenches workbenchesbridge.Loader + AI aibridge.Client + Edge edgebridge.Loader +} + +// 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 + deployments deploymentsscreen.Model + services servicesscreen.Model + clusters clustersscreen.Model + repositories repositoriesscreen.Model + pipelines pipelinesscreen.Model + notifications notificationsscreen.Model + providers providersscreen.Model + stacks stacksscreen.Model + pullrequests pullrequestsscreen.Model + ai aiscreen.Model + agents agentsscreen.Model + workbenches workbenchesscreen.Model + up upscreen.Model + down downscreen.Model + edge edgescreen.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), + 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), + ai: aiscreen.New(ctx, dependencies.AI, t), + agents: agentsscreen.New(ctx, dependencies.Agents, t), + workbenches: workbenchesscreen.New(ctx, dependencies.Workbenches, t), + up: upscreen.New(ctx, t), + down: downscreen.New(ctx, t), + edge: edgescreen.New(ctx, dependencies.Edge, 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: + return m.navigateTo(msg.Route) + 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 + } + if m.route == navigation.AI && m.ai.HasCancellableOperation() { + var cmd tea.Cmd + m.ai, cmd = m.ai.Update(msg) + return m, cmd + } + return m, tea.Quit + } + } + return m.updateActive(msg) +} + +func (m Model) navigateTo(route navigation.Route) (Model, tea.Cmd) { + m.route = route + switch route { + case navigation.Access: + 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() + case navigation.Clusters: + return m, m.clusters.Init() + case navigation.Repositories: + return m, m.repositories.Init() + case navigation.Pipelines: + return m, m.pipelines.Init() + case navigation.Notifications: + return m, m.notifications.Init() + case navigation.Providers: + return m, m.providers.Init() + case navigation.Stacks: + 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() + case navigation.Up: + return m, m.up.Init() + case navigation.Down: + m.down = m.down.Reset() + return m, m.down.Init() + case navigation.Edge: + return m, m.edge.Init() + default: + return m, m.welcome.Init() + } +} + +func (m Model) updateActive(msg tea.Msg) (Model, tea.Cmd) { + 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) + case navigation.Deployments: + 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) + case navigation.Repositories: + 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) + case navigation.Providers: + 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) + 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) + case navigation.Up: + m.up, cmd = m.up.Update(msg) + case navigation.Down: + m.down, cmd = m.down.Update(msg) + case navigation.Edge: + m.edge, cmd = m.edge.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..5ae590c9d --- /dev/null +++ b/tui/app/model_test.go @@ -0,0 +1,140 @@ +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.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") { + 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.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.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.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) + } + 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) + } + updated, _ = model.Update(navigation.NavigateMsg{Route: navigation.Down}) + routed = updated.(Model) + if routed.route != navigation.Down || !strings.Contains(routed.View().Content, "Destroy mode") { + t.Fatalf("down route/view = %q\n%s", routed.route, routed.View().Content) + } + updated, _ = model.Update(navigation.NavigateMsg{Route: navigation.Edge}) + routed = updated.(Model) + if routed.route != navigation.Edge || !strings.Contains(routed.View().Content, "Edge commands") { + t.Fatalf("edge route/view = %q\n%s", routed.route, routed.View().Content) + } +} + +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) + } +} + +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..efece5910 --- /dev/null +++ b/tui/app/view.go @@ -0,0 +1,55 @@ +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) + case navigation.Deployments: + 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) + case navigation.Repositories: + 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) + case navigation.Providers: + 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) + 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) + case navigation.Up: + content = m.up.View(m.width, m.height) + case navigation.Down: + content = m.down.View(m.width, m.height) + case navigation.Edge: + content = m.edge.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..36d94b026 --- /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 = 8 +) + +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/oplog/export.go b/tui/components/oplog/export.go new file mode 100644 index 000000000..ccd20d56f --- /dev/null +++ b/tui/components/oplog/export.go @@ -0,0 +1,53 @@ +// Package oplog writes captured TUI terraform/generation logs to a file so +// they can be copied after the terminal UI exits. +package oplog + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/x/ansi" +) + +// Write saves kind logs (and optional error) to dir. Empty dir uses the +// working directory, then the system temp dir if that write fails. +func Write(dir, kind string, lines []string, runErr error) (string, error) { + if dir == "" { + wd, err := os.Getwd() + if err != nil { + dir = os.TempDir() + } else { + dir = wd + } + } + name := fmt.Sprintf("plural-%s-%s.log", kind, time.Now().Format("20060102-150405")) + body := format(kind, lines, runErr) + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + if dir != os.TempDir() { + alt := filepath.Join(os.TempDir(), name) + if err2 := os.WriteFile(alt, []byte(body), 0o644); err2 == nil { + return alt, nil + } + } + return "", err + } + return path, nil +} + +func format(kind string, lines []string, runErr error) string { + var b strings.Builder + fmt.Fprintf(&b, "# plural %s log %s\n", kind, time.Now().Format(time.RFC3339)) + if runErr != nil { + fmt.Fprintf(&b, "# error: %s\n", runErr.Error()) + } + b.WriteByte('\n') + for _, line := range lines { + b.WriteString(ansi.Strip(line)) + b.WriteByte('\n') + } + return b.String() +} diff --git a/tui/components/oplog/export_test.go b/tui/components/oplog/export_test.go new file mode 100644 index 000000000..f514d6fff --- /dev/null +++ b/tui/components/oplog/export_test.go @@ -0,0 +1,31 @@ +package oplog + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteIncludesErrorAndStripsANSI(t *testing.T) { + dir := t.TempDir() + path, err := Write(dir, "down", []string{"\x1b[31mError: sts 403\x1b[0m", "ok"}, errors.New("exit status 1")) + if err != nil { + t.Fatal(err) + } + if filepath.Dir(path) != dir { + t.Fatalf("path=%s", path) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + got := string(data) + if !strings.Contains(got, "Error: sts 403") || strings.Contains(got, "\x1b[") { + t.Fatalf("expected stripped log, got:\n%s", got) + } + if !strings.Contains(got, "# error: exit status 1") { + t.Fatalf("missing error header:\n%s", got) + } +} 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..d44687a59 --- /dev/null +++ b/tui/navigation/navigation.go @@ -0,0 +1,36 @@ +// 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" + AI Route = "ai" + Agents Route = "agents" + Workbenches Route = "workbenches" + Deployments Route = "deployments" + Services Route = "services" + Clusters Route = "clusters" + Repositories Route = "repositories" + Pipelines Route = "pipelines" + Notifications Route = "notifications" + Providers Route = "providers" + Stacks Route = "stacks" + PullRequests Route = "pullrequests" + Up Route = "up" + Down Route = "down" + Edge Route = "edge" +) + +// 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..2b0f13e99 --- /dev/null +++ b/tui/screens/access/model.go @@ -0,0 +1,373 @@ +// 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 + m.appCursor = clampCursor(m.appCursor, len(m.snapshot.State.Profiles)) + m.consoleCursor = clampCursor(m.consoleCursor, len(m.snapshot.State.ConsoleProfiles)) + } + 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) % 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..4283d0200 --- /dev/null +++ b/tui/screens/access/model_test.go @@ -0,0 +1,188 @@ +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) 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 +} +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 TestReloadClampsCursors(t *testing.T) { + model := New(t.Context(), &fakeManager{}, theme.New(colorprofile.ASCII)) + model.appCursor = 5 + model.consoleCursor = 3 + model, _ = model.Update(loadedMsg{snapshot: accessbridge.Snapshot{State: accessbridge.State{ + Profiles: []accessbridge.Profile{{ID: "app-a"}, {ID: "app-b"}}, + ConsoleProfiles: []accessbridge.ConsoleProfile{{ID: "console-a"}}, + }}}) + if model.appCursor != 0 { + t.Fatalf("appCursor = %d after shorter reload, want 0", model.appCursor) + } + if model.consoleCursor != 0 { + t.Fatalf("consoleCursor = %d after shorter reload, want 0", model.consoleCursor) + } + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("confirm after reload produced no command") + } + cmd() +} + +func TestPreviousPanelMovesBackward(t *testing.T) { + manager := &fakeManager{snapshot: accessbridge.Snapshot{State: accessbridge.State{ + Profiles: []accessbridge.Profile{{ID: "app-a"}}, + ConsoleProfiles: []accessbridge.ConsoleProfile{{ID: "console-a"}}, + }}} + model := loadedModel(t, manager) + if model.panel != 0 { + t.Fatalf("initial panel = %d", model.panel) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + if model.panel != 1 { + t.Fatalf("tab panel = %d, want 1", model.panel) + } + shiftTab := tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} + if shiftTab.Keystroke() != "shift+tab" { + t.Fatalf("shift+tab keystroke = %q", shiftTab.Keystroke()) + } + model, _ = model.Update(shiftTab) + if model.panel != 0 { + t.Fatalf("shift+tab panel = %d, want 0", model.panel) + } + model, _ = model.Update(shiftTab) + if model.panel != 1 { + t.Fatalf("shift+tab wrap panel = %d, want 1", model.panel) + } +} + +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..c329cfdcb --- /dev/null +++ b/tui/screens/access/view.go @@ -0,0 +1,154 @@ +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 := make([]string, 0, 2+len(m.form)) + lines = append(lines, 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/agents/model.go b/tui/screens/agents/model.go new file mode 100644 index 000000000..a44b1b14a --- /dev/null +++ b/tui/screens/agents/model.go @@ -0,0 +1,292 @@ +// Package agents implements the interactive agent-run browser. +package agents + +import ( + "context" + "errors" + "io" + "os" + "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 + execResume bool + getwd func() (string, error) +} + +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, execResume: true, getwd: os.Getwd} +} + +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 == "" { + path = m.workingDirectory() + } + if path == "" { + m.err = errors.New("enter the path to an existing clone of " + m.detail.Repository) + return m, nil + } + m.input.Blur() + m.loading = true + m.err = nil + m.result = "Launching agent resume for " + m.detail.ID + " in " + path + return m, m.beginResume(path) + } + 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.err = nil + cwd := m.workingDirectory() + m.input.CharLimit = 1024 + m.input.SetValue(cwd) + m.input.Placeholder = cwd + if m.input.Placeholder == "" { + m.input.Placeholder = "absolute path to existing 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 (m Model) beginResume(path string) tea.Cmd { + if m.loader == nil { + return func() tea.Msg { return resumedMsg{err: errors.New("agent services are unavailable")} } + } + id, prRef, loader, ctx := m.detail.ID, m.detail.PRRef, m.loader, m.ctx + run := func() error { return loader.Resume(ctx, id, path, prRef) } + if !m.execResume { + return func() tea.Msg { return resumedMsg{err: run()} } + } + return tea.Exec(&resumeExecCommand{run: run}, func(err error) tea.Msg { + return resumedMsg{err: err} + }) +} + +// resumeExecCommand runs RestoreAndResume after the TUI releases the terminal +// so the provider agent can use stdin/stdout. +type resumeExecCommand struct { + run func() error +} + +func (c *resumeExecCommand) Run() error { + if c.run == nil { + return errors.New("agent resume is not configured") + } + return c.run() +} + +func (c *resumeExecCommand) SetStdin(io.Reader) {} +func (c *resumeExecCommand) SetStdout(io.Writer) {} +func (c *resumeExecCommand) SetStderr(io.Writer) {} + +func (m Model) workingDirectory() string { + getwd := m.getwd + if getwd == nil { + getwd = os.Getwd + } + dir, err := getwd() + if err != nil { + return "" + } + return strings.TrimSpace(dir) +} + +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..45274788a --- /dev/null +++ b/tui/screens/agents/model_test.go @@ -0,0 +1,109 @@ +package agents + +import ( + "context" + "errors" + "strings" + "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 + resumeID string + resumePath string + resumePR string + resumeErr error +} + +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 (f *fakeLoader) Resume(_ context.Context, id, path, prRef string) error { + f.resumeID, f.resumePath, f.resumePR = id, path, prRef + return f.resumeErr +} + +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) + } +} + +func TestRepoPathSuggestsWorkingDirectory(t *testing.T) { + loader := &fakeLoader{ + page: agentsbridge.Page{Items: []agentsbridge.Summary{{ID: "run-1", Repository: "acme/repo"}}}, + detail: agentsbridge.Detail{Summary: agentsbridge.Summary{ID: "run-1", Repository: "git@github.com:acme/tf-test.git"}}, + } + model := New(t.Context(), loader, theme.New(colorprofile.ASCII)) + model.execResume = false + model.getwd = func() (string, error) { return "/home/lukasz/plural/tf-test", nil } + 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: 'r'}) + if model.input.Value() != "/home/lukasz/plural/tf-test" { + t.Fatalf("suggested path = %q", model.input.Value()) + } + got := model.View(80, 24) + if !strings.Contains(got, "Current dir") || !strings.Contains(got, "/home/lukasz/plural/tf-test") { + t.Fatalf("path view missing cwd suggestion:\n%s", got) + } + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("enter did not resume from suggested cwd") + } + _, _ = model.Update(cmd()) + if loader.resumePath != "/home/lukasz/plural/tf-test" { + t.Fatalf("resume path = %q", loader.resumePath) + } +} + +func TestResumeCallsBridgeAndShowsError(t *testing.T) { + loader := &fakeLoader{ + page: agentsbridge.Page{Items: []agentsbridge.Summary{{ID: "run-1", Repository: "acme/repo", Provider: "codex", PRRef: "feat/fix"}}}, + detail: agentsbridge.Detail{Summary: agentsbridge.Summary{ID: "run-1", Repository: "acme/repo", Provider: "codex", PRRef: "feat/fix"}}, + resumeErr: errors.New("not a git checkout for git@github.com:acme/repo.git"), + } + model := New(t.Context(), loader, theme.New(colorprofile.ASCII)) + model.execResume = false + 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: 'r'}) + model.input.SetValue("/work/repo") + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("resume did not start") + } + model, _ = model.Update(cmd()) + if loader.resumeID != "run-1" || loader.resumePath != "/work/repo" || loader.resumePR != "feat/fix" { + t.Fatalf("resume args = %s %s %s", loader.resumeID, loader.resumePath, loader.resumePR) + } + if model.mode != modeResult || model.err == nil { + t.Fatalf("expected failed result, got mode=%d err=%v", model.mode, model.err) + } + got := model.View(80, 24) + if !strings.Contains(got, "Resume failed") || !strings.Contains(got, "not a git checkout") { + t.Fatalf("result view missing resume error:\n%s", got) + } +} diff --git a/tui/screens/agents/view.go b/tui/screens/agents/view.go new file mode 100644 index 000000000..f254bc96a --- /dev/null +++ b/tui/screens/agents/view.go @@ -0,0 +1,152 @@ +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 { + cwd := m.workingDirectory() + lines := []string{ + "Existing clone for " + m.detail.Repository, + "Current dir " + value(cwd), + "", + m.input.View(), + } + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(errorText(m.err))) + } + return page.Panel(m.theme, "Choose local clone", lines, width, 9, true), "enter use path · esc cancel" + } + if m.mode == modeResult { + lines := []string{m.theme.Success.Render("✓ Resume complete"), "", m.result} + if m.err != nil { + wrapped := wrapLines(errorText(m.err), max(1, width-4)) + lines = make([]string, 0, 2+len(wrapped)) + lines = append(lines, m.theme.Danger.Render("✗ Resume failed"), "") + lines = append(lines, wrapped...) + } + return page.Panel(m.theme, "Agent resume", lines, width, 12, 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))) + } + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(errorText(m.err))) + } + 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 +} + +func wrapLines(text string, width int) []string { + if width < 1 { + width = 1 + } + text = strings.TrimSpace(text) + if text == "" { + return []string{"no error details were returned"} + } + out := make([]string, 0, strings.Count(text, "\n")+1) + for _, line := range strings.Split(text, "\n") { + out = append(out, strings.Split(ansi.Wrap(line, width, ""), "\n")...) + } + return out +} + +func errorText(err error) string { + if err == nil { + return "" + } + text := strings.TrimSpace(err.Error()) + if text == "" || text == "" { + return fmt.Sprintf("%T", err) + } + return text +} diff --git a/tui/screens/ai/model.go b/tui/screens/ai/model.go new file mode 100644 index 000000000..320fc58d4 --- /dev/null +++ b/tui/screens/ai/model.go @@ -0,0 +1,313 @@ +package ai + +import ( + "context" + "errors" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + aibridge "github.com/pluralsh/plural-cli/pkg/bridge/ai" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeHub mode = iota + modeChat +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionNewChat + keyActionPgUp + keyActionPgDown + keyActionCancel + keyActionConnect +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionNewChat: {"ctrl+n"}, + keyActionPgUp: {"pgup"}, + keyActionPgDown: {"pgdown"}, + keyActionCancel: {"ctrl+c"}, + keyActionConnect: {"c"}, +} + +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: "c", title: "Chat", blurb: "Plural App assistant", command: "plural ai", usage: "Chat with Plural App about setup, open source, or Kubernetes."}, + {number: "2", 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: "3", 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."}, +} + +var errNoApp = errors.New("connect a Plural App profile before chatting") + +type replyMsg struct { + message aibridge.Message + err error + request uint64 +} + +type initMsg struct{} + +// Model owns the AI hub and the in-place Chat conversation. +type Model struct { + ctx context.Context + chat aibridge.Client + theme theme.Theme + mode mode + cursor int + history []aibridge.Message + input textinput.Model + thinking bool + needsAuth bool + err error + request uint64 + cancel context.CancelFunc + chatFromEnd int +} + +func New(ctx context.Context, chat aibridge.Client, t theme.Theme) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "Ask about Plural, open source, or Kubernetes" + input.CharLimit = 4000 + 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, chat: chat, theme: t, input: input} +} + +func (m Model) Init() tea.Cmd { return func() tea.Msg { return initMsg{} } } + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.needsAuth = false + m.err = nil + if m.mode == modeChat { + m.input.Focus() + } + return m, nil + case replyMsg: + return m.applyReply(msg) + case tea.KeyPressMsg: + if m.mode == modeChat { + return m.updateChat(msg) + } + return m.updateHub(msg) + } + if m.mode == modeChat && !m.thinking && !m.needsAuth { + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateHub(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) updateChat(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + if m.thinking { + if action == keyActionBack || action == keyActionCancel { + return m.stopThinking(), nil + } + return m, nil + } + if m.needsAuth { + if action == keyActionConnect { + return m, navigation.Navigate(navigation.Access) + } + if action == keyActionBack { + return m.closeChat(), nil + } + return m, nil + } + switch action { + case keyActionBack: + return m.closeChat(), nil + case keyActionConfirm: + return m.send() + case keyActionNewChat: + m.resetConversation() + return m, nil + case keyActionPgUp: + m.chatFromEnd += 8 + return m, nil + case keyActionPgDown: + m.chatFromEnd = max(0, m.chatFromEnd-8) + return m, nil + } + var cmd tea.Cmd + m.input, cmd = m.input.Update(key) + return m, cmd +} + +func (m Model) open() (Model, tea.Cmd) { + switch m.cursor { + case 0: + return m.openChat() + case 1: + return m, navigation.Navigate(navigation.Agents) + default: + return m, navigation.Navigate(navigation.Workbenches) + } +} + +func (m Model) openChat() (Model, tea.Cmd) { + m.mode = modeChat + m.needsAuth = false + m.err = nil + if len(m.history) == 0 { + m.resetConversation() + } + m.input.Focus() + return m, nil +} + +func (m Model) closeChat() Model { + m.mode = modeHub + m.input.Blur() + m.thinking = false + if m.cancel != nil { + m.cancel() + m.cancel = nil + } + return m +} + +func (m *Model) resetConversation() { + m.history = []aibridge.Message{{Role: aibridge.RoleSystem, Content: aibridge.Intro}} + m.chatFromEnd = 0 + m.err = nil + m.needsAuth = false + m.input.SetValue("") +} + +func (m Model) send() (Model, tea.Cmd) { + prompt := strings.TrimSpace(m.input.Value()) + if prompt == "" { + return m, nil + } + m.input.SetValue("") + m.history = append(m.history, aibridge.Message{Role: aibridge.RoleUser, Content: prompt}) + m.err = nil + m.chatFromEnd = 0 + return m, m.beginChat() +} + +func (m *Model) beginChat() tea.Cmd { + m.thinking = true + m.request++ + request, history := m.request, append([]aibridge.Message(nil), m.history...) + chatCtx, cancel := context.WithCancel(m.ctx) + m.cancel = cancel + client := m.chat + return func() tea.Msg { + defer cancel() + if client == nil { + return replyMsg{ + err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoApp}, + request: request, + } + } + message, err := client.Chat(chatCtx, history) + return replyMsg{message: message, err: err, request: request} + } +} + +func (m Model) applyReply(msg replyMsg) (Model, tea.Cmd) { + if msg.request != m.request { + return m, nil + } + m.thinking = false + m.cancel = nil + if msg.err != nil { + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if m.needsAuth { + m.input.Blur() + } + return m, nil + } + m.history = append(m.history, msg.message) + m.chatFromEnd = 0 + return m, nil +} + +func (m Model) stopThinking() Model { + if m.cancel != nil { + m.cancel() + m.cancel = nil + } + m.request++ + m.thinking = false + return m +} + +// HasCancellableOperation lets the shell route Ctrl+C here while a reply is in flight. +func (m Model) HasCancellableOperation() bool { return m.thinking && m.cancel != nil } + +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..3f8317c96 --- /dev/null +++ b/tui/screens/ai/model_test.go @@ -0,0 +1,143 @@ +package ai + +import ( + "context" + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + + "github.com/pluralsh/plural-cli/pkg/bridge" + aibridge "github.com/pluralsh/plural-cli/pkg/bridge/ai" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeChat struct { + history []aibridge.Message + reply aibridge.Message + err error +} + +func (f *fakeChat) Chat(_ context.Context, history []aibridge.Message) (aibridge.Message, error) { + f.history = append([]aibridge.Message(nil), history...) + return f.reply, f.err +} + +func TestAIHubRoutesToInteractiveScreens(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + got := normalizeView(model.View(80, 24)) + if !strings.Contains(got, "AI workspaces") || !strings.Contains(got, "Chat") || !strings.Contains(got, "Agents") { + t.Fatalf("hub view missing entries:\n%s", got) + } + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd != nil { + t.Fatal("chat selection should stay on the AI 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.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") + } +} + +func TestAIHubChatShortcutOpensConversation(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'c', Text: "c"}) + if cmd != nil { + t.Fatal("opening chat emitted navigation") + } + if model.mode != modeChat { + t.Fatalf("mode = %d, want chat", model.mode) + } + got := normalizeView(model.View(80, 24)) + if !strings.Contains(got, "Conversation") || !strings.Contains(got, "What can we do to help you with Plural") { + t.Fatalf("chat view missing intro:\n%s", got) + } +} + +func TestAIChatSendsAndAppendsReply(t *testing.T) { + client := &fakeChat{reply: aibridge.Message{Role: aibridge.RoleAssistant, Content: "run plural up"}} + model := New(t.Context(), client, theme.New(colorprofile.ASCII)) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model.input.SetValue("how do I bootstrap?") + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("send did not start chat") + } + if !model.thinking || !model.HasCancellableOperation() { + t.Fatal("expected in-flight chat") + } + model, _ = model.Update(cmd()) + if model.thinking { + t.Fatal("reply left thinking state") + } + if len(model.history) != 3 || model.history[2].Content != "run plural up" { + t.Fatalf("history = %#v", model.history) + } + if len(client.history) != 2 || client.history[1].Content != "how do I bootstrap?" { + t.Fatalf("client history = %#v", client.history) + } + got := normalizeView(model.View(80, 24)) + if !strings.Contains(got, "run plural up") || !strings.Contains(got, "You") { + t.Fatalf("transcript missing reply:\n%s", got) + } +} + +func TestAIChatUnauthenticatedOpensAccess(t *testing.T) { + client := &fakeChat{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} + model := New(t.Context(), client, theme.New(colorprofile.ASCII)) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model.input.SetValue("hello") + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if !model.needsAuth { + t.Fatal("expected app login required") + } + got := normalizeView(model.View(80, 24)) + if !strings.Contains(got, "App login required") { + t.Fatalf("missing auth panel:\n%s", got) + } + _, nav := model.Update(tea.KeyPressMsg{Code: 'c', Text: "c"}) + if nav == nil || nav() != (navigation.NavigateMsg{Route: navigation.Access}) { + t.Fatal("c did not open Access") + } + model, _ = model.Update(model.Init()()) + if model.needsAuth || model.mode != modeChat { + t.Fatal("returning to AI should retry chat after App login") + } +} + +func TestAIChatEscReturnsToHub(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd != nil { + t.Fatal("esc from chat should stay on the AI screen") + } + if model.mode != modeHub { + t.Fatalf("mode = %d, want hub", model.mode) + } +} + +func TestAIChatIgnoresStaleReplyAfterCancel(t *testing.T) { + model := New(t.Context(), &fakeChat{reply: aibridge.Message{Role: aibridge.RoleAssistant, Content: "late"}}, theme.New(colorprofile.ASCII)) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model.input.SetValue("hello") + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.thinking { + t.Fatal("cancel left thinking state") + } + model, _ = model.Update(cmd()) + if len(model.history) != 2 { + t.Fatalf("stale reply appended: %#v", model.history) + } +} diff --git a/tui/screens/ai/view.go b/tui/screens/ai/view.go new file mode 100644 index 000000000..c39ac0c02 --- /dev/null +++ b/tui/screens/ai/view.go @@ -0,0 +1,143 @@ +package ai + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/x/ansi" + + aibridge "github.com/pluralsh/plural-cli/pkg/bridge/ai" + "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, height) + return page.Render(m.theme, width, height, m.title(), m.headerStatus(), body, help) +} + +func (m Model) title() string { + if m.mode == modeChat { + return "AI · Chat" + } + return "AI" +} + +func (m Model) headerStatus() string { + if m.mode != modeChat { + return m.theme.Success.Render(fmt.Sprintf("%d commands", len(items))) + } + if m.thinking { + return m.theme.Warning.Render("◌ thinking") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect App") + } + if m.err != nil { + return m.theme.Danger.Render("✗ failed") + } + return m.theme.Success.Render(fmt.Sprintf("%d messages", len(m.history))) +} + +func (m Model) bodyAndHelp(width, height int) (string, string) { + if m.mode == modeChat { + return m.chatBodyAndHelp(width, height) + } + lines := make([]string, 0, 2+len(items)+2) + lines = append(lines, m.theme.Muted.Render("Choose Chat, Agents, or Workbenches."), "") + 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("Chat uses Plural App; Agents and Workbenches use Console.")) + return page.Panel(m.theme, "AI workspaces", lines, width, 10, true), "↑/↓ select · enter open · 1-3 shortcut · esc back" +} + +func (m Model) chatBodyAndHelp(width, height int) (string, string) { + if m.needsAuth { + lines := []string{ + m.theme.Warning.Render("○ Plural App is not connected"), + m.theme.Muted.Render(" Chat uses your app.plural.sh token, not Console."), + "", + m.theme.Body.Render("Press c to open Access."), + } + return page.Panel(m.theme, "App login required", lines, width, 8, true), "c connect · esc hub" + } + inputH := 5 + convH := max(8, height-inputH-6) + inner := max(1, convH-2) + lines := m.transcriptLines(width - 4) + if m.thinking { + lines = append(lines, m.theme.Warning.Render("◌ Thinking…")) + } + if m.err != nil { + lines = append(lines, m.theme.Danger.Render(m.err.Error())) + } + offset := conversationOffset(len(lines), inner, m.chatFromEnd) + end := min(len(lines), offset+inner) + visible := lines[offset:end] + conversation := page.Panel(m.theme, "Conversation", visible, width, convH, true) + input := m.input + input.SetWidth(max(8, width-8)) + message := page.Panel(m.theme, "Message", []string{input.View()}, width, inputH, true) + help := "enter send · ctrl+n new · pgup/pgdn · esc hub" + if m.thinking { + help = "esc cancel · ctrl+c cancel" + } + if width < 100 { + help = "enter send · ctrl+n · pgup/pgdn · esc" + if m.thinking { + help = "esc/ctrl+c cancel" + } + } + return conversation + "\n" + message, help +} + +func (m Model) transcriptLines(innerWidth int) []string { + if innerWidth < 1 { + innerWidth = 1 + } + lines := make([]string, 0, len(m.history)*4) + for i, message := range m.history { + if i > 0 { + lines = append(lines, "") + } + lines = append(lines, m.speaker(message.Role)) + content := strings.TrimSpace(message.Content) + if content == "" { + continue + } + lines = append(lines, strings.Split(ansi.Wrap(content, innerWidth, ""), "\n")...) + } + return lines +} + +func (m Model) speaker(role string) string { + if strings.EqualFold(role, aibridge.RoleUser) { + return m.theme.Title.Render("You") + } + return m.theme.Success.Render("Plural AI") +} + +func conversationOffset(total, inner, fromEnd int) int { + maxOff := max(0, total-inner) + if fromEnd <= 0 { + return maxOff + } + return max(0, maxOff-fromEnd) +} + +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/model.go b/tui/screens/clusters/model.go new file mode 100644 index 000000000..edd2c4fc0 --- /dev/null +++ b/tui/screens/clusters/model.go @@ -0,0 +1,306 @@ +// 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 + 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 clustersbridge.Page + 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 + + 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 { + 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(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 = 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(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/clusters/model_test.go b/tui/screens/clusters/model_test.go new file mode 100644 index 000000000..32bc082a9 --- /dev/null +++ b/tui/screens/clusters/model_test.go @@ -0,0 +1,235 @@ +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 { + page clustersbridge.Page + detail clustersbridge.Detail + err error +} + +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 +} + +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{ + 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, + 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.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)) + } + + 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 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))) + 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.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{ + 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.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{ + 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..be037dd59 --- /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 │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ 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 new file mode 100644 index 000000000..080e76006 --- /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 │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / · n/p page · esc back diff --git a/tui/screens/clusters/view.go b/tui/screens/clusters/view.go new file mode 100644 index 000000000..37f80036a --- /dev/null +++ b/tui/screens/clusters/view.go @@ -0,0 +1,219 @@ +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.page.Items))) + } + return m.theme.Success.Render(fmt.Sprintf("%d clusters", len(m.page.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 · 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 "Clusters · filter “" + m.filter + "”" + } + return "Clusters" +} + +func (m Model) listLines(width int) []string { + 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.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")} + 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 = "› " + } + 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, "…")) + } + 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…")} + } + 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 += 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 new file mode 100644 index 000000000..ce08d4571 --- /dev/null +++ b/tui/screens/deployments/model.go @@ -0,0 +1,136 @@ +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 + resourceStacks + resourcePullRequests +) + +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: "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: "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: "browse · gen-backend", route: navigation.Stacks}, + {id: resourcePullRequests, number: "8", shortcut: "u", title: "Pull requests", blurb: "browse · create · trigger · …", route: navigation.PullRequests}, + } +} + +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..8143cd90d --- /dev/null +++ b/tui/screens/deployments/model_test.go @@ -0,0 +1,186 @@ +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 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 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 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 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 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) + } +} + +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), "") + 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) + } +} + +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}) + 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..40fa628e9 --- /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 browse · kick · create · … │ + │ 2 c Clusters list · describe │ + │ 3 r Repositories list · describe │ + │ 4 p Pipelines list · describe │ + │ 5 n Notifications list · describe │ + │ 6 v Providers list · describe │ + │ 7 t Stacks browse · gen-backend │ + │ 8 u Pull requests browse · create · trigger · … │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ╭─ Connection ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Console https://console.acme.io │ + │ Tip plural cd … remains the automation API │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + 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 new file mode 100644 index 000000000..1957fcc18 --- /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 browse · kick · create · … │ + │ 2 c Clusters list · describe │ + │ 3 r Repositories list · describe │ + │ 4 p Pipelines list · describe │ + │ 5 n Notifications list · describe │ + │ 6 v Providers list · describe │ + │ 7 t Stacks browse · gen-backend │ + │ 8 u Pull requests browse · create · trigger · … │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + ╭─ Connection ─────────────────────────────────────────────────────────────╮ + │ Console https://console.acme.io │ + │ Tip plural cd … remains the automation API │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + 1–8 / 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..695be7cbd --- /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, 10, true) + "\n\n" + + page.Panel(m.theme, "Connection", m.connectionLines(), contentWidth, 4, false) + + help := "1–8 / 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/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/down/destroy_exec.go b/tui/screens/down/destroy_exec.go new file mode 100644 index 000000000..197ef942b --- /dev/null +++ b/tui/screens/down/destroy_exec.go @@ -0,0 +1,105 @@ +package down + +import ( + "context" + "io" + "strings" + "sync" + + tea "charm.land/bubbletea/v2" + + upbridge "github.com/pluralsh/plural-cli/pkg/bridge/up" +) + +const maxOpLogLines = 500 + +type opLogLineMsg struct{ line string } + +type lineWriter struct { + ch chan<- string + mu sync.Mutex + buf strings.Builder +} + +func (w *lineWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + for _, b := range p { + if b == '\n' { + w.flushLocked() + continue + } + if b == '\r' { + continue + } + w.buf.WriteByte(b) + } + return len(p), nil +} + +func (w *lineWriter) flushLocked() { + line := strings.TrimRight(w.buf.String(), "\r") + w.buf.Reset() + if line == "" { + return + } + select { + case w.ch <- line: + default: + } +} + +func (w *lineWriter) Close() { + w.mu.Lock() + defer w.mu.Unlock() + if w.buf.Len() > 0 { + w.flushLocked() + } +} + +func listenOpLog(ch <-chan string) tea.Cmd { + return func() tea.Msg { + line, ok := <-ch + if !ok { + return nil + } + return opLogLineMsg{line: line} + } +} + +func shouldExecLiveRunner(runner upbridge.Runner) bool { + if runner == nil { + return true + } + _, ok := runner.(upbridge.LiveRunner) + return ok +} + +func appendOpLog(lines []string, line string) []string { + lines = append(lines, line) + if len(lines) > maxOpLogLines { + lines = lines[len(lines)-maxOpLogLines:] + } + return lines +} + +func destroyStreamCmd(ctx context.Context, runner upbridge.Runner, in upbridge.DestroyInput, lines chan string) tea.Cmd { + if runner == nil { + runner = upbridge.DefaultRunner() + } + if ctx == nil { + ctx = context.Background() + } + return func() tea.Msg { + w := &lineWriter{ch: lines} + in.Output = w + var steps []string + err := runner.Destroy(ctx, in, func(step string) { + steps = append(steps, step) + _, _ = io.WriteString(w, "→ "+step+"\n") + }) + w.Close() + close(lines) + return destroyDoneMsg{err: err, steps: steps} + } +} diff --git a/tui/screens/down/model.go b/tui/screens/down/model.go new file mode 100644 index 000000000..3f989f025 --- /dev/null +++ b/tui/screens/down/model.go @@ -0,0 +1,438 @@ +// Package down implements the plural-down destroy wizard. +package down + +import ( + "context" + "fmt" + + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + + upbridge "github.com/pluralsh/plural-cli/pkg/bridge/up" + "github.com/pluralsh/plural-cli/pkg/common" + "github.com/pluralsh/plural-cli/pkg/utils" + "github.com/pluralsh/plural-cli/tui/components/oplog" + 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 ( + modeSelectCloud mode = iota + modeAffirm + modeDestroying + modeComplete +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionUp + keyActionDown + keyActionConfirm + keyActionBack + keyActionPgUp + keyActionPgDown + keyActionHome + keyActionEnd + keyActionExport +) + +var keyActionKeystrokes = map[keyAction]string{ + keyActionUp: "up", + keyActionDown: "down", + keyActionConfirm: "enter", + keyActionBack: "esc", + keyActionPgUp: "pgup", + keyActionPgDown: "pgdown", + keyActionHome: "home", + keyActionEnd: "end", + keyActionExport: "e", +} + +func actionForKeystroke(keystroke string) keyAction { + for action, candidate := range keyActionKeystrokes { + if keystroke == candidate { + return action + } + } + return keyActionNone +} + +type cloudOption struct { + cloud bool + id string + title string + blurb string + cli string +} + +func cloudOptions() []cloudOption { + return []cloudOption{ + {cloud: false, id: "self-hosted", title: "Self-hosted", blurb: "destroy terraform/mgmt (default)", cli: "plural down"}, + {cloud: true, id: "cloud", title: "Plural Cloud", blurb: "state-rm plural_cluster.mgmt then destroy (--cloud)", cli: "plural down --cloud"}, + } +} + +func affirmOptions() []struct { + value bool + title string + blurb string +} { + return []struct { + value bool + title string + blurb string + }{ + {value: true, title: "Yes", blurb: "destroy management cluster (default)"}, + {value: false, title: "No", blurb: "cancel — leave infrastructure intact"}, + } +} + +type destroyDoneMsg struct { + err error + steps []string +} + +// Model is the Down wizard screen. +type Model struct { + ctx context.Context + theme theme.Theme + runner upbridge.Runner + mode mode + cursor int + cloud bool + err error + steps []string + opLog []string + opLogCh chan string + opLogY int + opLogFollow bool + viewH int + viewW int + spin spinner.Model + exportDir string // tests; empty uses cwd + logExportPath string + logExportErr error +} + +// New constructs a Down wizard. +func New(ctx context.Context, t theme.Theme) Model { + return Model{ + ctx: ctx, + theme: t, + runner: upbridge.DefaultRunner(), + mode: modeSelectCloud, + spin: pluralspinner.New(t), + opLogFollow: true, + } +} + +func (m Model) Init() tea.Cmd { return nil } + +// Reset returns a fresh wizard so a later visit does not show the previous run. +func (m Model) Reset() Model { + next := New(m.ctx, m.theme) + next.runner = m.runner + next.exportDir = m.exportDir + return next +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case destroyDoneMsg: + return m.applyDestroyDone(msg) + case opLogLineMsg: + m.opLog = appendOpLog(m.opLog, msg.line) + if m.opLogCh != nil { + return m, tea.Batch(m.spin.Tick, listenOpLog(m.opLogCh)) + } + return m, nil + case tea.WindowSizeMsg: + m.viewH = msg.Height + m.viewW = msg.Width + return m, nil + case spinner.TickMsg: + if m.mode != modeDestroying { + return m, nil + } + var cmd tea.Cmd + m.spin, cmd = m.spin.Update(msg) + return m, cmd + case tea.KeyPressMsg: + return m.updateKey(msg) + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + switch m.mode { + case modeSelectCloud: + return m.updateSelectCloud(action, key) + case modeAffirm: + return m.updateAffirm(action, key) + case modeDestroying: + m.handleOpLogScroll(action) + return m, nil + case modeComplete: + if m.handleOpLogScroll(action) { + return m, nil + } + if action == keyActionExport { + m.saveLogs("down") + return m, nil + } + if action == keyActionBack || action == keyActionConfirm { + return m, navigation.Navigate(navigation.Welcome) + } + return m, nil + } + return m, nil +} + +func (m *Model) handleOpLogScroll(action keyAction) bool { + window := 20 + if m.viewH > 0 { + _, window = logPanelBudget(m.viewH, 5) + } + switch action { + case keyActionUp: + m.scrollOpLog(-1, window) + return true + case keyActionDown: + m.scrollOpLog(1, window) + return true + case keyActionPgUp: + m.scrollOpLog(-window, window) + return true + case keyActionPgDown: + m.scrollOpLog(window, window) + return true + case keyActionHome: + m.opLogFollow = false + m.opLogY = 0 + return true + case keyActionEnd: + m.opLogFollow = true + return true + } + return false +} + +func (m *Model) scrollOpLog(delta, window int) { + if window <= 0 { + window = 20 + } + total := len(wrapOpLog(m.opLog, opLogContentWidth(m.viewW))) + maxStart := max(0, total-window) + if m.opLogFollow { + m.opLogY = maxStart + } + m.opLogFollow = false + m.opLogY += delta + if m.opLogY < 0 { + m.opLogY = 0 + } + if m.opLogY >= maxStart { + m.opLogY = maxStart + m.opLogFollow = true + } +} + +func (m Model) updateSelectCloud(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + opts := cloudOptions() + 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(opts)-1 { + m.cursor++ + } + return m, nil + case keyActionConfirm: + return m.selectCloud(opts[m.cursor]) + } + text := keyText(key) + for i, o := range opts { + if text == cloudShortcut(o.id) || text == string(rune('1'+i)) { + m.cursor = i + return m.selectCloud(o) + } + } + return m, nil +} + +func (m Model) selectCloud(opt cloudOption) (Model, tea.Cmd) { + m.cloud = opt.cloud + m.err = nil + if v, ok := utils.GetEnvBoolValue("PLURAL_DOWN_AFFIRM_DESTROY"); ok { + if !v { + m.err = fmt.Errorf("cancelled destroy") + return m, nil + } + return m.beginDestroy() + } + m.mode = modeAffirm + m.cursor = 0 + return m, nil +} + +func (m Model) updateAffirm(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + opts := affirmOptions() + switch action { + case keyActionBack: + m.mode = modeSelectCloud + m.err = nil + m.cursor = 0 + if m.cloud { + 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: + if !opts[m.cursor].value { + m.err = fmt.Errorf("cancelled destroy") + m.mode = modeSelectCloud + m.cursor = 0 + if m.cloud { + m.cursor = 1 + } + return m, nil + } + return m.beginDestroy() + } + text := keyText(key) + switch text { + case "y", "Y": + m.cursor = 0 + return m.beginDestroy() + case "n", "N": + m.err = fmt.Errorf("cancelled destroy") + m.mode = modeSelectCloud + m.cursor = 0 + if m.cloud { + m.cursor = 1 + } + return m, nil + } + return m, nil +} + +func (m Model) beginDestroy() (Model, tea.Cmd) { + m.mode = modeDestroying + m.err = nil + m.steps = nil + m.opLog = nil + m.opLogFollow = true + m.opLogY = 0 + runner := m.runner + if runner == nil { + runner = upbridge.DefaultRunner() + } + ctx := m.ctx + if ctx == nil { + ctx = context.Background() + } + in := upbridge.DestroyInput{Cloud: m.cloud} + if shouldExecLiveRunner(runner) { + lines := make(chan string, 256) + m.opLogCh = lines + return m, tea.Batch(m.spin.Tick, destroyStreamCmd(ctx, runner, in, lines), listenOpLog(lines)) + } + return m, tea.Batch(m.spin.Tick, m.destroyCmd(in)) +} + +func (m Model) destroyCmd(in upbridge.DestroyInput) tea.Cmd { + runner := m.runner + if runner == nil { + runner = upbridge.DefaultRunner() + } + ctx := m.ctx + if ctx == nil { + ctx = context.Background() + } + return func() tea.Msg { + var steps []string + err := runner.Destroy(ctx, in, func(step string) { + steps = append(steps, step) + }) + return destroyDoneMsg{err: err, steps: steps} + } +} + +func (m Model) applyDestroyDone(msg destroyDoneMsg) (Model, tea.Cmd) { + m.mode = modeComplete + m.opLogCh = nil + m.opLogFollow = true + m.steps = msg.steps + m.err = msg.err + if msg.err != nil { + m.saveLogs("down") + } + return m, nil +} + +func (m *Model) saveLogs(kind string) { + path, err := oplog.Write(m.exportDir, kind, m.opLog, m.err) + m.logExportPath = path + m.logExportErr = err +} + +func cloudShortcut(id string) string { + switch id { + case "self-hosted": + return "s" + case "cloud": + return "c" + default: + return "" + } +} + +func keyText(key tea.KeyPressMsg) string { + text := key.Text + if text == "" && key.Code > 0 && key.Code < 128 { + text = string(rune(key.Code)) + } + return text +} + +// AffirmMessage is the same prompt plural down uses (for views/tests). +func AffirmMessage() string { return common.AffirmDown } + +// Cloud reports whether --cloud was selected. +func (m Model) Cloud() bool { return m.cloud } + +// ModeName is a test helper. +func (m Model) ModeName() string { + switch m.mode { + case modeSelectCloud: + return "select-cloud" + case modeAffirm: + return "affirm" + case modeDestroying: + return "destroying" + case modeComplete: + return "complete" + default: + return "unknown" + } +} diff --git a/tui/screens/down/model_test.go b/tui/screens/down/model_test.go new file mode 100644 index 000000000..ac4a03951 --- /dev/null +++ b/tui/screens/down/model_test.go @@ -0,0 +1,272 @@ +package down + +import ( + "context" + "os" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + upbridge "github.com/pluralsh/plural-cli/pkg/bridge/up" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type stubRunner struct { + calls []upbridge.DestroyInput + err error +} + +func (s *stubRunner) Run(context.Context, upbridge.RunInput, upbridge.ProgressFunc) (upbridge.RunResult, error) { + return upbridge.RunResult{}, nil +} + +func (s *stubRunner) Deploy(context.Context, upbridge.DeployInput, upbridge.ProgressFunc) error { + return nil +} + +func (s *stubRunner) Destroy(_ context.Context, in upbridge.DestroyInput, progress upbridge.ProgressFunc) error { + s.calls = append(s.calls, in) + if progress != nil { + progress("Building destroy context…") + progress("Destroying management cluster terraform…") + } + return s.err +} + +func testModel(t *testing.T) (Model, *stubRunner) { + t.Helper() + runner := &stubRunner{} + model := New(t.Context(), theme.New(colorprofile.ASCII)) + model.runner = runner + model.exportDir = t.TempDir() + return model, runner +} + +func TestDownSelectsSelfHostedThenAffirmsAndDestroys(t *testing.T) { + model, runner := testModel(t) + if model.ModeName() != "select-cloud" { + t.Fatalf("mode = %s", model.ModeName()) + } + view := model.View(80, 24) + if !strings.Contains(view, "Destroy mode") || !strings.Contains(view, "Self-hosted") { + t.Fatalf("select view:\n%s", view) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.ModeName() != "affirm" { + t.Fatalf("after enter mode = %s", model.ModeName()) + } + if model.Cloud() { + t.Fatal("expected self-hosted") + } + if !strings.Contains(model.View(80, 24), "Are you ready to destroy") { + t.Fatalf("affirm view:\n%s", model.View(80, 24)) + } + + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.ModeName() != "destroying" { + t.Fatalf("after affirm mode = %s", model.ModeName()) + } + if cmd == nil { + t.Fatal("expected destroy cmd") + } + // drain batch: spinner tick + destroyCmd + msg := model.destroyCmd(upbridge.DestroyInput{Cloud: false})() + model, _ = model.Update(msg) + if model.ModeName() != "complete" { + t.Fatalf("after destroy mode = %s err=%v", model.ModeName(), model.err) + } + if model.err != nil { + t.Fatal(model.err) + } + if len(runner.calls) != 1 || runner.calls[0].Cloud { + t.Fatalf("calls = %#v", runner.calls) + } + if !strings.Contains(model.View(80, 24), "destroy finished") { + t.Fatalf("complete view:\n%s", model.View(80, 24)) + } +} + +func TestDownCloudShortcut(t *testing.T) { + model, runner := testModel(t) + model, _ = model.Update(tea.KeyPressMsg{Code: 'c', Text: "c"}) + if model.ModeName() != "affirm" || !model.Cloud() { + t.Fatalf("mode=%s cloud=%v", model.ModeName(), model.Cloud()) + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + msg := model.destroyCmd(upbridge.DestroyInput{Cloud: true})() + _, _ = model.Update(msg) + if len(runner.calls) != 1 || !runner.calls[0].Cloud { + t.Fatalf("calls = %#v", runner.calls) + } +} + +func TestDownAffirmNoCancels(t *testing.T) { + model, _ := testModel(t) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'n', Text: "n"}) + if model.ModeName() != "select-cloud" { + t.Fatalf("mode = %s", model.ModeName()) + } + if model.err == nil || !strings.Contains(model.err.Error(), "cancelled") { + t.Fatalf("err = %v", model.err) + } +} + +func TestDownEscReturnsWelcome(t *testing.T) { + model, _ := testModel(t) + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil { + t.Fatal("expected navigate") + } + if got := cmd().(navigation.NavigateMsg).Route; got != navigation.Welcome { + t.Fatalf("route = %q", got) + } +} + +func TestDownDestroyFailure(t *testing.T) { + model, runner := testModel(t) + runner.err = context.Canceled + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + msg := model.destroyCmd(upbridge.DestroyInput{})() + model, _ = model.Update(msg) + if model.ModeName() != "complete" || model.err == nil { + t.Fatalf("mode=%s err=%v", model.ModeName(), model.err) + } + if !strings.Contains(model.View(80, 24), "Destroy failed") { + t.Fatalf("view:\n%s", model.View(80, 24)) + } + if model.logExportPath == "" { + t.Fatal("expected auto-export on destroy error") + } + if !strings.Contains(model.View(80, 24), "Saved ") { + t.Fatalf("view should show export path:\n%s", model.View(80, 24)) + } +} + +func TestExportLogsOnComplete(t *testing.T) { + model, _ := testModel(t) + model.mode = modeComplete + model.opLog = []string{"destroy-log-line"} + model, _ = model.Update(tea.KeyPressMsg{Code: 'e', Text: "e"}) + if model.logExportPath == "" || model.logExportErr != nil { + t.Fatalf("export path=%q err=%v", model.logExportPath, model.logExportErr) + } + data, err := os.ReadFile(model.logExportPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "destroy-log-line") { + t.Fatalf("file:\n%s", data) + } +} + +func TestShouldNotExecStubRunner(t *testing.T) { + if shouldExecLiveRunner(&stubRunner{}) { + t.Fatal("stub should stay in-process") + } + if !shouldExecLiveRunner(upbridge.LiveRunner{}) { + t.Fatal("live should stream") + } +} + +func TestOpLogLinesWithLongBuffer(t *testing.T) { + model, _ := testModel(t) + model.mode = modeDestroying + for i := 0; i < 100; i++ { + model.opLog = append(model.opLog, "line") + } + // Must not panic (regression: make cap used limit-start which went negative). + _ = model.View(80, 24) + lines := model.opLogLines(12, 80) + if len(lines) != 12 { + t.Fatalf("len=%d want 12", len(lines)) + } +} + +func TestOpLogLinesUsePanelWidth(t *testing.T) { + model, _ := testModel(t) + model.mode = modeComplete + long := strings.Repeat("abcdefghij", 20) // 200 chars + model.opLog = []string{long} + view := ansi.Strip(model.View(160, 24)) + if !strings.Contains(view, strings.Repeat("abcdefghij", 12)) { + t.Fatalf("expected wrapped log to keep the start of the line, got:\n%s", view) + } + if !strings.Contains(view, long[len(long)-40:]) { + t.Fatalf("expected long log line to wrap instead of truncate, got:\n%s", view) + } + if strings.Contains(view, long) { + t.Fatal("expected wrap (newline) so the 200-char line is not a single row") + } +} + +func TestOpLogScrollOnComplete(t *testing.T) { + model, _ := testModel(t) + model.mode = modeComplete + model.viewH = 24 + model.opLogFollow = true + for i := 0; i < 40; i++ { + model.opLog = append(model.opLog, "line") + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyHome}) + if model.opLogFollow || model.opLogY != 0 { + t.Fatalf("home: follow=%v y=%d", model.opLogFollow, model.opLogY) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnd}) + if !model.opLogFollow { + t.Fatal("end should follow") + } + view := model.View(80, 24) + if !strings.Contains(view, "destroy finished") || !strings.Contains(view, "Scroll logs") { + t.Fatalf("complete should keep logs:\n%s", view) + } +} + +func TestCompleteWaitsForUser(t *testing.T) { + model, _ := testModel(t) + model.mode = modeComplete + model.opLog = []string{"destroy-log-line"} + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + if model.ModeName() != "complete" { + t.Fatalf("scroll must stay on complete, mode=%s", model.ModeName()) + } + if !strings.Contains(model.View(80, 24), "destroy-log-line") { + t.Fatalf("logs should remain:\n%s", model.View(80, 24)) + } + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("enter should navigate welcome") + } + msg := cmd() + nav, ok := msg.(navigation.NavigateMsg) + if !ok || nav.Route != navigation.Welcome { + t.Fatalf("nav = %#v", msg) + } +} + +func TestResetClearsCompletedRun(t *testing.T) { + model, runner := testModel(t) + model.mode = modeComplete + model.err = context.Canceled + model.opLog = []string{"old-destroy-log"} + model.cloud = true + model = model.Reset() + if model.ModeName() != "select-cloud" { + t.Fatalf("mode=%s", model.ModeName()) + } + if model.err != nil || model.Cloud() || len(model.opLog) != 0 { + t.Fatalf("stale state: err=%v cloud=%v logs=%d", model.err, model.Cloud(), len(model.opLog)) + } + if model.runner != runner { + t.Fatal("reset should keep the runner") + } + if strings.Contains(model.View(80, 24), "old-destroy-log") || strings.Contains(model.View(80, 24), "Destroy complete") { + t.Fatalf("expected a fresh destroy picker:\n%s", model.View(80, 24)) + } +} diff --git a/tui/screens/down/view.go b/tui/screens/down/view.go new file mode 100644 index 000000000..878fbf36a --- /dev/null +++ b/tui/screens/down/view.go @@ -0,0 +1,261 @@ +package down + +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, height) + return page.Render(m.theme, width, height, "Down", m.headerStatus(), body, help) +} + +func (m Model) headerStatus() string { + switch m.mode { + case modeAffirm: + return m.theme.Muted.Render("step · destroy Affirm") + case modeDestroying: + return m.theme.Muted.Render("destroying…") + case modeComplete: + if m.err != nil { + return m.theme.Danger.Render("failed") + } + return m.theme.Success.Render("destroyed") + default: + return m.theme.Muted.Render("step 1 · mode") + } +} + +func (m Model) bodyAndHelp(width, height int) (string, string) { + switch m.mode { + case modeAffirm: + lines := []string{ + m.theme.Muted.Render(AffirmMessage()), + m.theme.Muted.Render("Same Affirm as plural down (PLURAL_DOWN_AFFIRM_DESTROY)."), + "", + "Mode " + m.modeLabel(), + m.theme.Muted.Render(" " + m.cli()), + "", + } + lines = append(lines, m.affirmLines(width)...) + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + return page.Panel(m.theme, "Destroy", lines, width, 14, true), "↑/↓ · y/n · enter · esc back" + case modeDestroying: + panelH, logN := logPanelBudget(height, 5) + lines := make([]string, 0, 5+logN) + lines = append(lines, + "Mode "+m.modeLabel(), + "", + m.spin.View()+" "+m.theme.Muted.Render("Destroying management cluster terraform…"), + m.theme.Muted.Render("Terraform output streams below (TUI stays open)."), + "", + ) + lines = append(lines, m.opLogLines(logN, width)...) + return page.Panel(m.theme, "Destroying", lines, width, panelH, true), "↑/↓ · pgup/pgdn scroll · end follow" + case modeComplete: + panelH, logN := logPanelBudget(height, 6) + lines := []string{} + if m.err != nil { + lines = append(lines, + m.theme.Danger.Render("Destroy failed — scroll logs below, then enter/esc welcome"), + m.theme.Danger.Render(m.err.Error()), + "", + ) + } else { + lines = append(lines, + m.theme.Success.Render("✓ Management cluster destroy finished"), + m.theme.Muted.Render("Scroll logs below · enter/esc welcome"), + "", + ) + } + lines = append(lines, m.opLogExportHint(width)) + lines = append(lines, m.opLogScrollHint(width)) + lines = append(lines, m.opLogLines(logN, width)...) + return page.Panel(m.theme, "Destroy complete", lines, width, panelH, true), "↑/↓ scroll · e export logs · enter/esc welcome" + default: + intro := make([]string, 0, 4+len(cloudOptions())) + intro = append(intro, + m.theme.Muted.Render("Destroys your management cluster and any apps installed on it."), + m.theme.Muted.Render("Same as plural down — requires workspace.yaml in the current repo."), + "", + ) + intro = append(intro, m.cloudLines(width)...) + if m.err != nil { + intro = append(intro, "", m.theme.Danger.Render(m.err.Error())) + } + help := "↑/↓ · 1–2 / s/c · enter · esc welcome" + if width < 100 { + help = "↑/↓ · enter · esc welcome" + } + return page.Panel(m.theme, "Destroy mode", intro, width, 12, true), help + } +} + +func (m Model) cloudLines(width int) []string { + opts := cloudOptions() + lines := make([]string, 0, len(opts)) + for i, o := range opts { + prefix := " " + label := fmt.Sprintf("%d %s %-14s %s", i+1, cloudShortcut(o.id), o.title, o.blurb) + if i == m.cursor { + prefix = "› " + label = m.theme.Title.Render(label) + } else { + label = m.theme.Body.Render(fmt.Sprintf("%d %s ", i+1, cloudShortcut(o.id))) + + m.theme.Body.Render(fmt.Sprintf("%-14s ", o.title)) + + m.theme.Muted.Render(o.blurb) + } + line := prefix + label + lines = append(lines, ansi.Truncate(line, max(1, width-4), "…")) + } + return lines +} + +func (m Model) affirmLines(width int) []string { + opts := affirmOptions() + lines := make([]string, 0, len(opts)) + for i, o := range opts { + prefix := " " + label := m.theme.Body.Render(o.title) + " " + m.theme.Muted.Render(o.blurb) + if i == m.cursor { + prefix = "› " + label = m.theme.Title.Render(o.title) + " " + m.theme.Muted.Render(o.blurb) + } + lines = append(lines, ansi.Truncate(prefix+label, max(1, width-4), "…")) + } + return lines +} + +func (m Model) modeLabel() string { + if m.cloud { + return "Plural Cloud" + } + return "Self-hosted" +} + +func (m Model) cli() string { + if m.cloud { + return "plural down --cloud" + } + return "plural down" +} + +func (m Model) opLogLines(limit, width int) []string { + if limit <= 0 { + limit = 12 + } + if len(m.opLog) == 0 { + return []string{m.theme.Muted.Render("Waiting for output…")} + } + wrapped := wrapOpLog(m.opLog, width) + start := m.opLogStart(limit, len(wrapped)) + end := start + limit + if end > len(wrapped) { + end = len(wrapped) + } + out := make([]string, 0, end-start) + for _, line := range wrapped[start:end] { + out = append(out, m.theme.Muted.Render(line)) + } + return out +} + +// opLogInnerWidth is the text width inside a page.Panel (borders + padding). +func opLogInnerWidth(panelWidth int) int { + return max(20, panelWidth-4) +} + +func wrapOpLog(lines []string, width int) []string { + maxW := opLogInnerWidth(width) + out := make([]string, 0, len(lines)) + for _, line := range lines { + if line == "" { + out = append(out, "") + continue + } + out = append(out, strings.Split(ansi.Wrap(line, maxW, ""), "\n")...) + } + return out +} + +func opLogContentWidth(termWidth int) int { + if termWidth <= 0 { + termWidth = page.DefaultWidth + } + return page.ContentWidth(termWidth) +} + +func (m Model) opLogStart(limit, total int) int { + if total == 0 { + return 0 + } + maxStart := max(0, total-limit) + if m.opLogFollow { + return maxStart + } + if m.opLogY < 0 { + return 0 + } + if m.opLogY > maxStart { + return maxStart + } + return m.opLogY +} + +func (m Model) opLogScrollHint(width int) string { + if len(m.opLog) == 0 { + return m.theme.Muted.Render("No log lines captured.") + } + limit := 12 + if m.viewH > 0 { + _, limit = logPanelBudget(m.viewH, 5) + } + wrapped := wrapOpLog(m.opLog, width) + start := m.opLogStart(limit, len(wrapped)) + end := start + limit + if end > len(wrapped) { + end = len(wrapped) + } + label := fmt.Sprintf("Logs %d–%d / %d", start+1, end, len(wrapped)) + if m.opLogFollow { + label += " · following" + } + return m.theme.Muted.Render(ansi.Truncate(label, max(1, width-4), "…")) +} + +func (m Model) opLogExportHint(width int) string { + if m.logExportErr != nil { + return m.theme.Danger.Render(ansi.Truncate("Could not save logs: "+m.logExportErr.Error()+" · e retry", max(1, width-4), "…")) + } + if m.logExportPath != "" { + return m.theme.Muted.Render(ansi.Truncate("Saved "+m.logExportPath+" · e to save again", max(1, width-4), "…")) + } + return m.theme.Muted.Render("e exports full logs to a file (ctrl+c quits the TUI)") +} + +// logPanelBudget sizes the streaming log panel to fill most of the terminal. +func logPanelBudget(termHeight, chrome int) (panelHeight, logLines int) { + panelHeight = termHeight - 6 + if panelHeight < 18 { + panelHeight = 18 + } + if chrome < 0 { + chrome = 0 + } + logLines = panelHeight - 2 - chrome + if logLines < 12 { + logLines = 12 + } + return panelHeight, logLines +} diff --git a/tui/screens/edge/model.go b/tui/screens/edge/model.go new file mode 100644 index 000000000..c2b2695de --- /dev/null +++ b/tui/screens/edge/model.go @@ -0,0 +1,677 @@ +// Package edge implements the TUI wizard for plural edge image and flash. +package edge + +import ( + "context" + "strings" + "time" + + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + edgebridge "github.com/pluralsh/plural-cli/pkg/bridge/edge" + pkgedge "github.com/pluralsh/plural-cli/pkg/edge" + "github.com/pluralsh/plural-cli/tui/components/oplog" + 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 ( + modeHub mode = iota + modeImageForm + modeImageReview + modeImageRunning + modeImageResult + modeFlashForm + modeFlashDevices + modeFlashDevicePath + modeFlashConfirm + modeFlashRunning + modeFlashResult +) + +type field struct { + key, label, placeholder string + password bool +} + +type doneMsg struct { + err error + request uint64 + kind string +} + +func imageFields() []field { + return []field{ + {key: "output-dir", label: "Output directory", placeholder: "image"}, + {key: "project", label: "Project", placeholder: "default"}, + {key: "model", label: "Board model", placeholder: "rpi5"}, + {key: "username", label: "Username", placeholder: "plural"}, + {key: "password", label: "Password", placeholder: "required unless cloud-config is set", password: true}, + {key: "user", label: "User email", placeholder: "optional bootstrap token identity"}, + {key: "wifi-ssid", label: "Wi-Fi SSID", placeholder: "optional"}, + {key: "wifi-password", label: "Wi-Fi password", placeholder: "optional", password: true}, + {key: "cloud-config", label: "Cloud config path", placeholder: "optional; skips Console templating"}, + {key: "plural-config", label: "Plural config path", placeholder: "optional"}, + {key: "oci-url", label: "OCI push URL", placeholder: "optional"}, + } +} + +func flashFields() []field { + return []field{ + {key: "image", label: "Image file", placeholder: "path to kairos.img"}, + {key: "device", label: "Storage device", placeholder: "/dev/sdX"}, + } +} + +// Model is the Edge screen: image build and flash. +type Model struct { + ctx context.Context + loader edgebridge.Loader + theme theme.Theme + mode mode + cursor int + field int + inputs []textinput.Model + err error + needsAuth bool + request uint64 + opLog []string + opLogCh chan string + opLogY int + opLogFollow bool + viewH int + viewW int + spin spinner.Model + exportDir string + logExportPath string + logExportErr error + listDevices func() ([]pkgedge.FlashDevice, error) + findImage func() string + devices []pkgedge.FlashDevice + deviceCursor int + deviceErr error + deviceCustom bool + imageSuggested bool + flashWritten int64 + flashTotal int64 + flashStarted time.Time + progressCh chan flashProgressMsg +} + +func New(ctx context.Context, loader edgebridge.Loader, t theme.Theme) Model { + return Model{ + ctx: ctx, + loader: loader, + theme: t, + spin: pluralspinner.New(t), + opLogFollow: true, + } +} + +func (m Model) Init() tea.Cmd { return nil } + +func newInputs(t theme.Theme, fields []field) []textinput.Model { + styles := textinput.DefaultDarkStyles() + styles.Focused.Text, styles.Focused.Prompt, styles.Focused.Placeholder = t.Body, t.Title, t.Muted + styles.Blurred = styles.Focused + inputs := make([]textinput.Model, len(fields)) + for i, field := range fields { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = field.placeholder + input.CharLimit = 512 + input.SetStyles(styles) + if field.password { + input.EchoMode = textinput.EchoPassword + } + switch field.key { + case "output-dir": + input.SetValue("image") + case "project": + input.SetValue("default") + case "model": + input.SetValue("rpi5") + case "username": + input.SetValue("plural") + } + inputs[i] = input + } + if len(inputs) > 0 { + inputs[0].Focus() + } + return inputs +} + +func (m Model) startImage() Model { + m.mode = modeImageForm + m.err = nil + m.needsAuth = false + m.field = 0 + m.inputs = newInputs(m.theme, imageFields()) + return m +} + +func (m Model) startFlash() Model { + m.mode = modeFlashForm + m.err = nil + m.needsAuth = false + m.field = 0 + m.inputs = newInputs(m.theme, flashFields()) + m.devices = nil + m.deviceCursor = 0 + m.deviceErr = nil + m.deviceCustom = false + m.imageSuggested = false + if path := m.defaultFlashImage(); path != "" && len(m.inputs) > 0 { + m.inputs[0].SetValue(path) + m.imageSuggested = true + } + return m +} + +func (m Model) defaultFlashImage() string { + if m.findImage != nil { + return m.findImage() + } + return pkgedge.DefaultFlashImage("") +} + +func (m Model) imageOptions() pkgedge.ImageOptions { + value := func(key string) string { + for i, field := range imageFields() { + if field.key == key && i < len(m.inputs) { + return strings.TrimSpace(m.inputs[i].Value()) + } + } + return "" + } + return pkgedge.ImageOptions{ + OutputDir: value("output-dir"), + Project: value("project"), + User: value("user"), + PluralConfig: value("plural-config"), + CloudConfig: value("cloud-config"), + Username: value("username"), + Password: value("password"), + WifiSSID: value("wifi-ssid"), + WifiPassword: value("wifi-password"), + Model: value("model"), + OCIURL: value("oci-url"), + } +} + +func (m Model) flashOptions() pkgedge.FlashOptions { + value := func(key string) string { + for i, field := range flashFields() { + if field.key == key && i < len(m.inputs) { + return strings.TrimSpace(m.inputs[i].Value()) + } + } + return "" + } + return pkgedge.FlashOptions{Image: value("image"), Device: value("device")} +} + +func (m *Model) prepareRun(kind string) { + if kind == "flash" { + m.mode = modeFlashRunning + } else { + m.mode = modeImageRunning + } + m.err = nil + m.needsAuth = false + m.opLog = nil + m.opLogFollow = true + m.opLogY = 0 + m.logExportPath = "" + m.logExportErr = nil + m.flashWritten = 0 + m.flashTotal = 0 + m.flashStarted = time.Time{} + m.progressCh = nil + m.request++ +} + +func (m *Model) beginImage() tea.Cmd { + m.prepareRun("image") + lines := make(chan string, 4096) + m.opLogCh = lines + return tea.Batch(m.spin.Tick, m.imageWorkCmd(lines), listenOpLog(lines)) +} + +func (m *Model) beginFlash() tea.Cmd { + m.prepareRun("flash") + lines := make(chan string, 4096) + progress := make(chan flashProgressMsg, 8) + m.opLogCh = lines + m.progressCh = progress + return tea.Batch(m.spin.Tick, m.flashWorkCmd(lines, progress), listenOpLog(lines), listenFlashProgress(progress)) +} + +func (m Model) imageWorkCmd(lines chan string) tea.Cmd { + request, loader, ctx, options := m.request, m.loader, m.ctx, m.imageOptions() + return func() tea.Msg { + var err error + if loader != nil { + err = loader.BuildImage(ctx, options, func(line string) { sendLog(lines, line) }) + } + if lines != nil { + close(lines) + } + return doneMsg{err: err, request: request, kind: "image"} + } +} + +func (m Model) flashWorkCmd(lines chan string, progress chan flashProgressMsg) tea.Cmd { + request, loader, ctx, options := m.request, m.loader, m.ctx, m.flashOptions() + options.OnProgress = func(written, total int64) { + sendFlashProgress(progress, flashProgressMsg{written: written, total: total}) + } + return func() tea.Msg { + var err error + if loader != nil { + err = loader.Flash(ctx, options, func(line string) { sendLog(lines, line) }) + } + if lines != nil { + close(lines) + } + if progress != nil { + close(progress) + } + return doneMsg{err: err, request: request, kind: "flash"} + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case doneMsg: + if msg.request != m.request { + return m, nil + } + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + m.opLogCh = nil + m.progressCh = nil + m.opLogFollow = true + if msg.kind == "flash" { + m.mode = modeFlashResult + } else { + m.mode = modeImageResult + } + if msg.err != nil { + m.saveLogs(msg.kind) + } + return m, nil + case opLogLineMsg: + m.opLog = appendOpLog(m.opLog, msg.line) + if m.opLogCh != nil { + return m, tea.Batch(m.spin.Tick, listenOpLog(m.opLogCh)) + } + return m, nil + case flashProgressMsg: + if m.flashStarted.IsZero() { + m.flashStarted = time.Now() + } + m.flashWritten, m.flashTotal = msg.written, msg.total + cmds := []tea.Cmd{m.spin.Tick} + if m.progressCh != nil { + cmds = append(cmds, listenFlashProgress(m.progressCh)) + } + return m, tea.Batch(cmds...) + case tea.WindowSizeMsg: + m.viewH = msg.Height + m.viewW = msg.Width + return m, nil + case spinner.TickMsg: + if m.mode != modeImageRunning && m.mode != modeFlashRunning { + return m, nil + } + var cmd tea.Cmd + m.spin, cmd = m.spin.Update(msg) + return m, cmd + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if (m.mode == modeImageForm || m.mode == modeFlashForm || m.mode == modeFlashDevicePath) && len(m.inputs) > 0 { + var cmd tea.Cmd + m.inputs[m.field], cmd = m.inputs[m.field].Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + stroke := key.Keystroke() + switch m.mode { + case modeHub: + if stroke == "esc" { + return m, navigation.Navigate(navigation.Welcome) + } + if m.needsAuth && stroke == "c" { + return m, navigation.Navigate(navigation.Access) + } + switch stroke { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < 1 { + m.cursor++ + } + case "enter": + if m.cursor == 0 { + return m.startImage(), nil + } + return m.startFlash(), nil + case "1": + return m.startImage(), nil + case "2": + return m.startFlash(), nil + } + return m, nil + case modeImageForm: + return m.updateForm(key, imageFields(), modeHub, modeImageReview) + case modeImageReview: + if stroke == "esc" { + m.mode = modeImageForm + m.field = len(m.inputs) - 1 + if m.field >= 0 { + m.inputs[m.field].Focus() + } + return m, nil + } + if stroke == "enter" { + return m, m.beginImage() + } + return m, nil + case modeFlashForm: + return m.updateFlashImage(key) + case modeFlashDevices: + return m.updateFlashDevices(key) + case modeFlashDevicePath: + return m.updateFlashDevicePath(key) + case modeFlashConfirm: + if stroke == "esc" { + if m.deviceCustom { + m.mode = modeFlashDevicePath + m.field = 1 + if len(m.inputs) > 1 { + m.inputs[1].Focus() + } + return m, nil + } + m.mode = modeFlashDevices + return m, nil + } + if stroke == "enter" { + opts := m.flashOptions() + if opts.Image == "" || opts.Device == "" { + return m, nil + } + return m, m.beginFlash() + } + return m, nil + case modeImageRunning, modeFlashRunning: + m.handleOpLogScroll(stroke) + return m, nil + case modeImageResult, modeFlashResult: + if m.handleOpLogScroll(stroke) { + return m, nil + } + if stroke == "e" { + kind := "image" + if m.mode == modeFlashResult { + kind = "flash" + } + m.saveLogs(kind) + return m, nil + } + if m.needsAuth && stroke == "c" { + return m, navigation.Navigate(navigation.Access) + } + if stroke == "esc" || stroke == "enter" { + m.mode = modeHub + m.err = nil + m.needsAuth = false + m.opLog = nil + m.opLogFollow = true + m.opLogY = 0 + m.logExportPath = "" + m.logExportErr = nil + } + return m, nil + } + return m, nil +} + +func (m Model) updateForm(key tea.KeyPressMsg, fields []field, back, next mode) (Model, tea.Cmd) { + stroke := key.Keystroke() + switch stroke { + case "esc": + if m.field == 0 { + m.mode = back + m.inputs = nil + return m, nil + } + m.inputs[m.field].Blur() + m.field-- + m.inputs[m.field].Focus() + return m, nil + case "enter": + m.inputs[m.field].Blur() + if m.field+1 < len(fields) { + m.field++ + m.inputs[m.field].Focus() + return m, nil + } + if next == modeFlashConfirm { + opts := m.flashOptions() + if opts.Image == "" || opts.Device == "" { + m.inputs[m.field].Focus() + return m, nil + } + } + m.mode = next + return m, nil + } + if len(m.inputs) == 0 { + return m, nil + } + var cmd tea.Cmd + m.inputs[m.field], cmd = m.inputs[m.field].Update(key) + return m, cmd +} + +func (m Model) updateFlashImage(key tea.KeyPressMsg) (Model, tea.Cmd) { + stroke := key.Keystroke() + switch stroke { + case "esc": + m.mode = modeHub + m.inputs = nil + return m, nil + case "enter": + if strings.TrimSpace(m.inputs[0].Value()) == "" { + return m, nil + } + m.inputs[0].Blur() + return m.showDevices(), nil + } + var cmd tea.Cmd + m.inputs[0], cmd = m.inputs[0].Update(key) + return m, cmd +} + +func (m Model) showDevices() Model { + m.mode = modeFlashDevices + m.deviceCustom = false + m.deviceCursor = 0 + list := m.listDevices + if list == nil { + list = pkgedge.ListFlashDevices + } + m.devices, m.deviceErr = list() + return m +} + +func (m Model) flashDeviceCount() int { + return len(m.devices) + 1 +} + +func (m Model) updateFlashDevices(key tea.KeyPressMsg) (Model, tea.Cmd) { + stroke := key.Keystroke() + n := m.flashDeviceCount() + switch stroke { + case "esc": + m.mode = modeFlashForm + m.field = 0 + if len(m.inputs) > 0 { + m.inputs[0].Focus() + } + return m, nil + case "up", "k": + if m.deviceCursor > 0 { + m.deviceCursor-- + } + return m, nil + case "down", "j": + if m.deviceCursor < n-1 { + m.deviceCursor++ + } + return m, nil + case "r": + return m.showDevices(), nil + case "enter": + return m.pickFlashDevice(m.deviceCursor) + } + if len(stroke) == 1 && stroke[0] >= '1' && stroke[0] <= '9' { + idx := int(stroke[0] - '1') + if idx < n { + return m.pickFlashDevice(idx) + } + } + return m, nil +} + +func (m Model) pickFlashDevice(index int) (Model, tea.Cmd) { + if index < 0 || index >= m.flashDeviceCount() { + return m, nil + } + if index == len(m.devices) { + m.mode = modeFlashDevicePath + m.deviceCustom = true + m.field = 1 + if len(m.inputs) > 1 { + m.inputs[1].SetValue("") + m.inputs[1].Focus() + } + return m, nil + } + m.deviceCustom = false + if len(m.inputs) > 1 { + m.inputs[1].SetValue(m.devices[index].Path) + m.inputs[1].Blur() + } + m.mode = modeFlashConfirm + return m, nil +} + +func (m Model) updateFlashDevicePath(key tea.KeyPressMsg) (Model, tea.Cmd) { + stroke := key.Keystroke() + switch stroke { + case "esc": + return m.showDevices(), nil + case "enter": + if strings.TrimSpace(m.inputs[1].Value()) == "" { + return m, nil + } + m.inputs[1].Blur() + m.mode = modeFlashConfirm + return m, nil + } + var cmd tea.Cmd + m.inputs[1], cmd = m.inputs[1].Update(key) + return m, cmd +} + +func (m Model) selectedFlashDevice() (pkgedge.FlashDevice, bool) { + path := m.flashOptions().Device + for _, device := range m.devices { + if device.Path == path { + return device, true + } + } + return pkgedge.FlashDevice{}, false +} + +func (m Model) flashNeedsRoot() bool { + if picked, ok := m.selectedFlashDevice(); ok { + return picked.NeedsRoot + } + path := m.flashOptions().Device + if path == "" { + return false + } + return !pkgedge.DeviceWritable(path) +} + +func (m *Model) handleOpLogScroll(stroke string) bool { + window := 20 + if m.viewH > 0 { + _, window = logPanelBudget(m.viewH, 4) + } + switch stroke { + case "up", "k": + m.scrollOpLog(-1, window) + return true + case "down", "j": + m.scrollOpLog(1, window) + return true + case "pgup": + m.scrollOpLog(-window, window) + return true + case "pgdown": + m.scrollOpLog(window, window) + return true + case "home": + m.opLogFollow = false + m.opLogY = 0 + return true + case "end": + m.opLogFollow = true + return true + } + return false +} + +func (m *Model) scrollOpLog(delta, window int) { + if window <= 0 { + window = 20 + } + total := len(wrapOpLog(m.opLog, opLogContentWidth(m.viewW))) + maxStart := max(0, total-window) + if m.opLogFollow { + m.opLogY = maxStart + } + m.opLogFollow = false + m.opLogY += delta + if m.opLogY < 0 { + m.opLogY = 0 + } + if m.opLogY >= maxStart { + m.opLogY = maxStart + m.opLogFollow = true + } +} + +func (m *Model) saveLogs(kind string) { + path, err := oplog.Write(m.exportDir, "edge-"+kind, m.opLog, m.err) + m.logExportPath = path + m.logExportErr = err +} diff --git a/tui/screens/edge/model_test.go b/tui/screens/edge/model_test.go new file mode 100644 index 000000000..1ff829298 --- /dev/null +++ b/tui/screens/edge/model_test.go @@ -0,0 +1,346 @@ +package edge + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/pkg/bridge" + pkgedge "github.com/pluralsh/plural-cli/pkg/edge" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + image pkgedge.ImageOptions + flash pkgedge.FlashOptions +} + +func (f *fakeLoader) BuildImage(_ context.Context, options pkgedge.ImageOptions, log func(string)) error { + f.image = options + if log != nil { + log("reading configuration") + log("preparing output directory") + } + return nil +} +func (f *fakeLoader) Flash(_ context.Context, options pkgedge.FlashOptions, log func(string)) error { + f.flash = options + if log != nil { + log("flashing " + options.Image + " onto " + options.Device) + } + return nil +} + +func drainWork(t *testing.T, model Model) Model { + t.Helper() + var msg tea.Msg + switch model.mode { + case modeImageRunning: + msg = model.imageWorkCmd(nil)() + case modeFlashRunning: + msg = model.flashWorkCmd(nil, nil)() + default: + t.Fatalf("expected running, got %d", model.mode) + } + model, _ = model.Update(msg) + return model +} + +func TestHubOpensImageAndFlash(t *testing.T) { + model := New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII)) + got := normalizeView(model.View(80, 24)) + if !strings.Contains(got, "Edge commands") || !strings.Contains(got, "image") || !strings.Contains(got, "flash") { + t.Fatalf("hub missing commands:\n%s", got) + } + model, _ = model.Update(tea.KeyPressMsg{Code: '1', Text: "1"}) + if model.mode != modeImageForm { + t.Fatalf("mode = %d", model.mode) + } + got = normalizeView(model.View(80, 24)) + if !strings.Contains(got, "plural edge image") { + t.Fatalf("image form missing CLI hint:\n%s", got) + } + model = New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII)) + model, _ = model.Update(tea.KeyPressMsg{Code: '2', Text: "2"}) + if model.mode != modeFlashForm { + t.Fatalf("mode = %d", model.mode) + } +} + +func TestFlashPrefillsLocalKairosImage(t *testing.T) { + path := "/work/edge/image/kairos.img" + model := New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII)) + model.findImage = func() string { return path } + model, _ = model.Update(tea.KeyPressMsg{Code: '2', Text: "2"}) + if model.inputs[0].Value() != path || !model.imageSuggested { + t.Fatalf("image = %q suggested=%v", model.inputs[0].Value(), model.imageSuggested) + } + got := normalizeView(model.View(80, 24)) + if !strings.Contains(got, path) || !strings.Contains(got, "Found image/kairos.img") { + t.Fatalf("form missing suggested image:\n%s", got) + } +} + +func TestImageReviewQueuesBuild(t *testing.T) { + loader := &fakeLoader{} + model := New(t.Context(), loader, theme.New(colorprofile.ASCII)) + model, _ = model.Update(tea.KeyPressMsg{Code: '1', Text: "1"}) + for i := 0; i < len(imageFields()); i++ { + if imageFields()[i].key == "password" { + model.inputs[i].SetValue("secret") + } + if imageFields()[i].key == "cloud-config" { + model.inputs[i].SetValue("/tmp/cloud.yaml") + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + } + if model.mode != modeImageReview { + t.Fatalf("expected review, got %d", model.mode) + } + got := normalizeView(model.View(80, 24)) + if !strings.Contains(got, "image") || strings.Contains(got, "secret") { + t.Fatalf("review should hide password:\n%s", got) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("review did not start build") + } + if model.mode != modeImageRunning { + t.Fatalf("expected running, got %d", model.mode) + } + got = normalizeView(model.View(80, 24)) + if !strings.Contains(got, "Running plural edge image") || !strings.Contains(got, "streams below") { + t.Fatalf("running view should look like terraform logs:\n%s", got) + } + model = drainWork(t, model) + if model.mode != modeImageResult || loader.image.Password != "secret" || loader.image.CloudConfig != "/tmp/cloud.yaml" { + t.Fatalf("build options = %#v mode=%d", loader.image, model.mode) + } +} + +func TestFlashRequiresConfirm(t *testing.T) { + loader := &fakeLoader{} + model := New(t.Context(), loader, theme.New(colorprofile.ASCII)) + model.listDevices = func() ([]pkgedge.FlashDevice, error) { + return []pkgedge.FlashDevice{{Path: "/dev/sdb", Model: "SanDisk Ultra", Size: 16 << 30}}, nil + } + model, _ = model.Update(tea.KeyPressMsg{Code: '2', Text: "2"}) + model.inputs[0].SetValue("kairos.img") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeFlashDevices { + t.Fatalf("mode = %d", model.mode) + } + got := normalizeView(model.View(80, 24)) + if !strings.Contains(got, "/dev/sdb") || !strings.Contains(got, "SanDisk Ultra") { + t.Fatalf("device picker missing USB disk:\n%s", got) + } + model, _ = model.Update(tea.KeyPressMsg{Code: '1', Text: "1"}) + if model.mode != modeFlashConfirm { + t.Fatalf("mode = %d", model.mode) + } + got = normalizeView(model.View(80, 24)) + if !strings.Contains(got, "overwrites") || !strings.Contains(got, "/dev/sdb") { + t.Fatalf("confirm missing warning:\n%s", got) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("confirm did not start flash") + } + if model.mode != modeFlashRunning { + t.Fatalf("expected running, got %d", model.mode) + } + model = drainWork(t, model) + if loader.flash.Image != "kairos.img" || loader.flash.Device != "/dev/sdb" { + t.Fatalf("flash options = %#v", loader.flash) + } +} + +func TestFlashConfirmWarnsNeedsRoot(t *testing.T) { + model := New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII)) + model.listDevices = func() ([]pkgedge.FlashDevice, error) { + return []pkgedge.FlashDevice{{Path: "/dev/sdb", Model: "SanDisk Ultra", Size: 16 << 30, NeedsRoot: true}}, nil + } + model, _ = model.Update(tea.KeyPressMsg{Code: '2', Text: "2"}) + model.inputs[0].SetValue("kairos.img") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + got := normalizeView(model.View(80, 24)) + if !strings.Contains(got, "needs root") { + t.Fatalf("picker should mark needs root:\n%s", got) + } + model, _ = model.Update(tea.KeyPressMsg{Code: '1', Text: "1"}) + got = normalizeView(model.View(80, 24)) + if !strings.Contains(got, "Needs root") || !strings.Contains(got, "sudo plural tui") { + t.Fatalf("confirm should warn about root:\n%s", got) + } +} + +func TestFlashEmptyDeviceStaysOnForm(t *testing.T) { + model := New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII)) + model.listDevices = func() ([]pkgedge.FlashDevice, error) { return nil, nil } + model, _ = model.Update(tea.KeyPressMsg{Code: '2', Text: "2"}) + model.inputs[0].SetValue("kairos.img") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeFlashDevices { + t.Fatalf("mode = %d", model.mode) + } + got := normalizeView(model.View(80, 24)) + if !strings.Contains(got, "No USB disks found") || !strings.Contains(got, "Enter path") { + t.Fatalf("empty USB list:\n%s", got) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeFlashDevicePath { + t.Fatalf("mode = %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeFlashDevicePath { + t.Fatalf("empty device continued to %d", model.mode) + } +} + +func TestFlashCustomPath(t *testing.T) { + loader := &fakeLoader{} + model := New(t.Context(), loader, theme.New(colorprofile.ASCII)) + model.listDevices = func() ([]pkgedge.FlashDevice, error) { + return []pkgedge.FlashDevice{{Path: "/dev/sdb"}}, nil + } + model, _ = model.Update(tea.KeyPressMsg{Code: '2', Text: "2"}) + model.inputs[0].SetValue("kairos.img") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(tea.KeyPressMsg{Code: '2', Text: "2"}) + if model.mode != modeFlashDevicePath { + t.Fatalf("mode = %d", model.mode) + } + model.inputs[1].SetValue("/dev/sdc") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeFlashConfirm || model.flashOptions().Device != "/dev/sdc" { + t.Fatalf("custom path = %#v mode=%d", model.flashOptions(), model.mode) + } +} + +func TestImageUnauthenticatedOffersAccess(t *testing.T) { + loader := &errLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect a Console profile")}} + model := New(t.Context(), loader, theme.New(colorprofile.ASCII)) + model, _ = model.Update(tea.KeyPressMsg{Code: '1', Text: "1"}) + for i := 0; i < len(imageFields()); i++ { + if imageFields()[i].key == "password" { + model.inputs[i].SetValue("secret") + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("review did not start build") + } + model = drainWork(t, model) + if !model.needsAuth || model.mode != modeImageResult { + t.Fatalf("needsAuth=%v mode=%d", model.needsAuth, model.mode) + } + got := normalizeView(model.View(80, 24)) + if !strings.Contains(got, "Press c to open Access") { + t.Fatalf("missing connect hint:\n%s", got) + } + _, cmd = model.Update(tea.KeyPressMsg{Code: 'c', Text: "c"}) + if cmd == nil { + t.Fatal("c did not navigate") + } + if got := cmd().(navigation.NavigateMsg).Route; got != navigation.Access { + t.Fatalf("route = %s", got) + } +} + +type errLoader struct{ err error } + +func (e *errLoader) BuildImage(context.Context, pkgedge.ImageOptions, func(string)) error { + return e.err +} +func (e *errLoader) Flash(context.Context, pkgedge.FlashOptions, func(string)) error { + return e.err +} + +func TestHubEscReturnsToWelcome(t *testing.T) { + model := New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII)) + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil { + t.Fatal("esc did not navigate") + } +} + +func TestImageRunningKeepsLogsInPanel(t *testing.T) { + model := New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII)) + model.mode = modeImageRunning + model.viewH = 24 + model, _ = model.Update(opLogLineMsg{line: "writing plural-bundle bundle"}) + got := normalizeView(model.View(80, 24)) + if !strings.Contains(got, "Building") || !strings.Contains(got, "writing plural-bundle bundle") { + t.Fatalf("expected terraform-style log panel:\n%s", got) + } +} + +func TestFlashRunningShowsProgressBar(t *testing.T) { + model := New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII)) + model.mode = modeFlashRunning + model.flashWritten = 6 << 30 + model.flashTotal = 10 << 30 + model.flashStarted = time.Now().Add(-3 * time.Second) + model, _ = model.Update(flashProgressMsg{written: 6 << 30, total: 10 << 30}) + got := normalizeView(model.View(80, 24)) + if !strings.Contains(got, "Flashing") || !strings.Contains(got, "60%") { + t.Fatalf("missing percent:\n%s", got) + } + if !strings.Contains(got, "█") || !strings.Contains(got, "░") { + t.Fatalf("missing progress bar:\n%s", got) + } + if strings.Contains(got, "bytes (") || strings.Contains(got, "copied,") { + t.Fatalf("raw dd output leaked into the flash window:\n%s", got) + } + if !strings.Contains(got, "6.0 GB") || !strings.Contains(got, "10.0 GB") { + t.Fatalf("missing size label:\n%s", got) + } +} + +func TestImageResultKeepsLogs(t *testing.T) { + model := New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII)) + model.exportDir = t.TempDir() + model.mode = modeImageResult + model.opLog = []string{"reading configuration", "image saved to image directory"} + got := normalizeView(model.View(80, 24)) + if !strings.Contains(got, "Image complete") || !strings.Contains(got, "reading configuration") { + t.Fatalf("result should keep logs:\n%s", got) + } + if !strings.Contains(got, "Scroll logs") { + t.Fatalf("missing scroll hint:\n%s", got) + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'e', Text: "e"}) + if model.logExportPath == "" || model.logExportErr != nil { + t.Fatalf("export path=%q err=%v", model.logExportPath, model.logExportErr) + } +} + +func TestOpLogLinesUsePanelWidth(t *testing.T) { + model := New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII)) + model.mode = modeImageResult + long := strings.Repeat("abcdefghij", 20) + model.opLog = []string{long} + view := ansi.Strip(model.View(160, 24)) + if !strings.Contains(view, strings.Repeat("abcdefghij", 12)) { + t.Fatalf("expected wrapped log to keep the start of the line, got:\n%s", view) + } + if strings.Contains(view, long) { + t.Fatal("expected wrap so the 200-char line is not a single row") + } +} + +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/edge/stream.go b/tui/screens/edge/stream.go new file mode 100644 index 000000000..7ee1c17e6 --- /dev/null +++ b/tui/screens/edge/stream.go @@ -0,0 +1,67 @@ +package edge + +import tea "charm.land/bubbletea/v2" + +const maxOpLogLines = 500 + +type opLogLineMsg struct{ line string } + +type flashProgressMsg struct { + written, total int64 +} + +func listenOpLog(ch <-chan string) tea.Cmd { + return func() tea.Msg { + line, ok := <-ch + if !ok { + return nil + } + return opLogLineMsg{line: line} + } +} + +func listenFlashProgress(ch <-chan flashProgressMsg) tea.Cmd { + return func() tea.Msg { + msg, ok := <-ch + if !ok { + return nil + } + return msg + } +} + +func appendOpLog(lines []string, line string) []string { + lines = append(lines, line) + if len(lines) > maxOpLogLines { + lines = lines[len(lines)-maxOpLogLines:] + } + return lines +} + +func sendLog(ch chan<- string, line string) { + if ch == nil || line == "" { + return + } + select { + case ch <- line: + default: + } +} + +func sendFlashProgress(ch chan flashProgressMsg, msg flashProgressMsg) { + if ch == nil { + return + } + select { + case ch <- msg: + default: + select { + case <-ch: + default: + } + select { + case ch <- msg: + default: + } + } +} diff --git a/tui/screens/edge/view.go b/tui/screens/edge/view.go new file mode 100644 index 000000000..a6ea80046 --- /dev/null +++ b/tui/screens/edge/view.go @@ -0,0 +1,445 @@ +package edge + +import ( + "fmt" + "strings" + "time" + + "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), height) + return page.Render(m.theme, width, height, m.title(), m.status(), body, help) +} + +func (m Model) title() string { + switch m.mode { + case modeImageForm, modeImageReview, modeImageRunning, modeImageResult: + return "Edge · image" + case modeFlashForm, modeFlashDevices, modeFlashDevicePath, modeFlashConfirm, modeFlashRunning, modeFlashResult: + return "Edge · flash" + default: + return "Edge" + } +} + +func (m Model) status() string { + if m.mode == modeImageRunning { + return m.theme.Warning.Render("◌ building image") + } + if m.mode == modeFlashRunning { + return m.theme.Warning.Render("◌ flashing") + } + if m.mode == modeImageResult || m.mode == modeFlashResult { + if m.err != nil { + return m.theme.Danger.Render("✗ failed") + } + return m.theme.Success.Render("✓ done") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + return m.theme.Muted.Render("plural edge") +} + +func (m Model) bodyAndHelp(width, height int) (string, string) { + switch m.mode { + case modeImageForm: + fields := imageFields() + field := fields[m.field] + m.inputs[m.field].SetWidth(max(8, width-8)) + return page.Panel(m.theme, field.label, []string{ + m.theme.Muted.Render(fmt.Sprintf("Same as plural edge image --%s (%d/%d)", field.key, m.field+1, len(fields))), + "", + m.inputs[m.field].View(), + }, width, 8, true), "enter next · esc back" + case modeImageReview: + opts := m.imageOptions() + lines := []string{ + "Output " + display(opts.OutputDir), + "Project " + display(opts.Project), + "Model " + display(opts.Model), + "Username " + display(opts.Username), + "Password " + secret(opts.Password), + "User " + display(opts.User), + "Wi-Fi " + display(opts.WifiSSID), + "Cloud cfg " + display(opts.CloudConfig), + "Plural cfg " + display(opts.PluralConfig), + "OCI URL " + display(opts.OCIURL), + "", + m.theme.Muted.Render("Builds a Kairos ARM image with Docker (privileged)."), + } + return page.Panel(m.theme, "Review image", lines, width, 16, true), "enter build · esc edit" + case modeImageRunning: + return m.viewRunning(width, height, "Building", "Running plural edge image…", "Docker / image output streams below (TUI stays open).") + case modeImageResult: + return m.viewResult(width, height, "Image complete", "✓ Image saved", "✗ Image build failed", "Output "+display(m.imageOptions().OutputDir)) + case modeFlashForm: + m.inputs[0].SetWidth(max(8, width-8)) + lines := []string{ + m.theme.Muted.Render("Same as plural edge flash --image (1/2)"), + "", + m.inputs[0].View(), + } + if m.imageSuggested { + lines = append(lines, "", m.theme.Muted.Render("Found image/kairos.img in the current directory.")) + } + return page.Panel(m.theme, "Image file", lines, width, 10, true), "enter next · esc back" + case modeFlashDevices: + return m.viewFlashDevices(width) + case modeFlashDevicePath: + m.inputs[1].SetWidth(max(8, width-8)) + return page.Panel(m.theme, "Storage device", []string{ + m.theme.Muted.Render("Same as plural edge flash --device (custom path)"), + "", + m.inputs[1].View(), + }, width, 8, true), "enter confirm · esc devices" + case modeFlashConfirm: + opts := m.flashOptions() + device := display(opts.Device) + if picked, ok := m.selectedFlashDevice(); ok { + device = picked.Label() + } + lines := []string{ + m.theme.Danger.Render("This overwrites the storage device."), + "", + "Image " + display(opts.Image), + "Device " + device, + "", + } + if m.flashNeedsRoot() { + lines = append(lines, + m.theme.Warning.Render("Needs root to write this device."), + m.theme.Muted.Render("Flash will try sudo/pkexec. If that fails: sudo plural tui"), + "", + ) + } + lines = append(lines, m.theme.Muted.Render("Same as plural edge flash --image --device.")) + return page.Panel(m.theme, "Confirm flash", lines, width, 12, true), "enter flash · esc edit" + case modeFlashRunning: + return m.viewFlashing(width) + case modeFlashResult: + return m.viewResult(width, height, "Flash complete", "✓ Image flashed", "✗ Flash failed", "Device "+display(m.flashOptions().Device)) + default: + lines := []string{ + m.theme.Muted.Render("Prepare a Raspberry Pi image, then write it to a disk."), + "", + } + items := []struct{ number, title, blurb string }{ + {"1", "image", "build Kairos ARM image (Docker)"}, + {"2", "flash", "write image onto a storage device"}, + } + for i, item := range items { + cursor := " " + if i == m.cursor { + cursor = "› " + } + row := fmt.Sprintf("%s%s %-8s %s", cursor, item.number, item.title, item.blurb) + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + return page.Panel(m.theme, "Edge commands", lines, width, 10, true), "↑/↓ select · enter open · 1-2 shortcut · esc welcome" + } +} + +func (m Model) viewFlashDevices(width int) (string, string) { + lines := []string{ + m.theme.Muted.Render("USB disks detected from sysfs (whole disk, not a partition)."), + "", + } + if m.deviceErr != nil { + lines = append(lines, m.theme.Danger.Render(m.deviceErr.Error()), "") + } + if len(m.devices) == 0 && m.deviceErr == nil { + lines = append(lines, m.theme.Muted.Render("No USB disks found. Plug in a stick and press r."), "") + } + for i, device := range m.devices { + label := device.Label() + if device.NeedsRoot { + label += " · needs root" + } + lines = append(lines, m.deviceRow(width, i, label)) + } + lines = append(lines, m.deviceRow(width, len(m.devices), "Enter path…")) + height := min(14, 6+m.flashDeviceCount()) + if height < 10 { + height = 10 + } + return page.Panel(m.theme, "Storage device", lines, width, height, true), "↑/↓ select · enter · 1-9 · r refresh · esc back" +} + +func (m Model) deviceRow(width, index int, label string) string { + cursor := " " + if index == m.deviceCursor { + cursor = "› " + } + row := fmt.Sprintf("%s%d %s", cursor, index+1, label) + return ansi.Truncate(row, max(1, width-2), "…") +} + +func (m Model) viewRunning(width, height int, title, status, hint string) (string, string) { + panelH, logN := logPanelBudget(height, 4) + lines := []string{ + m.spin.View() + " " + m.theme.Muted.Render(status), + "", + m.theme.Muted.Render(hint), + "", + } + lines = append(lines, m.opLogLines(logN, width)...) + return page.Panel(m.theme, title, lines, width, panelH, true), "↑/↓ · pgup/pgdn scroll · end follow" +} + +func (m Model) viewFlashing(width int) (string, string) { + opts := m.flashOptions() + lines := []string{ + m.spin.View() + " " + m.theme.Muted.Render("Writing image onto "+display(opts.Device)), + "", + m.flashProgressBar(max(20, width-14)), + m.theme.Muted.Render(m.flashProgressLabel()), + "", + } + if len(m.opLog) == 0 { + lines = append(lines, m.theme.Muted.Render("Waiting for flash to start…")) + } else { + start := 0 + if len(m.opLog) > 4 { + start = len(m.opLog) - 4 + } + for _, line := range m.opLog[start:] { + lines = append(lines, m.theme.Muted.Render(ansi.Truncate(line, max(1, width-4), "…"))) + } + } + return page.Panel(m.theme, "Flashing", lines, width, 12, true), "ctrl+c quit" +} + +func (m Model) flashProgressBar(width int) string { + pct := int(m.flashPercent()) + suffix := fmt.Sprintf(" %3d%%", pct) + barWidth := width - len(suffix) + if barWidth < 10 { + barWidth = 10 + } + filled := barWidth * pct / 100 + if filled > barWidth { + filled = barWidth + } + bar := m.theme.Success.Render(strings.Repeat("█", filled)) + m.theme.Muted.Render(strings.Repeat("░", barWidth-filled)) + return bar + suffix +} + +func (m Model) flashPercent() float64 { + if m.flashTotal <= 0 { + return 0 + } + pct := 100 * float64(m.flashWritten) / float64(m.flashTotal) + if pct > 100 { + return 100 + } + if pct < 0 { + return 0 + } + return pct +} + +func (m Model) flashProgressLabel() string { + if m.flashTotal <= 0 && m.flashWritten <= 0 { + return "0 B / —" + } + label := formatFlashSize(m.flashWritten) + " / " + if m.flashTotal > 0 { + label += formatFlashSize(m.flashTotal) + } else { + label += "—" + } + if rate := m.flashRate(); rate != "" { + label += " " + rate + } + if m.flashTotal > 0 { + label += fmt.Sprintf(" %d%%", int(m.flashPercent())) + } + return label +} + +func (m Model) flashRate() string { + if m.flashWritten <= 0 || m.flashStarted.IsZero() { + return "" + } + elapsed := time.Since(m.flashStarted).Seconds() + if elapsed < 0.2 { + return "" + } + return formatFlashSize(int64(float64(m.flashWritten)/elapsed)) + "/s" +} + +func formatFlashSize(n int64) string { + if n < 0 { + n = 0 + } + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp]) +} + +func (m Model) viewResult(width, height int, title, ok, fail, detail string) (string, string) { + panelH, logN := logPanelBudget(height, 6) + lines := []string{} + if m.err != nil { + lines = append(lines, + m.theme.Danger.Render(fail+" — scroll logs below, then enter/esc hub"), + m.theme.Danger.Render(m.err.Error()), + "", + ) + if m.needsAuth { + lines = append(lines, m.theme.Muted.Render("Press c to open Access, or retry with a cloud-config path."), "") + } + } else { + lines = append(lines, + m.theme.Success.Render(ok), + m.theme.Muted.Render("Scroll logs below · enter/esc hub"), + detail, + "", + ) + } + lines = append(lines, m.opLogExportHint(width)) + lines = append(lines, m.opLogScrollHint(width)) + lines = append(lines, m.opLogLines(logN, width)...) + help := "↑/↓ scroll · e export · enter/esc hub" + if m.needsAuth { + help = "c connect · ↑/↓ scroll · e export · enter/esc hub" + } + return page.Panel(m.theme, title, lines, width, panelH, true), help +} + +func display(v string) string { + if strings.TrimSpace(v) == "" { + return "—" + } + return v +} + +func secret(v string) string { + if strings.TrimSpace(v) == "" { + return "—" + } + return "••••" +} + +func (m Model) opLogLines(limit, width int) []string { + if limit <= 0 { + limit = 12 + } + if len(m.opLog) == 0 { + return []string{m.theme.Muted.Render("Waiting for output…")} + } + wrapped := wrapOpLog(m.opLog, width) + start := m.opLogStart(limit, len(wrapped)) + end := start + limit + if end > len(wrapped) { + end = len(wrapped) + } + out := make([]string, 0, end-start) + for _, line := range wrapped[start:end] { + out = append(out, m.theme.Muted.Render(line)) + } + return out +} + +func opLogInnerWidth(panelWidth int) int { + return max(20, panelWidth-4) +} + +func wrapOpLog(lines []string, width int) []string { + maxW := opLogInnerWidth(width) + out := make([]string, 0, len(lines)) + for _, line := range lines { + if line == "" { + out = append(out, "") + continue + } + out = append(out, strings.Split(ansi.Wrap(line, maxW, ""), "\n")...) + } + return out +} + +func opLogContentWidth(termWidth int) int { + if termWidth <= 0 { + termWidth = page.DefaultWidth + } + return page.ContentWidth(termWidth) +} + +func (m Model) opLogStart(limit, total int) int { + if total == 0 { + return 0 + } + maxStart := max(0, total-limit) + if m.opLogFollow { + return maxStart + } + if m.opLogY < 0 { + return 0 + } + if m.opLogY > maxStart { + return maxStart + } + return m.opLogY +} + +func (m Model) opLogScrollHint(width int) string { + if len(m.opLog) == 0 { + return m.theme.Muted.Render("No log lines captured.") + } + limit := 12 + if m.viewH > 0 { + _, limit = logPanelBudget(m.viewH, 5) + } + wrapped := wrapOpLog(m.opLog, width) + start := m.opLogStart(limit, len(wrapped)) + end := start + limit + if end > len(wrapped) { + end = len(wrapped) + } + label := fmt.Sprintf("Logs %d–%d / %d", start+1, end, len(wrapped)) + if m.opLogFollow { + label += " · following" + } + return m.theme.Muted.Render(ansi.Truncate(label, max(1, width-4), "…")) +} + +func (m Model) opLogExportHint(width int) string { + if m.logExportErr != nil { + return m.theme.Danger.Render(ansi.Truncate("Could not save logs: "+m.logExportErr.Error()+" · e retry", max(1, width-4), "…")) + } + if m.logExportPath != "" { + return m.theme.Muted.Render(ansi.Truncate("Saved "+m.logExportPath+" · e to save again", max(1, width-4), "…")) + } + return m.theme.Muted.Render("e exports full logs to a file (ctrl+c quits the TUI)") +} + +func logPanelBudget(termHeight, chrome int) (panelHeight, logLines int) { + panelHeight = termHeight - 6 + if panelHeight < 18 { + panelHeight = 18 + } + if chrome < 0 { + chrome = 0 + } + logLines = panelHeight - 2 - chrome + if logLines < 12 { + logLines = 12 + } + return panelHeight, logLines +} 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..eaf0e6b0e --- /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 += 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/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..d320daf52 --- /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 += 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/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..4b585eb00 --- /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 += 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/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..6415f9e2e --- /dev/null +++ b/tui/screens/pullrequests/view.go @@ -0,0 +1,299 @@ +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 := make([]string, 0, 2+len(m.opLog)) + lines = append(lines, 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 := make([]string, 0, 2+len(m.opLog)) + lines = append(lines, 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 += 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/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..cd60bf446 --- /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 += 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/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/golden_test.go b/tui/screens/services/golden_test.go new file mode 100644 index 000000000..0a8978c12 --- /dev/null +++ b/tui/screens/services/golden_test.go @@ -0,0 +1,171 @@ +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"}, + Version: "0.1.4", + Tarball: "https://console.acme.io/tarball/api", + DryRun: false, + Templated: true, + ClusterHandle: "prod-eu", + ClusterName: "production", + RevisionID: "rev-91ca", + RevisionSHA: "91ca21f0deadbeef", + RevisionRef: "main", + KustomizePath: "overlays/prod", + Repository: &servicesbridge.Repository{ID: "repo-1", URL: "https://github.com/acme/fleet.git", AuthMethod: "SSH", Health: "PULLABLE"}, + Configuration: []servicesbridge.ConfigEntry{{Name: "cluster", Value: "prod"}, {Name: "replicas", Value: "3"}}, + Components: []servicesbridge.Component{ + {Name: "api", Kind: "Deployment", Namespace: "default", State: "RUNNING", Synced: true}, + {Name: "api", Kind: "Service", Namespace: "default", State: "RUNNING", Synced: true}, + }, + Synced: 2, + 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"}, + Version: "0.1.4", + Tarball: "https://console.acme.io/tarball/api", + DryRun: false, + Templated: true, + ClusterHandle: "prod-eu", + ClusterName: "production", + RevisionID: "rev-91ca", + RevisionSHA: "91ca21f0deadbeef", + RevisionRef: "main", + KustomizePath: "overlays/prod", + Repository: &servicesbridge.Repository{ID: "repo-1", URL: "https://github.com/acme/fleet.git", AuthMethod: "SSH", Health: "PULLABLE"}, + Configuration: []servicesbridge.ConfigEntry{{Name: "cluster", Value: "prod"}, {Name: "replicas", Value: "3"}}, + Components: []servicesbridge.Component{ + {Name: "api", Kind: "Deployment", Namespace: "default", State: "RUNNING", Synced: true}, + {Name: "api", Kind: "Service", Namespace: "default", State: "RUNNING", Synced: true}, + }, + Synced: 2, + 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..cadd30978 --- /dev/null +++ b/tui/screens/services/model.go @@ -0,0 +1,1042 @@ +// Package services implements Console service browsing and contextual actions. +package services + +import ( + "context" + "fmt" + "path/filepath" + "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 + modeReview + modeOperating + modeResult + modeDeleteConfirm + modeTarball + modeCreate + modeEdit + modeClone + modeCloneCluster + modeWorkbench +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionFilter + keyActionNextPage + keyActionPrevPage + keyActionConnectConsole + keyActionCreate + keyActionBackground + keyActionPgUp + keyActionPgDown +) + +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"}, + keyActionCreate: {"a"}, + keyActionBackground: {"b"}, + keyActionPgUp: {"pgup"}, + keyActionPgDown: {"pgdown"}, +} + +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 +} +type opDoneMsg struct { + err error + detail servicesbridge.Detail + path string + request uint64 + kind actionKind +} + +// Model owns 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 + detailOffset int + 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 + + pickingCloneDest bool + cloneDest servicesbridge.Cluster +} + +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 = 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: modeClusters, wbTemplate: true, + } +} + +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) 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: + 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 + m.pickingCloneDest = false + m.cloneDest = servicesbridge.Cluster{} + 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)) + if m.pickingCloneDest { + m.mode = modeCloneCluster + } else { + 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.actionCursor = 0 + m.detailOffset = 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) + } + switch m.mode { + case modeFilter, modeDeleteConfirm, modeTarball, modeCreate, modeEdit, modeClone, modeWorkbench: + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(msg) + if m.mode == modeFilter { + m.filterInput = m.formInput + } + 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 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 modeCloneCluster: + return m.updateCloneCluster(action) + case modeWorkbench: + return m.updateWorkbench(action, key, text) + case modeDetail: + return m.updateDetail(action, text) + } + + if action == keyActionBack { + switch m.mode { + 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.Deployments) + } + } + if m.loading { + return m, nil + } + if m.needsAuth && (action == keyActionConnectConsole || text == "c") { + return m, navigation.Navigate(navigation.Access) + } + if m.mode == modeClusters { + return m.updateClusters(action) + } + return m.updateList(action) +} + +func (m Model) updateFilter(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.filterInput.Blur() + switch { + case m.pickingCloneDest: + m.mode = modeCloneCluster + case m.filteringCluster: + m.mode = modeClusters + default: + m.mode = modeList + } + return m, nil + case keyActionConfirm: + value := strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + if m.pickingCloneDest || m.filteringCluster { + m.clusterFilter = value + m.clusterCursor = 0 + if m.pickingCloneDest { + m.mode = modeCloneCluster + } else { + m.mode = modeClusters + } + 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 +} + +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 != "" { + m.detailOffset = 0 + 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)) + return m, nil + case keyActionMoveDown: + m.actionCursor = clampCursor(m.actionCursor+1, len(actions)) + return m, nil + case keyActionPgUp: + m.detailOffset = max(0, m.detailOffset-6) + return m, nil + case keyActionPgDown: + visible := 6 + maxOff := max(0, len(m.detailLines())-visible) + m.detailOffset = min(m.detailOffset+visible, maxOff) + return m, nil + case keyActionConfirm: + return m.openAction(actions[m.actionCursor]) + } + 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.beginClone() + 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, 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) 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: "Name", key: "name"}, + {label: "Namespace", key: "namespace"}, + } + m.formIndex = 0 + m.formValues = map[string]string{ + "name": m.detail.Name + "-clone", + "namespace": loCoalesce(m.detail.Namespace, "default"), + } + m.formInput.SetValue(m.formValues["name"]) + m.formInput.Placeholder = "cloned service name" + 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() + switch m.mode { + case modeCreate: + m.mode = modeList + case modeClone: + m.pickingCloneDest = true + m.mode = modeCloneCluster + default: + 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.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.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: 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 + " · " + clusterLabel(m.cluster), + "Dest " + dest, + "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) { + 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() + m.formInput = m.filterInput + } + return m, nil +} + +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + if action == keyActionCreate { + 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)) + 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() + m.formInput = m.filterInput + 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..45d577937 --- /dev/null +++ b/tui/screens/services/model_test.go @@ -0,0 +1,373 @@ +package services + +import ( + "context" + "errors" + "strings" + "testing" + + 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" + "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 + kicked string + deleted 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 (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() + 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) + } + view = model.View(80, 24) + if !strings.Contains(view, "Describe") { + t.Fatalf("detail view missing describe panel:\n%s", view) + } + + 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 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 TestDetailViewShowsDescribeFields(t *testing.T) { + model := New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII)) + model.loading = false + model.mode = modeDetail + model.detail = servicesbridge.Detail{ + Summary: servicesbridge.Summary{ID: "svc-1", Name: "api", Namespace: "default", Status: "FAILED", GitRef: "main", GitFolder: "services/api"}, + Version: "0.1.4", + Tarball: "https://console.example.com/tarball", + DryRun: true, + Templated: true, + ClusterHandle: "prod-eu", + ClusterName: "production", + RevisionID: "rev-1", + RevisionSHA: "abc123ff", + KustomizePath: "overlays/prod", + Repository: &servicesbridge.Repository{ID: "repo-1", URL: "https://github.com/acme/fleet.git", AuthMethod: "SSH", Health: "PULLABLE"}, + Configuration: []servicesbridge.ConfigEntry{{Name: "replicas", Value: "3"}}, + Components: []servicesbridge.Component{{Name: "api", Kind: "Deployment", Namespace: "default", State: "RUNNING", Synced: true}}, + Synced: 1, + Errors: []servicesbridge.ServiceError{{Source: "Deployment/api", Message: "rollout timed out"}}, + } + view := model.View(120, 30) + for _, want := range []string{"Describe", "Actions", "Kick", "Edit", "Clone", "Tarball", "svc-1", "0.1.4"} { + if !strings.Contains(view, want) { + t.Fatalf("detail view missing %q:\n%s", want, view) + } + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) + if model.detailOffset == 0 { + t.Fatal("pgdown did not scroll describe panel") + } + scrolled := model.View(120, 30) + for _, want := range []string{"overlays/prod", "PULLABLE"} { + if !strings.Contains(scrolled, want) { + t.Fatalf("scrolled describe missing %q:\n%s", want, scrolled) + } + } +} + +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 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}) + if cmd == nil { + t.Fatal("expected deployments navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Deployments}) { + 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 new file mode 100644 index 000000000..c23b0357d --- /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 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ · j/k 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..a75d26608 --- /dev/null +++ b/tui/screens/services/testdata/services-detail-120.golden @@ -0,0 +1,30 @@ + Plural Services · api HEALTHY + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ Describe ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Id 1 │ + │ Name api │ + │ Namespace default │ + │ Version 0.1.4 │ + │ Status HEALTHY │ + │ Cluster @prod-eu · production │ + │ Dry run false │ + │ Templated true │ + │ Tarball https://console.acme.io/tarball/api │ + │ Git main / services/api │ + │ Ref main │ + │ Folder services/api │ + │ Revision rev-91ca · 91ca21f0 · main │ + │ … │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ╭─ › 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] │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ↑/↓ actions · enter · k e c t m d · pgup/pgdn describe · r refresh · esc list 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..69c217c70 --- /dev/null +++ b/tui/screens/services/testdata/services-detail-80.golden @@ -0,0 +1,24 @@ + Plural Services · api HEALTHY + ──────────────────────────────────────────────────────────────────────────── + + ╭─ Describe ───────────────────────────────────────────────────────────────╮ + │ Id 1 │ + │ Name api │ + │ Namespace default │ + │ Version 0.1.4 │ + │ Status HEALTHY │ + │ Cluster @prod-eu · production │ + │ Dry run false │ + │ … │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + ╭─ › 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] │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + ↑/↓ · enter · k e c t m d · pgup/pgdn · r · esc 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..b361cad4e --- /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 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ · 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 new file mode 100644 index 000000000..c2c9b3336 --- /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 · a create · n/p page · esc diff --git a/tui/screens/services/view.go b/tui/screens/services/view.go new file mode 100644 index 000000000..22dddef08 --- /dev/null +++ b/tui/screens/services/view.go @@ -0,0 +1,609 @@ +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, height) + 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 modeCloneCluster: + title = "Clone · choose destination" + 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 || m.mode == modeOperating { + return m.theme.Warning.Render("◌ working") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + 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: + 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))) + case modeWorkbench: + return m.theme.Muted.Render("dry-run only") + default: + return m.theme.Muted.Render("services") + } +} + +func (m Model) bodyAndHelp(width, height 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" + } + 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 := make([]string, 0, 2+len(m.opLog)) + lines = append(lines, 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" + switch { + case m.result == "failed": + head = m.theme.Danger.Render("✗ Failed") + help = "enter retry review · esc detail" + case m.pending.kind == actionDelete: + help = "enter list · esc list" + case m.pending.kind == actionTarball: + head = m.theme.Success.Render("✓ Wrote " + m.result) + case m.pending.kind == actionWorkbench: + head = m.theme.Muted.Render("CLI equivalent") + help = "esc detail" + } + lines := make([]string, 0, 2+len(m.opLog)) + lines = append(lines, 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 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 { + 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: + actionsH := 8 + summaryH := max(9, height-actionsH-6) + lines := m.detailLines() + inner := max(1, summaryH-2) + offset := m.detailOffset + if maxOff := max(0, len(lines)-inner); offset > maxOff { + offset = maxOff + } + if offset < 0 { + offset = 0 + } + if offset > 0 { + lines = lines[offset:] + } + summary := page.Panel(m.theme, "Describe", lines, width, summaryH, false) + actions := page.Panel(m.theme, "Actions", m.actionLines(width), width, actionsH, true) + help := "↑/↓ actions · enter · k e c t m d · pgup/pgdn describe · r refresh · esc list" + if width < 100 { + help = "↑/↓ · enter · k e c t m d · pgup/pgdn · r · esc" + } + return summary + "\n\n" + actions, help + case modeClusters: + 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 := "↑/↓ · j/k · enter detail · a create · / filter · n/p page · r refresh · esc" + if width < 100 { + help = "↑/↓ · enter · a create · n/p page · 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)+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 := " " + if i == m.formIndex { + cursor = "› " + value = m.formInput.View() + } + lines = append(lines, cursor+pad(field.label, 12)+" "+value) + } + 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 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() { + 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 lines +} + +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")} + 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 = "› " + } + handle := cluster.Handle + if handle == "" { + handle = "—" + } else { + 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, "…")) + } + 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 +} + +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(" 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")} + 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 = "› " + } + 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 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 service detail…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load service"), m.theme.Danger.Render(m.err.Error())} + } + d := m.detail + status := m.statusBadge(d.Status) + if d.DeletedAt != "" { + status = m.theme.Danger.Render("Terminating") + m.theme.Muted.Render(" · "+d.DeletedAt) + } + tarball := d.Tarball + if tarball == "" { + tarball = "" + } + lines := []string{ + m.labelValue("Id", none(d.ID)), + m.labelValue("Name", none(d.Name)), + m.labelValue("Namespace", none(d.Namespace)), + m.labelValue("Version", none(d.Version)), + m.labelValue("Status", status), + m.labelValue("Cluster", m.detailCluster()), + m.labelValue("Dry run", fmt.Sprintf("%v", d.DryRun)), + m.labelValue("Templated", fmt.Sprintf("%v", d.Templated)), + m.labelValue("Tarball", tarball), + } + lines = append(lines, m.detailGitLines()...) + if d.KustomizePath != "" { + lines = append(lines, m.labelValue("Kustomize", d.KustomizePath)) + } + lines = append(lines, m.detailRepoLines()...) + lines = append(lines, m.detailConfigLines()...) + lines = append(lines, m.detailComponentLines()...) + lines = append(lines, m.detailErrorLines()...) + return lines +} + +func (m Model) detailCluster() string { + 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.ClusterName + } + } + if cluster == "" { + cluster = clusterLabel(m.cluster) + } + return none(cluster) +} + +func (m Model) detailGitLines() []string { + d := m.detail + git := strings.TrimSpace(d.GitRef + " / " + d.GitFolder) + if git == " / " || git == "" { + git = "—" + } + lines := []string{m.labelValue("Git", git)} + if d.GitRef != "" || d.GitFolder != "" { + if d.GitRef != "" { + lines = append(lines, m.indentValue("Ref", d.GitRef)) + } + if d.GitFolder != "" { + lines = append(lines, m.indentValue("Folder", d.GitFolder)) + } + } + revision := d.RevisionID + if d.RevisionSHA != "" && d.RevisionSHA != d.RevisionID { + if revision != "" { + revision += " · " + shortSHA(d.RevisionSHA) + } else { + revision = shortSHA(d.RevisionSHA) + } + } + if d.RevisionRef != "" { + if revision != "" { + revision += " · " + d.RevisionRef + } else { + revision = d.RevisionRef + } + } + if revision != "" { + lines = append(lines, m.labelValue("Revision", revision)) + } + return lines +} + +func (m Model) detailRepoLines() []string { + repo := m.detail.Repository + if repo == nil { + return nil + } + lines := []string{m.labelValue("Repository", none(repo.URL))} + if repo.ID != "" { + lines = append(lines, m.indentValue("Id", repo.ID)) + } + if repo.AuthMethod != "" { + lines = append(lines, m.indentValue("Auth", repo.AuthMethod)) + } + if repo.Health != "" { + lines = append(lines, m.indentValue("Health", repo.Health)) + } + if repo.Error != "" { + lines = append(lines, m.indentValue("Error", m.theme.Danger.Render(repo.Error))) + } + return lines +} + +func (m Model) detailConfigLines() []string { + if len(m.detail.Configuration) == 0 { + return []string{m.labelValue("Config", "")} + } + lines := []string{m.labelValue("Config", "")} + for _, entry := range m.detail.Configuration { + value := strings.ReplaceAll(entry.Value, "\n", " ") + lines = append(lines, m.indentValue(entry.Name, none(value))) + } + return lines +} + +func (m Model) detailComponentLines() []string { + total := len(m.detail.Components) + if total == 0 { + return []string{m.labelValue("Components", "")} + } + lines := []string{m.labelValue("Components", fmt.Sprintf("%d / %d synced", m.detail.Synced, total))} + lines = append(lines, m.theme.Muted.Render(" "+pad("NAME", 16)+pad("KIND", 14)+pad("NS", 12)+pad("STATE", 10)+"SYNC")) + for _, c := range m.detail.Components { + ns := c.Namespace + if ns == "" { + ns = "-" + } + state := c.State + if state == "" { + state = "-" + } + synced := "false" + if c.Synced { + synced = "true" + } + row := " " + pad(none(c.Name), 16) + pad(none(c.Kind), 14) + pad(ns, 12) + pad(state, 10) + synced + lines = append(lines, row) + } + return lines +} + +func (m Model) detailErrorLines() []string { + if len(m.detail.Errors) == 0 { + return []string{m.labelValue("Errors", "")} + } + lines := []string{m.theme.Danger.Render(fmt.Sprintf("Errors %d", len(m.detail.Errors)))} + for _, item := range m.detail.Errors { + source := item.Source + if source == "" { + source = "—" + } + lines = append(lines, m.theme.Danger.Render(" "+source)) + if item.Message != "" { + lines = append(lines, m.theme.Muted.Render(" "+item.Message)) + } + } + return lines +} + +func (m Model) indentValue(label, value string) string { + return " " + m.labelValue(label, value) +} + +func none(value string) string { + if strings.TrimSpace(value) == "" { + return "—" + } + return value +} + +func (m Model) labelValue(label, value string) string { + 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/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 new file mode 100644 index 000000000..72bac68f1 --- /dev/null +++ b/tui/screens/stacks/model.go @@ -0,0 +1,544 @@ +// Package stacks implements the Console infrastructure stacks browser and actions. +package stacks + +import ( + "context" + "fmt" + "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 + modeGenBackendForm + 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 stacksbridge.Page + err error + request uint64 +} +type detailMsg struct { + detail stacksbridge.Detail + err error + request uint64 +} +type opDoneMsg struct { + err error + result stacksbridge.GenBackendResult + 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 + 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 = 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 { + 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: + 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 + 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) + } + 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()) + 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 { + return m, navigation.Navigate(navigation.Deployments) + } + if m.loading { + return m, nil + } + if m.needsAuth && action == keyActionConnectConsole { + return m, navigation.Navigate(navigation.Access) + } + 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 + } + 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) { + 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..cbd742444 --- /dev/null +++ b/tui/screens/stacks/model_test.go @@ -0,0 +1,273 @@ +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 + result stacksbridge.GenBackendResult + err error + genErr 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 (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() + 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 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 := loadDetail(t, loader) + if !strings.Contains(model.View(80, 24), "gke-demo") { + t.Fatalf("detail missing name:\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}) + 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{ + 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..da959a488 --- /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 │ + │ … │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ╭─ › Actions ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ › g Gen-backend write _override.tf · terraform backend │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + ↑/↓ 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 new file mode 100644 index 000000000..c0af0ec87 --- /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 │ + │ … │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + ╭─ › Actions ──────────────────────────────────────────────────────────────╮ + │ › g Gen-backend write _override.tf · terraform backend │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + ↑/↓ · enter · g · r · esc 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..aff30f08c --- /dev/null +++ b/tui/screens/stacks/view.go @@ -0,0 +1,305 @@ +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 + } + 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 || 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 != modeGenBackendForm { + 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))) + 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") + } +} + +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" + } + 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 := make([]string, 0, 2+len(m.opLog)) + lines = append(lines, 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 := make([]string, 0, 2+len(m.opLog)) + lines = append(lines, 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)))) + } + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + return page.Panel(m.theme, "Generate backend", lines, width, 14, true), "↑/↓ fields · enter next/review · esc cancel" +} + +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 (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 + } + 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 += 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 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) != "" { + return v + } + } + return "" +} diff --git a/tui/screens/up/deploy_exec.go b/tui/screens/up/deploy_exec.go new file mode 100644 index 000000000..9b1f17d31 --- /dev/null +++ b/tui/screens/up/deploy_exec.go @@ -0,0 +1,178 @@ +package up + +import ( + "context" + "io" + "strings" + "sync" + + tea "charm.land/bubbletea/v2" + + upbridge "github.com/pluralsh/plural-cli/pkg/bridge/up" +) + +const maxOpLogLines = 500 + +type opLogLineMsg struct{ line string } + +type commitNeededMsg struct{} + +// commitGate blocks Deploy at the commit checkpoint until the TUI replies. +type commitGate struct { + req chan struct{} + reply chan string +} + +func newCommitGate() *commitGate { + return &commitGate{ + req: make(chan struct{}), + reply: make(chan string), + } +} + +func (g *commitGate) Prompt() string { + g.req <- struct{}{} + return <-g.reply +} + +// lineWriter splits writes into lines and pushes them onto ch (non-blocking when full). +type lineWriter struct { + ch chan<- string + mu sync.Mutex + buf strings.Builder +} + +func (w *lineWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + for _, b := range p { + if b == '\n' { + w.flushLocked() + continue + } + if b == '\r' { + continue + } + w.buf.WriteByte(b) + } + return len(p), nil +} + +func (w *lineWriter) flushLocked() { + line := strings.TrimRight(w.buf.String(), "\r") + w.buf.Reset() + if line == "" { + return + } + select { + case w.ch <- line: + default: + // drop if UI is behind — prefer keeping terraform unblocked + } +} + +func (w *lineWriter) Close() { + w.mu.Lock() + defer w.mu.Unlock() + if w.buf.Len() > 0 { + w.flushLocked() + } +} + +func listenOpLog(ch <-chan string) tea.Cmd { + return func() tea.Msg { + line, ok := <-ch + if !ok { + return nil + } + return opLogLineMsg{line: line} + } +} + +func listenCommitRequest(g *commitGate) tea.Cmd { + if g == nil { + return nil + } + return func() tea.Msg { + _, ok := <-g.req + if !ok { + return nil + } + return commitNeededMsg{} + } +} + +// shouldStreamLiveRunner prefers in-TUI log capture for the live runner. +// Test stubs keep a simple in-process Cmd (no log channel). +func shouldStreamLiveRunner(runner upbridge.Runner) bool { + if runner == nil { + return true + } + _, ok := runner.(upbridge.LiveRunner) + return ok +} + +func shouldExecDeploy(runner upbridge.Runner) bool { + return shouldStreamLiveRunner(runner) +} + +func appendOpLog(lines []string, line string) []string { + lines = append(lines, line) + if len(lines) > maxOpLogLines { + lines = lines[len(lines)-maxOpLogLines:] + } + return lines +} + +func deployStreamCmd(ctx context.Context, runner upbridge.Runner, in upbridge.DeployInput, lines chan string, gate *commitGate) tea.Cmd { + if runner == nil { + runner = upbridge.DefaultRunner() + } + if ctx == nil { + ctx = context.Background() + } + return func() tea.Msg { + w := &lineWriter{ch: lines} + in.Output = w + var steps []string + var committed string + in.CommittedMsg = &committed + if gate != nil { + in.PromptCommit = true + in.CommitPrompt = gate.Prompt + } + err := runner.Deploy(ctx, in, func(step string) { + steps = append(steps, step) + _, _ = io.WriteString(w, "→ "+step+"\n") + }) + w.Close() + close(lines) + if gate != nil { + close(gate.req) + } + return deployDoneMsg{err: err, steps: steps, commitMsg: committed} + } +} + +func runStreamCmd(ctx context.Context, runner upbridge.Runner, in upbridge.RunInput, lines chan string) tea.Cmd { + if runner == nil { + runner = upbridge.DefaultRunner() + } + if ctx == nil { + ctx = context.Background() + } + return func() tea.Msg { + w := &lineWriter{ch: lines} + in.Output = w + var steps []string + var importID string + res, err := runner.Run(ctx, in, func(step string) { + steps = append(steps, step) + _, _ = io.WriteString(w, "→ "+step+"\n") + }) + importID = res.ImportClusterID + w.Close() + close(lines) + return runDoneMsg{err: err, steps: steps, importClusterID: importID} + } +} diff --git a/tui/screens/up/deploy_exec_test.go b/tui/screens/up/deploy_exec_test.go new file mode 100644 index 000000000..eb9d7b391 --- /dev/null +++ b/tui/screens/up/deploy_exec_test.go @@ -0,0 +1,52 @@ +package up + +import ( + "strings" + "testing" + + upbridge "github.com/pluralsh/plural-cli/pkg/bridge/up" +) + +func TestShouldStreamLiveRunner(t *testing.T) { + if !shouldStreamLiveRunner(nil) { + t.Fatal("nil runner should stream") + } + if !shouldStreamLiveRunner(upbridge.LiveRunner{}) { + t.Fatal("live runner should stream") + } + if shouldStreamLiveRunner(&stubRunner{}) { + t.Fatal("stub runner should not stream") + } + if !shouldExecDeploy(upbridge.LiveRunner{}) { + t.Fatal("shouldExecDeploy alias broken") + } +} + +func TestLineWriterSplitsLines(t *testing.T) { + ch := make(chan string, 8) + w := &lineWriter{ch: ch} + if _, err := w.Write([]byte("hello\nwor")); err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte("ld\n")); err != nil { + t.Fatal(err) + } + w.Close() + var got []string + for len(ch) > 0 { + got = append(got, <-ch) + } + if strings.Join(got, "|") != "hello|world" { + t.Fatalf("got %#v", got) + } +} + +func TestAppendOpLogCaps(t *testing.T) { + var lines []string + for i := 0; i < maxOpLogLines+10; i++ { + lines = appendOpLog(lines, "x") + } + if len(lines) != maxOpLogLines { + t.Fatalf("len=%d", len(lines)) + } +} diff --git a/tui/screens/up/model.go b/tui/screens/up/model.go new file mode 100644 index 000000000..3f91af8fc --- /dev/null +++ b/tui/screens/up/model.go @@ -0,0 +1,2379 @@ +// 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/console" + "github.com/pluralsh/plural-cli/pkg/provider" + "github.com/pluralsh/plural-cli/pkg/utils" + "github.com/pluralsh/plural-cli/tui/components/oplog" + 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 + modeLoadInstances // GetConsoleInstances + modeSelectInstance // choseCluster survey + modeConsoleLogin // HandleCdLogin Affirm or token + modeSelectProvider + modeProbing + modeProviderForm + modeRunPreflights // provider.Preflights() after survey + modeIgnoreContinue // failed check + --ignore-preflights → Enter to continue + modeBucketPrefix // self-hosted Configure bucket naming + modePluralSubdomain // self-hosted ConfigureNetwork onplural.sh + modeAlreadyInit // workspace.yaml present — skip provider/git init + modeEnsuringInit // ensureWorkspace (domain / branch / gitignore) + modeSetupGit // CLI Affirm: setup git repo here? (Y/n) + modeSelectSCM // scm.Setup: github / gitlab / bitbucket + modeSCMSetup // tea.Exec: device login + create + clone + modeAppDomain // askAppDomain parity + modeAffirmDeploy // common.AffirmUp before deploy + modeSelected // Plan summary + modeRunning // Flush + Generate + modeDone // generate finished (or failed) + modeDeploying // up.Context.Deploy (commit prompt mid-Deploy at checkpoint) + modeDeployCommit // mid-Deploy commit message (TUI textinput; deploy goroutine blocked) + modeComplete // deploy finished + modeCLITip +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionUp + keyActionDown + keyActionConfirm + keyActionBack + keyActionPgUp + keyActionPgDown + keyActionHome + keyActionEnd + keyActionExport +) + +var keyActionKeystrokes = map[keyAction]string{ + keyActionUp: "up", + keyActionDown: "down", + keyActionConfirm: "enter", + keyActionBack: "esc", + keyActionPgUp: "pgup", + keyActionPgDown: "pgdown", + keyActionHome: "home", + keyActionEnd: "end", + keyActionExport: "e", +} + +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"}, + } +} + +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 +} + +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 +} + +type instancesMsg struct { + items []upbridge.ConsoleInstance + err error +} + +type runDoneMsg struct { + err error + steps []string + importClusterID string +} + +type deployDoneMsg struct { + err error + steps []string + commitMsg string +} + +type scmDoneMsg struct { + repo string + err error +} + +type ensureInitMsg 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 + scmRepo string + appDomain string + appDomainConfigured bool + domainOpts []string + domainNote string // zone fetch ignored (CLI "ignoring domain setup...") + spinner spinner.Model + + bucketPrefix string // self-hosted Configure + pluralDNS string // full subdomain.onplural.sh + alreadyInit bool // workspace.yaml present — skip init (CLI ensureWorkspace) + + 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 + opLog []string // terraform / generate lines shown in-TUI + opLogCh chan string // active stream (nil when idle) + opLogY int // first visible log line when not following + opLogFollow bool // stick to bottom while streaming / after End + viewH int // last known terminal height (for scroll window) + viewW int // last known terminal width (for wrap-aware scroll) + commitGate *commitGate // mid-deploy commit prompt bridge + exportDir string // tests; empty uses cwd + logExportPath string + logExportErr 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 + // scmSetup stubs SCM auth/create/clone in tests; nil uses upbridge.SetupSCM under tea.Exec. + scmSetup func(providerID string) (repoName string, err error) + // registerDomain stubs CreateDomain in tests; nil uses upbridge.RegisterPluralDomain. + registerDomain func(subdomain string) (fullDomain string, err error) + // hasWorkspace / loadWorkspace / ensureWorkspace stub the skip-init path in tests. + hasWorkspace func() bool + loadWorkspace func() (upbridge.ExistingWorkspace, error) + ensureWorkspace func() error + persistAppDomain func(domain string) error +} + +// 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, + instanceLister: upbridge.DefaultInstanceLister(), + runner: upbridge.DefaultRunner(), + opLogFollow: true, + priorConsole: func() (string, string) { + c := upbridge.ReadPriorConsole() + return c.Url, c.Token + }, + saveConsole: upbridge.SaveConsoleConfig, + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + if next, cmd, ok := m.applyAsync(msg); ok { + return next, cmd + } + return m.updateInput(msg) +} + +func (m Model) applyAsync(msg tea.Msg) (Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case probeMsg: + next, cmd := m.applyProbe(msg) + return next, cmd, true + case preflightMsg: + next, cmd := m.applyPreflight(msg) + return next, cmd, true + case optionsMsg: + next, cmd := m.applyOptions(msg) + return next, cmd, true + case domainMsg: + next, cmd := m.applyDomain(msg) + return next, cmd, true + case instancesMsg: + next, cmd := m.applyInstances(msg) + return next, cmd, true + case runDoneMsg: + next, cmd := m.applyRunDone(msg) + return next, cmd, true + case deployDoneMsg: + next, cmd := m.applyDeployDone(msg) + return next, cmd, true + case scmDoneMsg: + next, cmd := m.applySCMDone(msg) + return next, cmd, true + case ensureInitMsg: + next, cmd := m.applyEnsureInit(msg) + return next, cmd, true + case opLogLineMsg: + m.opLog = appendOpLog(m.opLog, msg.line) + if m.opLogCh != nil { + return m, tea.Batch(m.spinner.Tick, listenOpLog(m.opLogCh)), true + } + return m, nil, true + case tea.WindowSizeMsg: + m.viewH = msg.Height + m.viewW = msg.Width + return m, nil, true + case commitNeededMsg: + m.mode = modeDeployCommit + m.formInput.SetValue("") + m.formInput.Placeholder = "commit message (empty to skip)" + m.formInput.Focus() + m.err = nil + return m, nil, true + case spinner.TickMsg: + return m.applySpinner(msg) + default: + return m, nil, false + } +} + +func (m Model) applySpinner(msg spinner.TickMsg) (Model, tea.Cmd, bool) { + if m.mode != modeProbing && m.mode != modeAppDomain && m.mode != modeRunPreflights && m.mode != modeLoadInstances && m.mode != modeRunning && m.mode != modeDeploying && m.mode != modeSCMSetup && m.mode != modeEnsuringInit { + return m, nil, true + } + if m.mode == modeAppDomain && (len(m.domainOpts) > 0 || m.formInput.Focused()) { + return m, nil, true + } + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd, true +} + +func (m Model) updateInput(msg tea.Msg) (Model, tea.Cmd) { + key, ok := msg.(tea.KeyPressMsg) + if !ok { + return m.updateNonKey(msg) + } + action := actionForKeystroke(key.Keystroke()) + return m.updateByMode(action, key) +} + +func (m Model) updateNonKey(msg tea.Msg) (Model, tea.Cmd) { + 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 + } + case modeConsoleLogin: + if m.consoleTokenMode { + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(msg) + return m, cmd + } + case modeBucketPrefix, modePluralSubdomain, modeDeployCommit: + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateByMode(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch m.mode { + case modeSelected: + return m.updateSelected(action) + case modeRunning, modeDeploying: + return m.updateOpLogScroll(action) + case modeSCMSetup, modeEnsuringInit: + return m, nil // ignore keys while SCM oauth / ensure + case modeDeployCommit: + return m.updateDeployCommit(action, key) + case modeDone: + return m.updateDone(action) + case modeComplete: + return m.updateComplete(action) + case modeIgnoreContinue: + return m.updateIgnoreContinue(action) + case modeAlreadyInit: + return m.updateAlreadyInit(action) + case modeBucketPrefix: + return m.updateBucketPrefix(action, key) + case modePluralSubdomain: + return m.updatePluralSubdomain(action, key) + 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, modeLoadInstances: + return m.updateBusy(action) + case modeSelectInstance: + return m.updateSelectInstance(action, key) + case modeConsoleLogin: + return m.updateConsoleLogin(action, key) + case modeSelectProvider: + return m.updateSelectProvider(action, key) + case modeIgnorePreflights: + return m.updateIgnorePreflights(action, key) + default: + return m.updateSelectFlow(action, key) + } +} + +func (m Model) updateBusy(action keyAction) (Model, tea.Cmd) { + if action != keyActionBack { + return m, nil + } + if m.mode == modeLoadInstances { + return m.updateLoadInstances(action) + } + return m.updateProbing(action) +} + +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.err = nil + if m.flow.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 + } + 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 advances past the ignore-preflights warning gate. +// Self-hosted still collects Configure (bucket + onplural.sh) before the git Affirm. +func (m Model) continueAfterIgnoredFailure() (Model, tea.Cmd) { + m.err = nil + return m.beginAfterPreflight() +} + +func (m Model) beginAlreadyInit() (Model, tea.Cmd) { + load := m.loadWorkspace + if load == nil { + load = upbridge.LoadExistingWorkspace + } + ws, err := load() + if err != nil { + m.err = err + m.mode = modeIgnorePreflights + m.cursor = 0 + if m.ignorePreflights { + m.cursor = 1 + } + return m, nil + } + m.alreadyInit = true + m.inGitRepo = true + m.gitAffirmOpen = false + m.scm = upbridge.SCMProvider{} + m.scmRepo = "" + m.bucketPrefix = ws.BucketPrefix + m.pluralDNS = ws.PluralDNS + m.appDomain = ws.AppDomain + m.appDomainConfigured = ws.AppDomainConfigured + m.provider = providerFromID(m.providers, ws.ProviderID) + m.formValues = formValuesFromWorkspace(ws) + m.formFields = upbridge.ProviderFormFields(ws.ProviderID) + m.credSummary = "workspace.yaml · already initialized" + m.err = nil + m.mode = modeAlreadyInit + return m, nil +} + +func providerFromID(providers []upbridge.Provider, id string) upbridge.Provider { + for _, p := range providers { + if p.ID == id { + return p + } + } + return upbridge.Provider{ID: id, Title: id} +} + +func formValuesFromWorkspace(ws upbridge.ExistingWorkspace) map[string]string { + values := map[string]string{"cluster": ws.Cluster} + switch ws.ProviderID { + case "aws": + values["region"] = ws.Region + case "azure": + values["location"] = ws.Region + values["resourceGroup"] = ws.Project + case "gcp", "google": + values["region"] = ws.Region + values["project"] = ws.Project + default: + if ws.Region != "" { + values["region"] = ws.Region + } + if ws.Project != "" { + values["project"] = ws.Project + } + } + return values +} + +func (m Model) updateAlreadyInit(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.err = nil + m.alreadyInit = false + m.provider = upbridge.Provider{} + m.formFields = nil + m.formValues = nil + m.credSummary = "" + if m.flow.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 + case keyActionConfirm: + m.mode = modeEnsuringInit + m.err = nil + return m, tea.Batch(m.spinner.Tick, m.ensureInitCmd()) + } + return m, nil +} + +func (m Model) ensureInitCmd() tea.Cmd { + ensure := m.ensureWorkspace + if ensure == nil { + ensure = upbridge.EnsureExistingWorkspace + } + return func() tea.Msg { + return ensureInitMsg{err: ensure()} + } +} + +func (m Model) applyEnsureInit(msg ensureInitMsg) (Model, tea.Cmd) { + if m.mode != modeEnsuringInit { + return m, nil + } + if msg.err != nil { + m.err = msg.err + m.mode = modeAlreadyInit + return m, nil + } + m.err = nil + if m.flow.DryRun { + m.mode = modeSelected + return m, nil + } + return m.beginAppDomain() +} + +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) { + 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 + 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) updateDone(action keyAction) (Model, tea.Cmd) { + if m.handleOpLogScroll(action) { + return m, nil + } + switch action { + case keyActionBack: + m.mode = modeSelected + m.runErr = nil + m.runSteps = nil + m.opLog = nil + m.opLogFollow = true + m.opLogY = 0 + m.logExportPath = "" + m.logExportErr = nil + return m, nil + case keyActionExport: + m.saveLogs("up-generate") + return m, nil + case keyActionConfirm: + if m.runErr != nil || m.flow.DryRun { + return m, nil + } + // CLI: Affirm already done; Deploy runs terraform then prompts commit mid-flight. + return m.beginDeploy() + } + return m, nil +} + +func (m Model) updateComplete(action keyAction) (Model, tea.Cmd) { + if m.handleOpLogScroll(action) { + return m, nil + } + if action == keyActionExport { + m.saveLogs("up-deploy") + return m, nil + } + if action == keyActionBack { + m.mode = modeDone + m.deployErr = nil + return m, nil + } + return m, nil +} + +func (m Model) updateOpLogScroll(action keyAction) (Model, tea.Cmd) { + m.handleOpLogScroll(action) + return m, nil +} + +// handleOpLogScroll updates scroll state. Returns true if the action was a scroll key. +func (m *Model) handleOpLogScroll(action keyAction) bool { + window := m.opLogWindow() + switch action { + case keyActionUp: + m.scrollOpLog(-1, window) + return true + case keyActionDown: + m.scrollOpLog(1, window) + return true + case keyActionPgUp: + m.scrollOpLog(-window, window) + return true + case keyActionPgDown: + m.scrollOpLog(window, window) + return true + case keyActionHome: + m.opLogFollow = false + m.opLogY = 0 + return true + case keyActionEnd: + m.opLogFollow = true + return true + } + return false +} + +func (m Model) opLogWindow() int { + _, n := logPanelBudget(m.viewH, 4) + return n +} + +func (m *Model) scrollOpLog(delta, window int) { + if window <= 0 { + window = 20 + } + total := len(wrapOpLog(m.opLog, opLogContentWidth(m.viewW))) + maxStart := max(0, total-window) + if m.opLogFollow { + m.opLogY = maxStart + } + m.opLogFollow = false + m.opLogY += delta + if m.opLogY < 0 { + m.opLogY = 0 + } + if m.opLogY >= maxStart { + m.opLogY = maxStart + m.opLogFollow = true + } +} + +func (m Model) beginDeploy() (Model, tea.Cmd) { + m.mode = modeDeploying + m.deployErr = nil + m.runSteps = nil + m.opLog = nil + m.opLogFollow = true + m.opLogY = 0 + m.logExportPath = "" + m.logExportErr = nil + m.err = nil + m.commitMsg = "" + runner := m.runner + if runner == nil { + runner = upbridge.DefaultRunner() + } + live := shouldStreamLiveRunner(runner) + in := upbridge.DeployInput{ + Cloud: m.flow.Cloud, + CloudCluster: m.cloudInstance.Name, + ImportClusterID: m.importClusterID, + IgnorePreflights: m.ignorePreflights || m.flow.DryRun, + CommitMsg: m.commitMsg, + PromptCommit: false, + } + ctx := m.ctx + if ctx == nil { + ctx = context.Background() + } + if live { + lines := make(chan string, 256) + gate := newCommitGate() + m.opLogCh = lines + m.commitGate = gate + return m, tea.Batch( + m.spinner.Tick, + deployStreamCmd(ctx, runner, in, lines, gate), + listenOpLog(lines), + listenCommitRequest(gate), + ) + } + 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, + PromptCommit: false, + } + 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, commitMsg: in.CommitMsg} + } +} + +func (m Model) applyDeployDone(msg deployDoneMsg) (Model, tea.Cmd) { + if m.mode != modeDeploying && m.mode != modeDeployCommit { + return m, nil + } + m.opLogCh = nil + m.commitGate = nil + m.formInput.Blur() + if len(msg.steps) > 0 { + m.runSteps = msg.steps + } + if msg.commitMsg != "" { + m.commitMsg = msg.commitMsg + } + m.deployErr = msg.err + m.mode = modeComplete + m.opLogFollow = true + if msg.err != nil { + m.err = msg.err + m.saveLogs("up-deploy") + } else { + m.err = nil + } + return m, nil +} + +func (m Model) updateDeployCommit(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionConfirm: + msg := strings.TrimSpace(m.formInput.Value()) + m.commitMsg = msg + m.formInput.Blur() + m.mode = modeDeploying + gate := m.commitGate + if gate != nil { + go func() { gate.reply <- msg }() + } + return m, tea.Batch(m.spinner.Tick, listenCommitRequest(gate)) + case keyActionBack: + // empty commit = skip (same as CLI empty message) + m.formInput.SetValue("") + m.formInput.Blur() + m.mode = modeDeploying + gate := m.commitGate + if gate != nil { + go func() { gate.reply <- "" }() + } + return m, tea.Batch(m.spinner.Tick, listenCommitRequest(gate)) + } + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd +} + +func (m Model) beginRun() (Model, tea.Cmd) { + if !m.alreadyInit && 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.opLog = nil + m.opLogFollow = true + m.opLogY = 0 + m.importClusterID = "" + m.mode = modeRunning + + 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, + BucketPrefix: m.bucketPrefix, + PluralDNS: m.pluralDNS, + }, + Generate: upbridge.GenerateInput{ + Cloud: m.flow.Cloud, + CloudCluster: m.cloudInstance.Name, + IgnorePreflights: m.ignorePreflights || m.flow.DryRun, + }, + SkipFlush: m.alreadyInit, + } + ctx := m.ctx + if ctx == nil { + ctx = context.Background() + } + if shouldStreamLiveRunner(runner) { + lines := make(chan string, 256) + m.opLogCh = lines + return m, tea.Batch(m.spinner.Tick, runStreamCmd(ctx, runner, in, lines), listenOpLog(lines)) + } + 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, + BucketPrefix: m.bucketPrefix, + PluralDNS: m.pluralDNS, + }, + Generate: upbridge.GenerateInput{ + Cloud: m.flow.Cloud, + CloudCluster: m.cloudInstance.Name, + IgnorePreflights: m.ignorePreflights || m.flow.DryRun, + }, + SkipFlush: m.alreadyInit, + } + 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 + } + m.opLogCh = nil + if len(msg.steps) > 0 { + m.runSteps = msg.steps + } + m.importClusterID = msg.importClusterID + m.runErr = msg.err + m.mode = modeDone + m.opLogFollow = true // jump to end so latest output is visible + if msg.err != nil { + m.err = msg.err + m.saveLogs("up-generate") + } else { + m.err = nil + } + return m, nil +} + +func (m *Model) saveLogs(kind string) { + runErr := m.runErr + if kind == "up-deploy" { + runErr = m.deployErr + } + path, err := oplog.Write(m.exportDir, kind, m.opLog, runErr) + m.logExportPath = path + m.logExportErr = err +} + +func (m Model) updateSetupGit(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + opts := m.gitAffirmOptions() + switch action { + case keyActionBack: + m.err = nil + if !m.flow.Cloud { + return m.beginPluralSubdomain() + } + 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 m.alreadyInit { + m.mode = modeAlreadyInit + return m, nil + } + if !m.flow.Cloud { + return m.beginPluralSubdomain() + } + 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.Cloud { + return m.beginLoadInstances() + } + if m.flow.NeedsProvider() { + return m.afterCloudOrIgnoreReady() + } + m.mode = modeCLITip + return m, nil +} + +// afterCloudOrIgnoreReady continues after ignore-preflights (self-hosted) or +// Console login (cloud). If workspace.yaml exists, skip provider/git init. +func (m Model) afterCloudOrIgnoreReady() (Model, tea.Cmd) { + check := m.hasWorkspace + if check == nil { + check = upbridge.HasWorkspace + } + if check() { + return m.beginAlreadyInit() + } + m.mode = modeSelectProvider + m.cursor = 0 + 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 + return m.afterCloudOrIgnoreReady() +} + +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.beginAfterPreflight() +} + +// beginAfterPreflight continues after successful (or ignored) preflights. +// Self-hosted mirrors ProjectManifest.Configure before the git Affirm. +func (m Model) beginAfterPreflight() (Model, tea.Cmd) { + if !m.flow.Cloud { + return m.beginBucketPrefix() + } + return m.beginGitStep() +} + +func (m Model) beginBucketPrefix() (Model, tea.Cmd) { + m.mode = modeBucketPrefix + m.err = nil + m.formInput.SetValue(m.bucketPrefix) + m.formInput.Placeholder = "e.g. acme" + m.formInput.Focus() + return m, nil +} + +func (m Model) beginPluralSubdomain() (Model, tea.Cmd) { + m.mode = modePluralSubdomain + m.err = nil + hint := strings.TrimSuffix(m.pluralDNS, ".onplural.sh") + m.formInput.SetValue(hint) + m.formInput.Placeholder = "e.g. acme" + m.formInput.Focus() + return m, nil +} + +func (m Model) updateBucketPrefix(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.formInput.Blur() + 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 + return m, nil + case keyActionConfirm: + return m.confirmBucketPrefix() + } + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd +} + +func (m Model) confirmBucketPrefix() (Model, tea.Cmd) { + val := strings.TrimSpace(m.formInput.Value()) + if err := upbridge.ValidateBucketPrefix(val); err != nil { + m.err = err + return m, nil + } + m.bucketPrefix = val + m.err = nil + m.formInput.Blur() + return m.beginPluralSubdomain() +} + +func (m Model) updatePluralSubdomain(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.formInput.Blur() + m.err = nil + return m.beginBucketPrefix() + case keyActionConfirm: + return m.confirmPluralSubdomain() + } + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd +} + +func (m Model) confirmPluralSubdomain() (Model, tea.Cmd) { + sub := strings.TrimSpace(m.formInput.Value()) + if err := upbridge.ValidatePluralSubdomain(sub); err != nil { + m.err = err + return m, nil + } + register := m.registerDomain + if register == nil { + register = upbridge.RegisterPluralDomain + } + full, err := register(sub) + if err != nil { + m.err = err + return m, nil + } + m.pluralDNS = full + m.err = nil + m.formInput.Blur() + 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 + m.scmRepo = "" + m.mode = modeSCMSetup + setup := m.scmSetup + useExec := setup == nil + if setup == nil { + setup = upbridge.SetupSCM + } + cmd := scmSetupCmd(s.ID, setup, useExec) + if useExec { + // Terminal is released for oauth/survey — no TUI spinner ticks during Exec. + return m, cmd + } + return m, tea.Batch(m.spinner.Tick, cmd) +} + +func (m Model) applySCMDone(msg scmDoneMsg) (Model, tea.Cmd) { + if m.mode != modeSCMSetup { + return m, nil + } + if msg.err != nil { + m.err = msg.err + m.mode = modeSelectSCM + return m, nil + } + m.scmRepo = msg.repo + m.err = nil + m.inGitRepo = true + 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) { + if skip, ok := utils.GetEnvBoolValue("PLURAL_UP_SKIP_APP_DOMAIN"); ok && skip { + return m.beginAffirmDeploy() + } + if m.appDomainConfigured || strings.TrimSpace(m.appDomain) != "" { + return m.beginAffirmDeploy() + } + 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.appDomainConfigured = true + m.formInput.Blur() + m.err = nil + if err := m.persistAppDomainChoice(m.appDomain); err != nil { + m.err = err + return m, nil + } + return m.beginAffirmDeploy() +} + +func (m Model) persistAppDomainChoice(domain string) error { + if m.persistAppDomain != nil { + return m.persistAppDomain(domain) + } + if m.alreadyInit { + return upbridge.PersistAppDomain(domain) + } + return nil +} + +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 + 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..1e4715517 --- /dev/null +++ b/tui/screens/up/model_test.go @@ -0,0 +1,1235 @@ +package up + +import ( + "context" + "fmt" + "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} } + 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{} + model.scmSetup = func(string) (string, error) { return "demo-repo", nil } + model.registerDomain = func(sub string) (string, error) { + return upbridge.PluralDomain(sub), nil + } + model.hasWorkspace = func() bool { return false } + model.ensureWorkspace = func() error { return nil } + model.persistAppDomain = func(string) error { return nil } + model.exportDir = t.TempDir() + 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} } + 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{} + model.scmSetup = func(string) (string, error) { return "demo-repo", nil } + model.registerDomain = func(sub string) (string, error) { + return upbridge.PluralDomain(sub), nil + } + model.hasWorkspace = func() bool { return false } + model.ensureWorkspace = func() error { return nil } + model.persistAppDomain = func(string) error { return nil } + model.exportDir = t.TempDir() + 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 { + if in.SkipFlush { + progress("Skipping workspace.yaml write (already initialized)…") + } else { + 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 (s *stubRunner) Destroy(_ context.Context, in upbridge.DestroyInput, progress upbridge.ProgressFunc) error { + if progress != nil { + progress("Destroying management cluster terraform…") + } + return nil +} + +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 +} + +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 drainConfigure(t *testing.T, model Model) Model { + t.Helper() + if model.mode == modeBucketPrefix { + model.formInput.SetValue("acme") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + } + if model.mode == modePluralSubdomain { + model.formInput.SetValue("acme") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + } + return model +} + +func drainSCM(t *testing.T, model Model, _ tea.Cmd) Model { + t.Helper() + if model.mode != modeSCMSetup { + return model + } + msg := model.scmSetupCmdForTest()() + model, _ = model.Update(msg) + return model +} + +// scmSetupCmdForTest rebuilds the stub Cmd when finish helpers lost the original. +func (m Model) scmSetupCmdForTest() tea.Cmd { + setup := m.scmSetup + if setup == nil { + setup = func(string) (string, error) { return "demo-repo", nil } + } + return scmSetupCmd(m.scm.ID, setup, false) +} + +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}) + } + model = drainConfigure(t, model) + if model.mode == modeSetupGit { + model, _ = model.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + } + if model.mode == modeSelectSCM { + var cmd tea.Cmd + model, cmd = model.Update(tea.KeyPressMsg{Code: 'g', Text: "g"}) + model = drainSCM(t, model, cmd) + } + 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 != modePluralSubdomain { + t.Fatalf("esc to plural dns = %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeBucketPrefix { + t.Fatalf("esc to bucket = %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 != modeBucketPrefix { + t.Fatalf("expected bucket naming after enter, got %d", model.mode) + } + if len(model.formFields) != 0 { + t.Fatalf("should not open region form, fields=%v", model.formFields) + } + model = drainConfigure(t, model) + if model.mode != modeSetupGit { + t.Fatalf("expected git affirm after configure, got %d", model.mode) + } + 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, cmd = model.Update(tea.KeyPressMsg{Code: 'g', Text: "g"}) + model = drainSCM(t, model, cmd) + 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 != modeBucketPrefix { + t.Fatalf("expected bucket naming 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 != modeBucketPrefix { + t.Fatalf("expected bucket naming, mode=%d", model.mode) + } + model = drainConfigure(t, model) + // Already in a git work tree — CLI skips Affirm / scm.Setup. + if model.mode != modeAppDomain { + t.Fatalf("expected app domain when already in git, got %d inGit=%v", model.mode, model.inGitRepo) + } + 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, 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), "demo-cloud") { + t.Fatalf("missing instance:\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 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 TestDryRunGoesToProvider(t *testing.T) { + model := testModel(t) + model, _ = model.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + if model.mode != modeIgnorePreflights || model.SelectedFlow() != "dry-run" || !model.DryRun() { + t.Fatalf("after dry-run = mode=%d flow=%q dry=%v", model.mode, model.SelectedFlow(), model.DryRun()) + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'r', Text: "r"}) + if model.mode != modeSelectProvider || !model.DryRun() { + t.Fatalf("dry-run provider = mode=%d dry=%v", model.mode, model.DryRun()) + } +} + +func TestDryRunSkipsDomainAffirmAndStopsAfterGenerate(t *testing.T) { + model := testModel(t) + model, _ = model.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + 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}) + model = finishToSelected(t, model) + if model.mode != modeSelected || !model.DryRun() { + t.Fatalf("plan = mode=%d dry=%v", model.mode, model.DryRun()) + } + if strings.Contains(model.View(80, 24), "App domain") { + t.Fatal("dry-run plan should omit app domain") + } + if !strings.Contains(model.View(80, 24), "no Deploy") { + t.Fatalf("plan should say no Deploy:\n%s", model.View(80, 24)) + } + runner := model.runner.(*stubRunner) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + 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].Generate.IgnorePreflights { + t.Fatalf("run = %#v", runner.calls) + } + if !strings.Contains(model.View(80, 24), "no Deploy will run") { + t.Fatalf("done view:\n%s", model.View(80, 24)) + } + // Enter must not start Deploy on dry-run. + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeDone || len(runner.deploys) != 0 { + t.Fatalf("dry-run must not deploy: mode=%d deploys=%d", model.mode, len(runner.deploys)) + } +} + +func TestCloudDryRunLoadsInstancesThenProvider(t *testing.T) { + model := testModel(t) + model.priorConsole = func() (string, string) { + return "https://demo.onplural.sh", "existing-token" + } + 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, _ = model.Update(tea.KeyPressMsg{Code: 'x', Text: "x"}) + if model.SelectedFlow() != "cloud-dry-run" || !model.Cloud() || !model.DryRun() { + t.Fatalf("flow=%q cloud=%v dry=%v", model.SelectedFlow(), model.Cloud(), model.DryRun()) + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'r', Text: "r"}) + model = drainInstances(t, model) + if model.mode != modeSelectInstance { + t.Fatalf("expected instance select, got %d err=%v", model.mode, model.err) + } + 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.DryRun() || model.cloudInstance.Name != "demo-cloud" { + t.Fatalf("after login = mode=%d dry=%v inst=%q err=%v", model.mode, model.DryRun(), model.cloudInstance.Name, model.err) + } +} + +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) + } + flush := runner.calls[0].Flush + if flush.BucketPrefix != "acme" || flush.PluralDNS != "acme.onplural.sh" || flush.Cloud { + t.Fatalf("self-hosted flush = %#v", flush) + } + 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)) + } + if model.logExportPath == "" { + t.Fatal("expected auto-export on generate error") + } +} + +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 != modeDeploying { + t.Fatalf("expected deploying (commit is mid-Deploy), 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 { + t.Fatalf("deploys = %#v", runner.deploys) + } + if runner.deploys[0].PromptCommit { + t.Fatal("stub path should not prompt commit") + } + 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}) // start deploy + 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)) + } + if model.logExportPath == "" { + t.Fatal("expected auto-export on deploy error") + } + // Stay on complete until esc so logs remain reviewable. + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeComplete { + t.Fatalf("enter must not leave failed complete, mode=%d", model.mode) + } +} + +func TestOpLogScrollOnDone(t *testing.T) { + model := testModel(t) + model.mode = modeDone + model.viewH = 24 + model.opLogFollow = true + for i := 0; i < 40; i++ { + model.opLog = append(model.opLog, fmt.Sprintf("line-%02d", i)) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyHome}) + if model.opLogFollow || model.opLogY != 0 { + t.Fatalf("home: follow=%v y=%d", model.opLogFollow, model.opLogY) + } + if start := model.opLogStart(12, 40); start != 0 { + t.Fatalf("home start=%d", start) + } + before := model.opLogY + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) + if model.opLogY <= before { + t.Fatalf("pgdown should advance y: before=%d after=%d", before, model.opLogY) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnd}) + if !model.opLogFollow { + t.Fatal("end should follow") + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + if model.opLogFollow { + t.Fatal("up should stop follow") + } + view := model.View(80, 24) + if !strings.Contains(view, "line-") { + t.Fatalf("expected scrollable logs in view:\n%s", view) + } +} + +func TestDoneKeepsLogsUntilContinue(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, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = drainRun(t, model, nil) + if model.mode != modeDone { + t.Fatalf("mode=%d", model.mode) + } + model.opLog = []string{"alpha-log", "beta-log", "gamma-log"} + view := model.View(80, 24) + if !strings.Contains(view, "Finished generating") || !strings.Contains(view, "alpha-log") { + t.Fatalf("done should keep logs visible:\n%s", view) + } + // Arrow scroll must not advance to Deploy. + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + if model.mode != modeDone { + t.Fatalf("scroll left mode=%d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeDeploying { + t.Fatalf("enter should start deploy, mode=%d", model.mode) + } +} + +func TestOpLogLinesUsePanelWidth(t *testing.T) { + model := testModel(t) + model.mode = modeDone + long := strings.Repeat("abcdefghij", 20) + model.opLog = []string{long} + view := ansi.Strip(model.View(160, 24)) + if !strings.Contains(view, strings.Repeat("abcdefghij", 12)) { + t.Fatalf("expected wrapped log to keep the start of the line, got:\n%s", view) + } + if !strings.Contains(view, long[len(long)-40:]) { + t.Fatalf("expected long log line to wrap instead of truncate, got:\n%s", view) + } +} + +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 != modeBucketPrefix { + t.Fatalf("expected bucket prefix after form, got %d", model.mode) + } + model = drainConfigure(t, model) + if model.mode != modeSetupGit { + t.Fatalf("expected git affirm after configure, got %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + if model.mode != modeSelectSCM { + t.Fatalf("expected scm select, got %d", model.mode) + } + model, cmd = model.Update(tea.KeyPressMsg{Code: 'g', Text: "g"}) + if model.mode != modeSCMSetup { + t.Fatalf("expected scm setup, got %d", model.mode) + } + model = drainSCM(t, model, cmd) + if model.err != nil { + t.Fatalf("scm setup: %v", model.err) + } + if model.scmRepo != "demo-repo" { + t.Fatalf("scmRepo = %q", model.scmRepo) + } +} + +func TestConfigureValidation(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 = drainPreflight(t, model) + if model.mode != modeBucketPrefix { + t.Fatalf("mode = %d", model.mode) + } + model.formInput.SetValue("BAD") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeBucketPrefix || model.err == nil { + t.Fatalf("expected bucket validation, mode=%d err=%v", model.mode, model.err) + } +} + +func TestAlreadyInitializedSkipsProvider(t *testing.T) { + model := testModel(t) + model.hasWorkspace = func() bool { return true } + model.loadWorkspace = func() (upbridge.ExistingWorkspace, error) { + return upbridge.ExistingWorkspace{ + ProviderID: "aws", + Cluster: "demo", + Region: "us-east-2", + BucketPrefix: "acme", + PluralDNS: "acme.onplural.sh", + }, nil + } + model, _ = model.Update(tea.KeyPressMsg{Code: 's', Text: "s"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'i', Text: "i"}) + if model.mode != modeAlreadyInit || !model.alreadyInit { + t.Fatalf("expected already-init, mode=%d init=%v err=%v", model.mode, model.alreadyInit, model.err) + } + if model.provider.ID != "aws" || model.formValues["cluster"] != "demo" { + t.Fatalf("provider=%q values=%v", model.provider.ID, model.formValues) + } + if !strings.Contains(model.View(80, 24), "skipping init") { + t.Fatalf("view:\n%s", model.View(80, 24)) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeEnsuringInit { + t.Fatalf("expected ensuring, got %d", model.mode) + } + model, _ = model.Update(ensureInitMsg{}) + model = drainDomain(t, model) + if model.mode != modeAppDomain { + t.Fatalf("expected app domain after ensure, got %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeAffirmDeploy { + t.Fatalf("expected deploy affirm, got %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + if model.mode != modeSelected { + t.Fatalf("expected plan, got %d", model.mode) + } + + 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("done = mode=%d calls=%d", model.mode, len(runner.calls)) + } + if !runner.calls[0].SkipFlush { + t.Fatalf("expected SkipFlush, got %#v", runner.calls[0]) + } +} + +func TestAlreadyInitializedSkipsAppDomain(t *testing.T) { + model := testModel(t) + model.hasWorkspace = func() bool { return true } + model.loadWorkspace = func() (upbridge.ExistingWorkspace, error) { + return upbridge.ExistingWorkspace{ + ProviderID: "aws", + Cluster: "demo", + Region: "us-east-2", + BucketPrefix: "acme", + PluralDNS: "acme.onplural.sh", + AppDomain: "apps.example.com", + AppDomainConfigured: true, + }, nil + } + model, _ = model.Update(tea.KeyPressMsg{Code: 's', Text: "s"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'i', Text: "i"}) + if model.mode != modeAlreadyInit { + t.Fatalf("expected already-init, mode=%d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(ensureInitMsg{}) + if model.mode != modeAffirmDeploy { + t.Fatalf("expected deploy affirm after skipping domain, got %d appDomain=%q", model.mode, model.appDomain) + } + if model.appDomain != "apps.example.com" { + t.Fatalf("appDomain = %q", model.appDomain) + } +} + +func TestAlreadyInitPersistsAppDomainOnConfirm(t *testing.T) { + var persisted string + model := testModel(t) + model.hasWorkspace = func() bool { return true } + model.loadWorkspace = func() (upbridge.ExistingWorkspace, error) { + return upbridge.ExistingWorkspace{ + ProviderID: "aws", + Cluster: "demo", + Region: "us-east-2", + BucketPrefix: "acme", + PluralDNS: "acme.onplural.sh", + }, nil + } + model.persistAppDomain = func(domain string) error { + persisted = domain + return nil + } + model, _ = model.Update(tea.KeyPressMsg{Code: 's', Text: "s"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'i', Text: "i"}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(ensureInitMsg{}) + model = drainDomain(t, model) + if model.mode != modeAppDomain { + t.Fatalf("expected app domain, got %d", model.mode) + } + model.formInput.SetValue("apps.example.com") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if persisted != "apps.example.com" { + t.Fatalf("persisted = %q", persisted) + } + if !model.appDomainConfigured { + t.Fatal("expected appDomainConfigured") + } + if model.mode != modeAffirmDeploy { + t.Fatalf("mode = %d", model.mode) + } +} + +func goldenModels(t *testing.T) (flow, ignore, provider, form, selected, 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"} + 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, 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}, + {"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, 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}, + {"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/scm_exec.go b/tui/screens/up/scm_exec.go new file mode 100644 index 000000000..7a3aa3fc5 --- /dev/null +++ b/tui/screens/up/scm_exec.go @@ -0,0 +1,45 @@ +package up + +import ( + "fmt" + "io" + + tea "charm.land/bubbletea/v2" +) + +// scmExecCommand runs SCM device login / create / clone after the TUI releases +// the terminal (tea.Exec → releaseTerminal), matching plural up's scm.Setup. +type scmExecCommand struct { + providerID string + setup func(string) (string, error) + repoName string +} + +func (c *scmExecCommand) Run() error { + name, err := c.setup(c.providerID) + c.repoName = name + return err +} + +func (c *scmExecCommand) SetStdin(io.Reader) {} +func (c *scmExecCommand) SetStdout(io.Writer) {} +func (c *scmExecCommand) SetStderr(io.Writer) {} + +// scmSetupCmd returns either a normal Cmd (tests) or tea.Exec (live oauth/survey). +func scmSetupCmd(providerID string, setup func(string) (string, error), useExec bool) tea.Cmd { + if setup == nil { + return func() tea.Msg { + return scmDoneMsg{err: fmt.Errorf("scm setup is not configured")} + } + } + if !useExec { + return func() tea.Msg { + name, err := setup(providerID) + return scmDoneMsg{repo: name, err: err} + } + } + cmd := &scmExecCommand{providerID: providerID, setup: setup} + return tea.Exec(cmd, func(err error) tea.Msg { + return scmDoneMsg{repo: cmd.repoName, err: err} + }) +} 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..6b46be0a4 --- /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. │ + │ Self-hosted and dry-run run the provider survey; cloud paths pick a Console first. │ + │ │ + │ › 1 s Self-hosted pick a cloud provider · provision management cluster │ + │ 2 c Plural Cloud pick a Console instance (--cloud) · then provider survey │ + │ 3 d Dry-run generate repo only (--dry-run) · no deploy │ + │ 4 x Cloud · dry-run Plural Cloud generate only (--cloud --dry-run) │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ 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..bc78aec36 --- /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. │ + │ Self-hosted and dry-run run the provider survey; cloud paths pick a Con… │ + │ │ + │ › 1 s Self-hosted pick a cloud provider · provision management c… │ + │ 2 c Plural Cloud pick a Console instance (--cloud) · then provi… │ + │ 3 d Dry-run generate repo only (--dry-run) · no deploy │ + │ 4 x Cloud · dry-run Plural Cloud generate only (--cloud --dry-run) │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · 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..04755fe4d --- /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 │ + │ │ + │ Enter to Flush workspace.yaml + Generate, then Deploy. │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + 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 new file mode 100644 index 000000000..48532e1dd --- /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 │ + │ │ + │ Enter to Flush workspace.yaml + Generate, then Deploy. │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + enter run · 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..e556b0ee7 --- /dev/null +++ b/tui/screens/up/view.go @@ -0,0 +1,1071 @@ +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, height) + 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 modeLoadInstances: + return m.theme.Muted.Render("loading Console instances…") + case modeSelectInstance: + return m.theme.Muted.Render("step · Console instance") + case modeConsoleLogin: + return m.headerConsoleLogin() + 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 modeAlreadyInit: + return m.theme.Muted.Render("step · already initialized") + case modeEnsuringInit: + return m.theme.Muted.Render("checking workspace…") + case modeBucketPrefix: + return m.theme.Muted.Render("step · bucket naming") + case modePluralSubdomain: + return m.theme.Muted.Render("step · onplural.sh") + case modeSetupGit: + return m.theme.Muted.Render("step · git repository") + case modeSelectSCM: + return m.theme.Muted.Render("step · scm provider") + case modeSCMSetup: + return m.theme.Muted.Render("scm · authenticate / create / clone") + case modeAppDomain: + return m.theme.Muted.Render("step · app domain") + case modeAffirmDeploy: + return m.theme.Muted.Render("step · deploy Affirm") + case modeSelected: + return m.headerSelected() + case modeRunning: + return m.theme.Muted.Render("running Flush + Generate…") + case modeDone: + return m.headerDone() + case modeDeploying: + return m.theme.Muted.Render("deploying…") + case modeDeployCommit: + return m.theme.Muted.Render("commit checkpoint") + case modeComplete: + return m.headerComplete() + case modeCLITip: + return m.theme.Muted.Render(m.flow.ID) + default: + return m.theme.Muted.Render("step 1 · mode") + } +} + +func (m Model) headerConsoleLogin() string { + if m.consoleTokenMode { + return m.theme.Muted.Render("step · console token") + } + return m.theme.Muted.Render("step · console credentials") +} + +func (m Model) headerSelected() string { + 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") +} + +func (m Model) headerDone() string { + if m.runErr != nil { + return m.theme.Danger.Render("failed") + } + return m.theme.Success.Render("generated") +} + +func (m Model) headerComplete() string { + if m.deployErr != nil { + return m.theme.Danger.Render("deploy failed") + } + return m.theme.Success.Render("deployed") +} + +func (m Model) viewPlan(width int) (string, string) { + lines := []string{ + m.theme.Success.Render("✓ Continuing plural up init"), + "", + "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+")") + } + 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.flow.Cloud { + if m.bucketPrefix != "" { + lines = append(lines, "Bucket "+m.bucketPrefix) + } + if m.pluralDNS != "" { + lines = append(lines, "Plural DNS "+m.pluralDNS) + } + } + switch { + case m.scm.ID != "": + scmLine := "SCM " + m.scm.Title + if m.scmRepo != "" { + scmLine += " → " + m.scmRepo + } + lines = append(lines, scmLine) + case m.alreadyInit: + lines = append(lines, "Git workspace.yaml present · init skipped") + case 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) + } + next := m.planNextHint() + lines = append(lines, + "", + m.theme.Muted.Render("Equivalent CLI"), + " "+m.cli(), + "", + m.theme.Muted.Render(next), + ) + 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 +} + +func (m Model) planNextHint() string { + switch { + case m.flow.DryRun && m.alreadyInit && m.flow.Cloud: + return "Enter to ImportCluster + Generate only (skip Flush, no Deploy)." + case m.flow.DryRun && m.alreadyInit: + return "Enter to Generate only (skip Flush, no Deploy — --dry-run)." + case m.flow.DryRun && m.flow.Cloud: + return "Enter to Flush + ImportCluster + Generate only (no Deploy — --cloud --dry-run)." + case m.flow.DryRun: + return "Enter to Flush + Generate only (no Deploy — --dry-run)." + case m.alreadyInit && m.flow.Cloud: + return "Enter to ImportCluster + Generate (skip Flush), then Deploy." + case m.alreadyInit: + return "Enter to Generate (skip Flush — workspace.yaml exists), then Deploy." + case m.flow.Cloud: + return "Enter to Flush + ImportCluster + Generate, then Deploy." + default: + return "Enter to Flush workspace.yaml + Generate, then Deploy." + } +} + +func (m Model) bodyAndHelp(width, height int) (string, string) { + switch m.mode { + case modeSelected: + return m.viewPlan(width) + case modeRunning: + return m.viewGenerating(width, height) + case modeDone: + return m.viewGenerateDone(width, height) + case modeDeploying: + return m.viewDeploying(width, height) + case modeDeployCommit: + return m.viewDeployCommit(width, height) + case modeComplete: + return m.viewDeployComplete(width, height) + case modeCLITip: + return m.viewCLITip(width) + case modeLoadInstances: + return m.viewLoadInstances(width) + case modeSelectInstance: + return m.viewSelectInstance(width) + case modeConsoleLogin: + return m.viewConsoleLogin(width) + case modeSetupGit: + return m.viewSetupGit(width) + case modeAffirmDeploy: + return m.viewAffirmDeploy(width) + case modeIgnoreContinue: + return m.viewIgnoreContinue(width) + case modeAlreadyInit: + return m.viewAlreadyInit(width) + case modeEnsuringInit: + return m.viewEnsuringInit(width) + case modeBucketPrefix: + return m.viewBucketPrefix(width) + case modePluralSubdomain: + return m.viewPluralSubdomain(width) + case modeRunPreflights: + return m.viewRunPreflights(width) + case modeSelectSCM: + return m.viewSelectSCM(width) + case modeSCMSetup: + return m.viewSCMSetup(width) + case modeAppDomain: + return m.viewAppDomain(width) + case modeProbing: + return m.viewProbing(width) + case modeProviderForm: + return m.formView(width) + case modeSelectProvider: + return m.viewSelectProvider(width) + case modeIgnorePreflights: + return m.viewIgnorePreflights(width) + default: + return m.viewSelectFlow(width) + } +} + +func (m Model) viewGenerating(width, height int) (string, string) { + panelH, logN := logPanelBudget(height, 4) + lines := []string{ + m.spinner.View() + " " + m.theme.Muted.Render("Running Flush + Generate…"), + "", + } + if m.flow.DryRun { + lines = append(lines, + m.theme.Muted.Render("Dry-run: generate only — output streams below (no Deploy)."), + "", + ) + } else { + lines = append(lines, + m.theme.Muted.Render("Terraform / generation output streams below (TUI stays open)."), + "", + ) + } + lines = append(lines, m.opLogLines(logN, width)...) + return page.Panel(m.theme, "Generating", lines, width, panelH, true), "↑/↓ · pgup/pgdn scroll · end follow" +} + +func (m Model) viewGenerateDone(width, height int) (string, string) { + panelH, logN := logPanelBudget(height, 6) + lines := []string{} + switch { + case m.runErr != nil: + lines = append(lines, + m.theme.Danger.Render("Generate failed — scroll logs below, then esc to Plan"), + m.theme.Danger.Render(truncate(m.runErr.Error(), max(20, width-4))), + "", + ) + case m.flow.DryRun: + lines = append(lines, + m.theme.Success.Render("✓ Dry-run finished — no Deploy will run"), + m.theme.Muted.Render("Scroll logs below · esc returns to Plan"), + "", + ) + default: + lines = append(lines, + m.theme.Success.Render("✓ Finished generating the repo"), + m.theme.Muted.Render("Scroll logs below · enter Deploy · esc Plan"), + "", + ) + } + lines = append(lines, m.opLogExportHint(width)) + lines = append(lines, m.opLogScrollHint(width)) + lines = append(lines, m.opLogLines(logN, width)...) + help := "↑/↓ scroll · e export · esc plan" + if m.runErr == nil && !m.flow.DryRun { + help = "↑/↓ scroll · e export · enter deploy · esc plan" + } + return page.Panel(m.theme, "Generate complete", lines, width, panelH, true), help +} + +func (m Model) viewDeploying(width, height int) (string, string) { + panelH, logN := logPanelBudget(height, 4) + lines := make([]string, 0, 4+logN) + lines = append(lines, + m.spinner.View()+" "+m.theme.Muted.Render("Running Deploy (terraform / import / apps)…"), + "", + m.theme.Muted.Render("Terraform output streams below. Commit is prompted after mgmt apply."), + "", + ) + lines = append(lines, m.opLogLines(logN, width)...) + return page.Panel(m.theme, "Deploying", lines, width, panelH, true), "↑/↓ · pgup/pgdn scroll · end follow" +} + +func (m Model) viewDeployCommit(width, height int) (string, string) { + panelH, logN := logPanelBudget(height, 6) + lines := make([]string, 0, 6+logN) + lines = append(lines, + m.theme.Muted.Render("==> Enter a commit message to push your configuration"), + m.theme.Muted.Render("Same checkpoint as plural up (after management terraform)."), + "", + "› Message", + " "+m.formInput.View(), + "", + ) + lines = append(lines, m.opLogLines(logN, width)...) + return page.Panel(m.theme, "Commit", lines, width, panelH, true), "enter continue · esc skip commit" +} + +func (m Model) viewDeployComplete(width, height int) (string, string) { + panelH, logN := logPanelBudget(height, 6) + lines := []string{} + if m.deployErr != nil { + lines = append(lines, + m.theme.Danger.Render("Deploy failed — scroll logs below, then esc to retry"), + m.theme.Danger.Render(truncate(m.deployErr.Error(), max(20, width-4))), + "", + ) + } else { + lines = append(lines, + m.theme.Success.Render("✓ Finished setting up your management cluster!"), + m.theme.Muted.Render("Scroll logs below · esc back"), + "", + ) + } + lines = append(lines, m.opLogExportHint(width)) + lines = append(lines, m.opLogScrollHint(width)) + lines = append(lines, m.opLogLines(logN, width)...) + return page.Panel(m.theme, "Deploy complete", lines, width, panelH, true), "↑/↓ scroll · e export · esc back" +} + +func (m Model) viewCLITip(width int) (string, string) { + lines := []string{ + 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), + "Preflights " + yesNoLabel(m.ignorePreflights), + "", + m.theme.Muted.Render("Equivalent CLI"), + " " + m.cli(), + "", + 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" +} + +func (m Model) viewLoadInstances(width int) (string, string) { + 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" +} + +func (m Model) viewSelectInstance(width int) (string, string) { + 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 +} + +func (m Model) viewConsoleLogin(width int) (string, string) { + 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" +} + +func (m Model) viewSetupGit(width int) (string, string) { + 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 +} + +func (m Model) viewAffirmDeploy(width int) (string, string) { + 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" +} + +func (m Model) viewIgnoreContinue(width int) (string, string) { + 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."), + m.theme.Muted.Render("Next: Configure (self-hosted) → git Affirm → scm.Setup → domain → plan."), + } + return page.Panel(m.theme, "Continuing with warning", lines, width, 14, true), "enter continue · esc back" +} + +func (m Model) viewAlreadyInit(width int) (string, string) { + lines := []string{ + m.theme.Success.Render("Found workspace.yaml, skipping init as this repo has already been initialized"), + "", + m.theme.Muted.Render("Same path as plural up when workspace.yaml is present."), + m.theme.Muted.Render("Next: ensure domain / branch → app domain → deploy Affirm → Generate."), + "", + "Mode " + m.flow.Title, + "Provider " + m.provider.Title + " (" + m.provider.ID + ")", + } + if cluster := formValue(m.formValues, "cluster"); cluster != "" { + lines = append(lines, "Cluster "+cluster) + } + if m.pluralDNS != "" { + lines = append(lines, "Plural DNS "+m.pluralDNS) + } + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + return page.Panel(m.theme, "Already initialized", lines, width, 14, true), "enter continue · esc back" +} + +func (m Model) viewEnsuringInit(width int) (string, string) { + lines := []string{ + m.spinner.View() + " " + m.theme.Muted.Render("Checking domain…"), + m.theme.Muted.Render("ensureWorkspace — Plural DNS / branch / .gitignore"), + } + return page.Panel(m.theme, "Workspace check", lines, width, 10, true), "please wait" +} + +func (m Model) viewBucketPrefix(width int) (string, string) { + lines := []string{ + m.theme.Muted.Render(upbridge.BucketPrefixPrompt), + m.theme.Muted.Render("Same as plural up Configure (workspace bucket naming)."), + "", + "› Prefix", + " " + m.formInput.View(), + } + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + return page.Panel(m.theme, "Bucket naming", lines, width, 12, true), "enter · esc back" +} + +func (m Model) viewPluralSubdomain(width int) (string, string) { + lines := []string{ + m.theme.Muted.Render(upbridge.PluralSubdomainPrompt), + m.theme.Muted.Render("Registers subdomain.onplural.sh (CreateDomain)."), + "", + "› Subdomain", + " " + m.formInput.View(), + } + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + return page.Panel(m.theme, "Plural DNS", lines, width, 12, true), "enter · esc back" +} + +func (m Model) viewRunPreflights(width int) (string, string) { + 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" +} + +func (m Model) viewSelectSCM(width int) (string, string) { + lines := make([]string, 0, 3+len(m.scms)) + lines = append(lines, + 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 +} + +func (m Model) viewSCMSetup(width int) (string, string) { + lines := []string{ + "SCM " + m.scm.Title, + "", + m.spinner.View() + " " + m.theme.Muted.Render("Device login · create repo · clone…"), + m.theme.Muted.Render("Terminal released for GitHub/GitLab/Bitbucket oauth (same as plural up)."), + m.theme.Muted.Render("Follow the one-time code prompt in the terminal, then return here."), + } + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + return page.Panel(m.theme, "SCM setup", lines, width, 12, true), "wait for browser / device flow" +} + +func (m Model) viewAppDomain(width int) (string, string) { + 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 +} + +func (m Model) viewProbing(width int) (string, string) { + 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" +} + +func (m Model) viewSelectProvider(width int) (string, string) { + intro := make([]string, 0, 7+8) + intro = append(intro, + "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."), + "", + ) + intro = append(intro, m.providerLines(width)...) + if m.err != nil { + intro = append(intro, "", m.theme.Danger.Render(m.err.Error())) + intro = append(intro, 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", intro, width, 16, true), help +} + +func (m Model) viewIgnorePreflights(width int) (string, string) { + intro := make([]string, 0, 7+4) + intro = append(intro, + "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)."), + "", + ) + intro = 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", intro, width, 14, true), help +} + +func (m Model) viewSelectFlow(width int) (string, string) { + intro := make([]string, 0, 3+len(m.flows)) + intro = append(intro, + m.theme.Muted.Render("Sets up your repository and an initial management cluster."), + m.theme.Muted.Render("Self-hosted and dry-run run the provider survey; cloud paths pick a Console first."), + "", + ) + intro = 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", intro, 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) 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)) + 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 +} + +func (m Model) opLogLines(limit, width int) []string { + if limit <= 0 { + limit = 12 + } + if len(m.opLog) == 0 { + return []string{m.theme.Muted.Render("Waiting for output…")} + } + wrapped := wrapOpLog(m.opLog, width) + start := m.opLogStart(limit, len(wrapped)) + end := start + limit + if end > len(wrapped) { + end = len(wrapped) + } + out := make([]string, 0, end-start) + for _, line := range wrapped[start:end] { + out = append(out, m.theme.Muted.Render(line)) + } + return out +} + +// opLogInnerWidth is the text width inside a page.Panel (borders + padding). +func opLogInnerWidth(panelWidth int) int { + return max(20, panelWidth-4) +} + +func wrapOpLog(lines []string, width int) []string { + maxW := opLogInnerWidth(width) + out := make([]string, 0, len(lines)) + for _, line := range lines { + if line == "" { + out = append(out, "") + continue + } + out = append(out, strings.Split(ansi.Wrap(line, maxW, ""), "\n")...) + } + return out +} + +func opLogContentWidth(termWidth int) int { + if termWidth <= 0 { + termWidth = page.DefaultWidth + } + return page.ContentWidth(termWidth) +} + +func (m Model) opLogStart(limit, total int) int { + if total == 0 { + return 0 + } + maxStart := max(0, total-limit) + if m.opLogFollow { + return maxStart + } + if m.opLogY < 0 { + return 0 + } + if m.opLogY > maxStart { + return maxStart + } + return m.opLogY +} + +func (m Model) opLogScrollHint(width int) string { + if len(m.opLog) == 0 { + return m.theme.Muted.Render("No log lines captured.") + } + limit := 12 + if m.viewH > 0 { + _, limit = logPanelBudget(m.viewH, 5) + } + wrapped := wrapOpLog(m.opLog, width) + start := m.opLogStart(limit, len(wrapped)) + end := min(len(wrapped), start+limit) + label := fmt.Sprintf("Logs %d–%d / %d", start+1, end, len(wrapped)) + if m.opLogFollow { + label += " · following" + } + return m.theme.Muted.Render(ansi.Truncate(label, max(1, width-4), "…")) +} + +func (m Model) opLogExportHint(width int) string { + if m.logExportErr != nil { + return m.theme.Danger.Render(ansi.Truncate("Could not save logs: "+m.logExportErr.Error()+" · e retry", max(1, width-4), "…")) + } + if m.logExportPath != "" { + return m.theme.Muted.Render(ansi.Truncate("Saved "+m.logExportPath+" · e to save again", max(1, width-4), "…")) + } + return m.theme.Muted.Render("e exports full logs to a file (ctrl+c quits the TUI)") +} + +// logPanelBudget sizes the streaming log panel to fill most of the terminal. +// chrome is the number of intro lines above the log (excluding panel borders). +func logPanelBudget(termHeight, chrome int) (panelHeight, logLines int) { + // header (2) + blank (1) + help (1) + min separation (2) + panelHeight = termHeight - 6 + if panelHeight < 18 { + panelHeight = 18 + } + if chrome < 0 { + chrome = 0 + } + logLines = panelHeight - 2 - chrome + if logLines < 12 { + logLines = 12 + } + return panelHeight, logLines +} diff --git a/tui/screens/welcome/groups.go b/tui/screens/welcome/groups.go new file mode 100644 index 000000000..775a4b6e3 --- /dev/null +++ b/tui/screens/welcome/groups.go @@ -0,0 +1,38 @@ +package welcome + +import "github.com/pluralsh/plural-cli/tui/navigation" + +type groupID uint8 + +const ( + groupUp groupID = iota + groupDown + groupDeployments + groupAccess + groupDiagnose + groupAI + groupEdge + 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: groupUp, number: "1", shortcut: "u", title: "Up", blurb: "bootstrap · management cluster", route: navigation.Up}, + {id: groupDown, number: "2", shortcut: "x", title: "Down", blurb: "destroy · management cluster", route: navigation.Down}, + {id: groupDeployments, number: "3", shortcut: "d", title: "CD / Deployments", blurb: "clusters · services · repos", route: navigation.Deployments}, + {id: groupAccess, number: "4", shortcut: "a", title: "Access", blurb: "login · profiles · Console", route: navigation.Access}, + {id: groupDiagnose, number: "5", shortcut: "g", title: "Diagnose", blurb: "local context · checks", route: navigation.Diagnostics}, + {id: groupAI, number: "6", shortcut: "i", title: "AI", blurb: "chat · agents · workbenches", route: navigation.AI}, + {id: groupEdge, number: "7", shortcut: "e", title: "Edge", blurb: "image · flash", route: navigation.Edge}, + {id: groupHelp, number: "8", shortcut: "?", title: "Help", blurb: "shortcuts · about"}, + } +} diff --git a/tui/screens/welcome/model.go b/tui/screens/welcome/model.go new file mode 100644 index 000000000..9565a7db0 --- /dev/null +++ b/tui/screens/welcome/model.go @@ -0,0 +1,150 @@ +package welcome + +import ( + "context" + + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + + welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + 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 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 + 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 { + return Model{ + ctx: ctx, + loader: loader, + theme: t, + spinner: pluralspinner.New(t), + groups: welcomeGroups(), + 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 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 + 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 new file mode 100644 index 000000000..30b7b703d --- /dev/null +++ b/tui/screens/welcome/model_test.go @@ -0,0 +1,465 @@ +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 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", + 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("group picker view height = %d, want 24", len(lines)) + } +} + +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"}) + 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 != 2 { + t.Fatalf("cursor = %d, want 2", model.cursor) + } +} + +func TestWelcomeOpensDownFromShortcut(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'x', Text: "x"}) + if cmd == nil { + t.Fatal("selecting Down did not emit navigation") + } + if got := cmd().(navigation.NavigateMsg).Route; got != navigation.Down { + t.Fatalf("route = %q, want down", got) + } + 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: '4', Text: "4"}) + 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 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: '6', Text: "6"}) + 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}) + 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") + } + if got := cmd().(navigation.NavigateMsg).Route; got != navigation.Diagnostics { + t.Fatalf("route = %q, want diagnostics", got) + } +} + +func TestWelcomeOpensEdgeFromShortcut(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'e', Text: "e"}) + if cmd == nil { + t.Fatal("selecting Edge did not emit navigation") + } + if got := cmd().(navigation.NavigateMsg).Route; got != navigation.Edge { + t.Fatalf("route = %q, want edge", 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)) + } + 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]) + } +} + +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 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..5d254e543 --- /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 ────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + ╭─ Choose an area ─────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ › 1 u Up bootstrap · management cluster │ + │ 2 x Down destroy · management cluster │ + │ 3 d CD / Deployments clusters · services · repos │ + │ 4 a Access login · profiles · Console │ + │ 5 g Diagnose local context · checks │ + │ 6 i AI chat · agents · workbenches │ + │ 7 e Edge image · flash │ + │ 8 ? Help shortcuts · about │ + │ │ + │ Setup · Agents · Develop — later │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + 1–8 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 new file mode 100644 index 000000000..3818251da --- /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 ────────────────────────────────────────────────────────────────╯ + + + ╭─ Choose an area ─────────────────────────────────────────────────────────╮ + │ › 1 u Up bootstrap · management cluster │ + │ 2 x Down destroy · management cluster │ + │ 3 d CD / Deployments clusters · services · repos │ + │ 4 a Access login · profiles · Console │ + │ 5 g Diagnose local context · checks │ + │ 6 i AI chat · agents · workbenches │ + │ 7 e Edge image · flash │ + │ 8 ? Help shortcuts · about │ + │ │ + │ Setup · Agents · Develop — later │ + ╰──────────────────────────────────────────────────────────────────────────╯ + 1–8 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 new file mode 100644 index 000000000..47eb385c7 --- /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 ────────────────────────────────────────────────────────────────╯ + + + ╭─ Choose an area ─────────────────────────────────────────────────────────╮ + │ 1 u Up bootstrap · management cluster │ + │ › 2 x Down destroy · management cluster │ + │ 3 d CD / Deployments clusters · services · repos │ + │ 4 a Access login · profiles · Console │ + │ 5 g Diagnose local context · checks │ + │ 6 i AI chat · agents · workbenches │ + │ 7 e Edge image · flash │ + │ 8 ? Help shortcuts · about │ + │ │ + │ Setup · Agents · Develop — later │ + ╰──────────────────────────────────────────────────────────────────────────╯ + 1–8 open · letter shortcut · ↑/↓ · enter · ctrl+c quit diff --git a/tui/screens/welcome/view.go b/tui/screens/welcome/view.go new file mode 100644 index 000000000..ed31fe541 --- /dev/null +++ b/tui/screens/welcome/view.go @@ -0,0 +1,328 @@ +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) + groups := m.renderGroups(contentWidth) + gap := m.verticalGap(height, header, groups) + + 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–8 / 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–8 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) { + 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 := make([]string, 0, 3+8) + lines = append(lines, + 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/screens/workbenches/model.go b/tui/screens/workbenches/model.go new file mode 100644 index 000000000..39c47cf36 --- /dev/null +++ b/tui/screens/workbenches/model.go @@ -0,0 +1,349 @@ +// Package workbenches implements interactive workbench browsing and job follow-up. +package workbenches + +import ( + "context" + "strings" + + "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/components/page" + "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 +} + +// Model is the Workbenches screen: browse jobs and queue a follow-up on one. +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 textinput.Model + detail workbenchesbridge.Detail + result workbenchesbridge.PromptResult + returnTo mode + detailOffset int + viewW int + viewH int +} + +// New constructs the workbenches screen. +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 := textinput.New() + prompt.Prompt = "› " + prompt.Placeholder = "follow-up prompt" + prompt.CharLimit = 4000 + prompt.SetStyles(styles) + 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++ + 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) beginFollowup() tea.Cmd { + m.mode = modeOperating + m.loading = true + m.err = nil + m.request++ + request, loader, ctx := m.request, m.loader, m.ctx + jobID := m.detail.ID + prompt := strings.TrimSpace(m.prompt.Value()) + return func() tea.Msg { + result, err := loader.FollowUp(ctx, jobID, prompt, 0) + return followedMsg{result: result, err: err, request: request} + } +} + +func (m Model) startFollowup() Model { + if m.mode == modeList { + if len(m.page.Items) == 0 { + return m + } + m.detail = workbenchesbridge.Detail{Summary: m.page.Items[m.cursor]} + } + if m.detail.ID == "" { + return m + } + m.returnTo = m.mode + m.err = nil + m.mode = modePrompt + m.prompt.SetValue("") + m.prompt.Focus() + return m +} + +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.detailOffset = 0 + 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.WindowSizeMsg: + m.viewW, m.viewH = msg.Width, msg.Height + 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) { + stroke := key.Keystroke() + if m.mode == modeFilter { + switch stroke { + 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 stroke { + case "esc": + m.mode = m.backMode() + m.prompt.Blur() + return m, nil + case "enter": + if strings.TrimSpace(m.prompt.Value()) == "" { + return m, nil + } + 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 stroke == "esc" { + m.mode = modePrompt + m.prompt.Focus() + return m, nil + } + if stroke == "enter" { + return m, m.beginFollowup() + } + return m, nil + } + if m.mode == modeOperating { + return m, nil + } + if m.mode == modeResult { + if stroke == "esc" || stroke == "enter" { + m.mode = m.backMode() + } + return m, nil + } + if m.mode == modeDetail { + switch stroke { + case "esc": + m.mode = modeList + m.detailOffset = 0 + case "f": + return m.startFollowup(), nil + case "up", "k": + m.scrollDetail(-1) + case "down", "j": + m.scrollDetail(1) + case "pgup": + m.scrollDetail(-m.detailVisible()) + case "pgdown": + m.scrollDetail(m.detailVisible()) + case "home": + m.detailOffset = 0 + case "end": + m.scrollDetail(len(m.detailLines(m.contentWidth()))) + } + 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.filterInput.SetValue(m.filter) + m.filterInput.Focus() + case "r": + return m, m.beginList() + case "f": + return m.startFollowup(), nil + } + return m, nil +} + +func (m Model) backMode() mode { + if m.returnTo == modeDetail { + return modeDetail + } + return modeList +} + +func (m *Model) scrollDetail(delta int) { + inner := m.detailVisible() + maxOff := max(0, len(m.detailLines(m.contentWidth()))-inner) + m.detailOffset = min(max(0, m.detailOffset+delta), maxOff) +} + +func (m Model) detailVisible() int { + return max(1, detailPanelHeight(m.viewH)-2) +} + +func (m Model) contentWidth() int { + width := m.viewW + if width <= 0 { + width = page.DefaultWidth + } + return page.ContentWidth(width) +} + +func detailPanelHeight(height int) int { + if height <= 0 { + height = page.DefaultHeight + } + return max(12, height-10) +} + +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/workbenches/model_test.go b/tui/screens/workbenches/model_test.go new file mode 100644 index 000000000..0182e71e7 --- /dev/null +++ b/tui/screens/workbenches/model_test.go @@ -0,0 +1,193 @@ +package workbenches + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + 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 + gotJob string + gotPrompt string + result workbenchesbridge.PromptResult +} + +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, jobID, prompt string, _ time.Duration) (workbenchesbridge.PromptResult, error) { + f.gotJob, f.gotPrompt = jobID, prompt + return f.result, nil +} + +func TestJobDetailScrollsPrompt(t *testing.T) { + var b strings.Builder + for i := 0; i < 40; i++ { + fmt.Fprintf(&b, "LINE-%02d unique-content\n", i) + } + loader := &fakeLoader{ + page: workbenchesbridge.Page{Items: []workbenchesbridge.Summary{{ID: "job-1", WorkbenchName: "triage"}}}, + detail: workbenchesbridge.Detail{Summary: workbenchesbridge.Summary{ + ID: "job-1", + Prompt: b.String(), + }}, + } + model := listed(t, loader) + model, _ = model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + top := normalizeView(model.View(80, 24)) + if !strings.Contains(top, "LINE-00 unique-content") { + t.Fatalf("expected start of prompt:\n%s", top) + } + if strings.Contains(top, "LINE-39 unique-content") { + t.Fatalf("entire prompt fit without scrolling:\n%s", top) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) + mid := normalizeView(model.View(80, 24)) + if strings.Contains(mid, "LINE-00 unique-content") { + t.Fatalf("pgdown did not move the prompt:\n%s", mid) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnd}) + bottom := normalizeView(model.View(80, 24)) + if !strings.Contains(bottom, "LINE-39 unique-content") { + t.Fatalf("end did not reveal the end of the prompt:\n%s", bottom) + } +} + +func TestJobDetailWrapsMultilinePrompt(t *testing.T) { + prompt := "You've been assigned the following issue from github, please investigate everything necessary to complete the task. The issue will be described below:\n\n## Comment on something" + loader := &fakeLoader{ + page: workbenchesbridge.Page{Items: []workbenchesbridge.Summary{{ + ID: "21968068-f35f-41a3-9a27-3a1d5ef56df5", + WorkbenchName: "DevOps Automation", + Status: "successful", + Prompt: prompt, + }}}, + detail: workbenchesbridge.Detail{Summary: workbenchesbridge.Summary{ + ID: "21968068-f35f-41a3-9a27-3a1d5ef56df5", + WorkbenchName: "DevOps Automation", + Status: "successful", + Prompt: prompt, + InsertedAt: "2026-09-08T12:52:44.988123Z", + }}, + } + model := listed(t, loader) + got := normalizeView(model.View(80, 24)) + if strings.Contains(got, "\n## Comment") { + t.Fatalf("list row should collapse prompt newlines:\n%s", got) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + got = normalizeView(model.View(80, 24)) + jobAt := strings.Index(got, "21968068-f35f-41a3-9a27-3a1d5ef56df5") + promptAt := strings.Index(got, "You've been assigned") + headingAt := strings.Index(got, "## Comment on something") + if jobAt < 0 || promptAt < 0 || headingAt < 0 { + t.Fatalf("detail missing fields:\n%s", got) + } + if !(jobAt < promptAt && promptAt < headingAt) { + t.Fatalf("expected Job ID, then wrapped prompt:\n%s", got) + } + if !strings.Contains(got, "2026-09-08 12:52 UTC") { + t.Fatalf("timestamp not formatted:\n%s", got) + } +} + +func TestListStillBrowsesJobs(t *testing.T) { + model := listed(t, &fakeLoader{ + page: workbenchesbridge.Page{Items: []workbenchesbridge.Summary{{ID: "job-1", WorkbenchName: "triage", Prompt: "investigate"}}}, + }) + got := normalizeView(model.View(80, 24)) + if !strings.Contains(got, "Recent workbench jobs") || !strings.Contains(got, "triage") { + t.Fatalf("list missing jobs:\n%s", got) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("enter did not open detail") + } +} + +func TestFollowUpPromptLooksLikeOtherInputs(t *testing.T) { + model := listed(t, &fakeLoader{ + page: workbenchesbridge.Page{Items: []workbenchesbridge.Summary{{ID: "job-1"}}}, + }) + model, _ = model.Update(tea.KeyPressMsg{Code: 'f', Text: "f"}) + model.prompt.SetValue("hello") + got := normalizeView(model.View(80, 24)) + if strings.Contains(got, " 1 hello") || strings.Contains(got, "ctrl+s") { + t.Fatalf("prompt still looks like a textarea:\n%s", got) + } + if !strings.Contains(got, "› hello") { + t.Fatalf("expected single-line prompt input:\n%s", got) + } + if !strings.Contains(got, "enter review") { + t.Fatalf("expected enter to review:\n%s", got) + } +} + +func TestFollowUpQueuesSelectedJob(t *testing.T) { + loader := &fakeLoader{ + page: workbenchesbridge.Page{Items: []workbenchesbridge.Summary{{ID: "job-1", WorkbenchName: "triage"}}}, + result: workbenchesbridge.PromptResult{ + ID: "prompt-1", + WorkbenchID: "job-1", + DequeueAt: "2026-09-10T10:00:00Z", + }, + } + model := listed(t, loader) + model, _ = model.Update(tea.KeyPressMsg{Code: 'f', Text: "f"}) + if model.mode != modePrompt || model.detail.ID != "job-1" { + t.Fatalf("expected prompt for selected job, mode=%d id=%q", model.mode, model.detail.ID) + } + model.prompt.SetValue("verify the fix") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeReview { + t.Fatalf("expected review mode, got %d", model.mode) + } + got := normalizeView(model.View(80, 24)) + if !strings.Contains(got, "verify the fix") || !strings.Contains(got, "job-1") { + t.Fatalf("review missing job fields:\n%s", got) + } + 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.result) + } + if loader.gotJob != "job-1" || loader.gotPrompt != "verify the fix" { + t.Fatalf("follow-up job=%q prompt=%q", loader.gotJob, loader.gotPrompt) + } +} + +func listed(t *testing.T, loader workbenchesbridge.Loader) Model { + t.Helper() + model := New(t.Context(), loader, theme.New(colorprofile.ASCII)) + model, cmd := model.Update(model.Init()()) + if cmd == nil { + t.Fatal("init did not list jobs") + } + model, _ = model.Update(cmd()) + return model +} + +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/workbenches/view.go b/tui/screens/workbenches/view.go new file mode 100644 index 000000000..a8646488b --- /dev/null +++ b/tui/screens/workbenches/view.go @@ -0,0 +1,190 @@ +package workbenches + +import ( + "fmt" + "strings" + "time" + + "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), height) + title := "Workbenches" + if m.detail.ID != "" && (m.mode == modeDetail || (m.mode == modeResult && m.returnTo == modeDetail)) { + 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, height int) (string, string) { + tall := detailPanelHeight(height) + 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 { + m.prompt.SetWidth(max(8, width-8)) + return page.Panel(m.theme, "Follow-up prompt", []string{ + "Job " + value(m.detail.ID), + "Workbench " + value(m.detail.WorkbenchName), + "", + m.prompt.View(), + }, width, 9, true), "enter review · esc cancel" + } + if m.mode == modeReview { + inner := max(1, width-4) + lines := []string{ + "Job " + value(m.detail.ID), + "Workbench " + value(m.detail.WorkbenchName), + "", + m.theme.Muted.Render("Prompt"), + } + lines = append(lines, wrapLines(m.prompt.Value(), inner)...) + lines = append(lines, "", m.theme.Muted.Render("Queues a follow-up on the selected workbench job.")) + return page.Panel(m.theme, "Review follow-up", lines, 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 { + if m.err != nil { + inner := max(1, width-4) + lines := []string{m.theme.Danger.Render("✗ Follow-up failed"), ""} + lines = append(lines, wrapLines(m.err.Error(), inner)...) + return page.Panel(m.theme, "Result", lines, width, 12, true), "enter/esc back" + } + return page.Panel(m.theme, "Result", []string{ + m.theme.Success.Render("✓ Prompt queued"), + "", + "Prompt ID " + value(m.result.ID), + "Job ID " + value(m.result.WorkbenchID), + "Dequeues " + value(m.result.DequeueAt), + }, width, 10, true), "enter/esc back" + } + if m.mode == modeDetail { + lines := windowed(m.detailLines(width), m.detailOffset, tall) + return page.Panel(m.theme, "Job detail", lines, width, tall, true), "↑/↓ scroll · pgup/pgdn · 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 { + inner := max(1, width-4) + lines := []string{"Unable to load workbench jobs"} + lines = append(lines, wrapLines(m.err.Error(), inner)...) + return page.Panel(m.theme, "Recent workbench jobs", lines, 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), summary(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 · f follow up · / filter · r refresh · esc AI hub" +} + +func (m Model) detailLines(width int) []string { + inner := max(1, width-4) + lines := []string{ + "Workbench " + value(m.detail.WorkbenchName), + "Status " + value(m.detail.Status), + "Job ID " + value(m.detail.ID), + "Started " + formatTime(m.detail.InsertedAt), + "", + m.theme.Muted.Render("Prompt"), + } + return append(lines, wrapLines(m.detail.Prompt, inner)...) +} + +func windowed(lines []string, offset, panelH int) []string { + inner := max(1, panelH-2) + maxOff := max(0, len(lines)-inner) + if offset > maxOff { + offset = maxOff + } + if offset < 0 { + offset = 0 + } + if offset == 0 { + return lines + } + return lines[offset:] +} + +func wrapLines(text string, width int) []string { + if width < 1 { + width = 1 + } + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.TrimSpace(text) + if text == "" { + return []string{"—"} + } + out := make([]string, 0, strings.Count(text, "\n")+1) + for _, line := range strings.Split(text, "\n") { + line = strings.TrimRight(line, " ") + if line == "" { + out = append(out, "") + continue + } + out = append(out, strings.Split(ansi.Wrap(line, width, ""), "\n")...) + } + return out +} + +func formatTime(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "—" + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { + parsed, err := time.Parse(layout, v) + if err == nil { + return parsed.UTC().Format("2006-01-02 15:04 UTC") + } + } + return v +} + +func summary(v string) string { + v = strings.Join(strings.Fields(v), " ") + if v == "" { + return "—" + } + return ansi.Truncate(v, 60, "…") +} + +func value(v string) string { + if strings.TrimSpace(v) == "" { + return "—" + } + return strings.Join(strings.Fields(strings.ReplaceAll(v, "\n", " ")), " ") +} 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) + } + }) + } +}