diff --git a/.github/workflows/goreleaser.yaml b/.github/workflows/goreleaser.yaml index 4c69eb6..15111ed 100644 --- a/.github/workflows/goreleaser.yaml +++ b/.github/workflows/goreleaser.yaml @@ -21,7 +21,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6 with: - go-version: '>=1.25.3' + go-version: '>=1.25.8' - name: Run GoReleaser uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6 diff --git a/.github/workflows/lint-test.yaml b/.github/workflows/lint-test.yaml index 7bf0c2d..dc45ab2 100644 --- a/.github/workflows/lint-test.yaml +++ b/.github/workflows/lint-test.yaml @@ -13,7 +13,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6 with: - go-version: ">=1.25.3" + go-version: ">=1.25.8" - name: golangci-lint uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 #v9 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..601f451 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,69 @@ +# Contributing + +## UI Architecture + +`sitectl` supports two interaction modes: + +- one-off command execution such as `sitectl compose ps` +- an embedded TUI dashboard launched by running `sitectl` with no additional arguments + +Because both modes need to share behavior, interactive command UIs must be designed as composable Bubble Tea models instead of bespoke terminal flows. + +### Rule + +When a command needs interactive UI: + +- keep business logic separate from UI state and rendering +- make the UI self-contained inside the command or shared UI package +- ensure the same UI can run standalone or be embedded inside the dashboard + +In practice, command implementations should follow this split: + +- service layer: pure command logic and side effects +- UI layer: Bubble Tea model and Bubbles-based components +- Cobra layer: chooses between non-interactive execution and launching the UI + +### Required Libraries + +Interactive `sitectl` UIs should build on the shared TUI stack already in use: + +- `bubbletea` for state, events, and screen management +- `bubbles` for list, help, input, viewport, progress, and similar primitives +- `lipgloss` for styling and layout +- `bubblezone` for click targets and mouse hit detection where needed +- `harmonica` for motion and transitions where appropriate +- `ntcharts` for terminal charts where appropriate + +### What Not To Do + +Do not implement custom terminal widgets when the library stack already provides them. + +Examples: + +- do not hand-roll a select menu when `bubbles/list` fits +- do not hand-roll a text input when `bubbles/textinput` or `textarea` fits +- do not hand-roll help footers when `bubbles/help` fits +- do not hand-roll scroll containers when `bubbles/viewport` fits + +`lipgloss` should be used for presentation and composition, not as a replacement for Bubble Tea/Bubbles interaction primitives. + +### Shared Components + +Reusable interaction primitives should live in shared UI packages so commands and the dashboard can both consume them. + +Current direction: + +- shared prompt/select/input components belong in `pkg/ui` +- command-specific interactive screens can live near the command, but should still be Bubble Tea models +- older bespoke prompt implementations should be migrated to shared Bubble Tea/Bubbles components over time + +### Design Goal + +A command that has an interactive flow should be embeddable in the dashboard without rewriting its UI logic. + +That means a command UI should be structured so it can be: + +- launched directly from Cobra +- pushed or mounted inside the dashboard TUI + +If a proposed command UI cannot be reused that way, it should be redesigned before being added. diff --git a/README.md b/README.md index 58d8cf6..ca6b812 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,12 @@ While [Docker's native context feature](https://docs.docker.com/engine/manage-re - [islandora](https://github.com/libops/sitectl-isle) - [drupal](https://github.com/libops/sitectl-drupal) +## Contributing + +Contributor guidance, including the TUI and command UI architecture rules, lives in [CONTRIBUTING.md](./CONTRIBUTING.md). + ## Attribution - The `config` commands for setting contexts were heavily inspired by `kubectl` +- Adding a TUI was inspired by 37signals' [once](https://github.com/basecamp/once) CLI diff --git a/cmd/compose.go b/cmd/compose.go index 127ec0a..512265e 100644 --- a/cmd/compose.go +++ b/cmd/compose.go @@ -2,9 +2,7 @@ package cmd import ( "fmt" - "os" "os/exec" - "path/filepath" "slices" "github.com/libops/sitectl/pkg/config" @@ -90,10 +88,12 @@ Examples: } if context.DockerHostType == config.ContextLocal { - path := filepath.Join(context.ProjectDir, "docker-compose.yml") - _, err = os.Stat(path) + hasComposeProject, err := context.HasComposeProject() if err != nil { - helpers.ExitOnError(fmt.Errorf("docker-compose.yml not found at %s: %v", path, err)) + helpers.ExitOnError(fmt.Errorf("failed to inspect compose project in %s: %v", context.ProjectDir, err)) + } + if !hasComposeProject { + helpers.ExitOnError(fmt.Errorf("no compose project file found in %s (expected one of docker-compose.yml, docker-compose.yaml, compose.yml, compose.yaml)", context.ProjectDir)) } if err := context.EnsureTrackedComposeOverrideSymlink(); err != nil { return err diff --git a/cmd/config.go b/cmd/config.go index 453717e..49862f4 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -17,6 +17,7 @@ import ( corecomponent "github.com/libops/sitectl/pkg/component" "github.com/libops/sitectl/pkg/config" "github.com/libops/sitectl/pkg/helpers" + "github.com/libops/sitectl/pkg/plugin" sitevalidate "github.com/libops/sitectl/pkg/validate" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -386,6 +387,7 @@ var deleteContextCmd = &cobra.Command{ var createConfigInput = config.GetInput var createConfigPromptChoice = corecomponent.PromptChoice +var createConfigDiscoverPlugins = plugin.DiscoverInstalled var createConfigVerifyRemote = func(ctx *config.Context) error { return ctx.VerifyRemoteInput(true) } @@ -464,7 +466,7 @@ func runCreateConfig(cmd *cobra.Command, args []string) error { Input: createConfigInput, ProjectDirValidator: config.ValidateExistingComposeProjectDir, ContextNamePrompt: append( - strings.Split(corecomponent.RenderSection("sitectl context name", "Enter the sitectl context name to save for this existing Docker Compose project."), "\n"), + strings.Split(corecomponent.RenderSection("sitectl context name", "This is the saved sitectl target for this project. A good pattern is -, for example museum-local or museum-prod."), "\n"), "", corecomponent.RenderPromptLine("Context name [%s]: "), ), @@ -477,13 +479,43 @@ func runCreateConfig(cmd *cobra.Command, args []string) error { if err != nil { return err } + if !f.Changed("plugin") { + selectedPlugin, pluginErr := promptContextPlugin(ctx.Plugin) + if pluginErr != nil { + return pluginErr + } + if strings.TrimSpace(selectedPlugin) != "" && selectedPlugin != ctx.Plugin { + ctx.Plugin = selectedPlugin + if err := config.SaveContext(ctx, defaultContext); err != nil { + return err + } + } + } writeCreatedContextSummary(cmd, "Context created successfully", ctx) if err := promptAdditionalEnvironmentContexts(cmd, ctx); err != nil { return err } return nil } - return fmt.Errorf("no context name provided and current directory does not look like a docker compose project") + + contextName, err := createConfigInput( + append( + strings.Split(corecomponent.RenderSection( + "sitectl context name", + "Provide a admin label for this site and environment. Only provide alpha numeric characters and dashes. A good pattern is -, for example museum-local or museum-prod.", + ), "\n"), + "", + corecomponent.RenderPromptLine("Context name: "), + )..., + ) + if err != nil { + return err + } + contextName = strings.TrimSpace(contextName) + if contextName == "" { + return fmt.Errorf("context name cannot be empty") + } + args = []string{contextName} } cc, err := config.GetContext(args[0]) @@ -522,22 +554,54 @@ func runCreateConfig(cmd *cobra.Command, args []string) error { } } - t, err := createConfigInput(fmt.Sprintf("Is the context local (on this machine) or remote (on a VM)? [%s]: ", string(context.DockerHostType))) - if err != nil { - return err + if !f.Changed("type") { + t, err := createConfigInput(fmt.Sprintf("Is the context local (on this machine) or remote (on a VM)? [%s]: ", string(context.DockerHostType))) + if err != nil { + return err + } + if t != "" { + if t != "remote" && t != "local" { + return fmt.Errorf("unknown context type %q: valid values are local or remote", t) + } + context.DockerHostType = config.ContextType(t) + } } - if t != "" { - if t != "remote" && t != "local" { - return fmt.Errorf("unknown context type %q: valid values are local or remote", t) + if !f.Changed("project-dir") { + dir, err := createConfigInput(fmt.Sprintf("Full directory path to the project (directory where docker-compose.yml is located) [%s]: ", context.ProjectDir)) + if err != nil { + return err + } + if dir != "" { + context.ProjectDir = dir } - context.DockerHostType = config.ContextType(t) } - dir, err := createConfigInput(fmt.Sprintf("Full directory path to the project (directory where docker-compose.yml is located) [%s]: ", context.ProjectDir)) + context.ProjectDir, err = config.ExpandProjectDir(context.ProjectDir) if err != nil { return err } - if dir != "" { - context.ProjectDir = dir + if context.DockerHostType == config.ContextLocal && strings.TrimSpace(context.Environment) == "" { + context.Environment = "local" + } + if !f.Changed("plugin") && (strings.TrimSpace(context.Plugin) == "" || context.Plugin == "core") { + selectedPlugin, pluginErr := promptContextPlugin(context.Plugin) + if pluginErr != nil { + return pluginErr + } + if strings.TrimSpace(selectedPlugin) != "" { + context.Plugin = selectedPlugin + } + } + if strings.TrimSpace(context.ProjectName) == "" { + context.ProjectName = firstNonEmptyString(filepath.Base(context.ProjectDir), "docker-compose") + } + if !f.Changed("compose-project-name") && strings.TrimSpace(context.ComposeProjectName) == "" { + context.ComposeProjectName = firstNonEmptyString(config.DetectComposeProjectName(context.ProjectDir), context.ProjectName) + } + if !f.Changed("compose-network") && strings.TrimSpace(context.ComposeNetwork) == "" { + context.ComposeNetwork = config.DetectComposeNetworkName(context.ProjectDir, context.EffectiveComposeProjectName()) + } + if strings.TrimSpace(context.Site) == "" { + context.Site = firstNonEmptyString(context.ProjectName, context.Name) } if context.DockerHostType == config.ContextRemote { @@ -576,6 +640,41 @@ func runCreateConfig(cmd *cobra.Command, args []string) error { return nil } +func promptContextPlugin(defaultPlugin string) (string, error) { + choices := []corecomponent.Choice{{ + Value: "core", + Label: "core", + Help: "No stack-specific plugin. Use base sitectl behavior.", + Aliases: []string{"1"}, + }} + for _, discovered := range createConfigDiscoverPlugins() { + name := strings.TrimSpace(discovered.Name) + if name == "" || name == "core" { + continue + } + choices = append(choices, corecomponent.Choice{ + Value: name, + Label: name, + Help: firstNonEmptyString(discovered.Description, "Use the "+name+" plugin for this site."), + Aliases: nil, + }) + } + if len(choices) == 1 { + return firstNonEmptyString(defaultPlugin, "core"), nil + } + selected, err := createConfigPromptChoice( + "plugin", + choices, + firstNonEmptyString(strings.TrimSpace(defaultPlugin), "core"), + createConfigInput, + strings.Split(corecomponent.RenderSection("plugin", "If this project belongs to a known sitectl plugin, pick it here. For example, an Islandora stack would usually use the isle plugin."), "\n")..., + ) + if err != nil { + return "", err + } + return strings.TrimSpace(selected), nil +} + func promptAdditionalEnvironmentContexts(cmd *cobra.Command, localCtx *config.Context) error { if localCtx == nil || localCtx.DockerHostType != config.ContextLocal { return nil @@ -657,6 +756,16 @@ func promptRemoteEnvironmentContext(localCtx, previousRemote *config.Context) (* sshKey := firstNonEmptyString(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.SSHKeyPath }), defaultKey) dockerSocket := firstNonEmptyString(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.DockerSocket }), "/var/run/docker.sock") projectName := firstNonEmptyString(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.ProjectName }), localCtx.ProjectName, "docker-compose") + composeProjectName := firstNonEmptyString( + remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.ComposeProjectName }), + localCtx.EffectiveComposeProjectName(), + projectName, + ) + composeNetwork := firstNonEmptyString( + remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.ComposeNetwork }), + localCtx.ComposeNetwork, + localCtx.EffectiveComposeNetwork(), + ) runSudo := remoteContextBool(previousRemote, func(ctx *config.Context) bool { return ctx.RunSudo }, localCtx.RunSudo) for { @@ -685,6 +794,8 @@ func promptRemoteEnvironmentContext(localCtx, previousRemote *config.Context) (* Environment: environment, ProjectDir: strings.TrimSpace(projectDir), ProjectName: firstNonEmptyString(localCtx.ProjectName, "docker-compose"), + ComposeProjectName: composeProjectName, + ComposeNetwork: composeNetwork, SSHHostname: strings.TrimSpace(hostname), SSHUser: sshUser, SSHPort: sshPort, @@ -699,7 +810,12 @@ func promptRemoteEnvironmentContext(localCtx, previousRemote *config.Context) (* DatabaseName: localCtx.DatabaseName, } remoteCtx.ProjectName = projectName + remoteCtx.ComposeProjectName = composeProjectName + remoteCtx.ComposeNetwork = composeNetwork remoteCtx.RunSudo = runSudo + if detected := config.DetectContextComposeNetwork(remoteCtx); detected != "" { + remoteCtx.ComposeNetwork = detected + } if err := createConfigVerifyRemote(remoteCtx); err != nil { retry, retryErr := createConfigPromptChoice( @@ -856,7 +972,7 @@ func validateRemoteDockerAccess(ctx *config.Context) error { if promptErr != nil { return promptErr } - projectName, promptErr := promptRequiredValueWithDefault("Docker Compose project name", firstNonEmptyString(ctx.ProjectName, "docker-compose")) + projectName, promptErr := promptRequiredValueWithDefault("Logical project name", firstNonEmptyString(ctx.ProjectName, "docker-compose")) if promptErr != nil { return promptErr } @@ -870,6 +986,8 @@ func validateRemoteDockerAccess(ctx *config.Context) error { } ctx.ProjectDir = projectDir ctx.ProjectName = projectName + ctx.ComposeProjectName = firstNonEmptyString(ctx.ComposeProjectName, projectName) + ctx.ComposeNetwork = firstNonEmptyString(config.DetectContextComposeNetwork(ctx), ctx.ComposeNetwork, ctx.EffectiveComposeNetwork()) ctx.DockerSocket = dockerSocket ctx.RunSudo = runSudo continue @@ -1047,6 +1165,12 @@ func inheritNewContextDefaultsFromActive(target, active *config.Context, flags * if !flags.Changed("project-name") && active.ProjectName != "" && target.ProjectName == "docker-compose" { target.ProjectName = active.ProjectName } + if !flags.Changed("compose-project-name") && active.ComposeProjectName != "" && target.ComposeProjectName == "" { + target.ComposeProjectName = active.ComposeProjectName + } + if !flags.Changed("compose-network") && active.ComposeNetwork != "" && target.ComposeNetwork == "" { + target.ComposeNetwork = active.ComposeNetwork + } if !flags.Changed("compose-file") && len(target.ComposeFile) == 0 && len(active.ComposeFile) > 0 { target.ComposeFile = append([]string{}, active.ComposeFile...) } diff --git a/cmd/config_create_test.go b/cmd/config_create_test.go index 09e612f..2e8e89e 100644 --- a/cmd/config_create_test.go +++ b/cmd/config_create_test.go @@ -10,6 +10,7 @@ import ( corecomponent "github.com/libops/sitectl/pkg/component" "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/plugin" "github.com/spf13/cobra" ) @@ -38,20 +39,26 @@ func TestRunCreateConfigAutodetectsCurrentComposeProject(t *testing.T) { oldInput := createConfigInput oldPromptChoice := createConfigPromptChoice + oldDiscoverPlugins := createConfigDiscoverPlugins oldVerifyRemote := createConfigVerifyRemote oldProjectDirExists := createConfigProjectDirExists oldRunComposePS := createConfigRunComposePS t.Cleanup(func() { createConfigInput = oldInput createConfigPromptChoice = oldPromptChoice + createConfigDiscoverPlugins = oldDiscoverPlugins createConfigVerifyRemote = oldVerifyRemote createConfigProjectDirExists = oldProjectDirExists createConfigRunComposePS = oldRunComposePS }) + createConfigDiscoverPlugins = func() []plugin.InstalledPlugin { return nil } createConfigVerifyRemote = func(ctx *config.Context) error { return nil } createConfigProjectDirExists = func(ctx *config.Context) (bool, error) { return true, nil } createConfigRunComposePS = func(ctx *config.Context) error { return nil } createConfigPromptChoice = func(name string, choices []corecomponent.Choice, defaultValue string, input corecomponent.InputFunc, sections ...string) (string, error) { + if name == "plugin" { + return "core", nil + } if name == "add-environment" { return "no", nil } @@ -125,22 +132,30 @@ func TestRunCreateConfigAutodetectsCurrentComposeProjectAndAddsRemoteEnvironment oldInput := createConfigInput oldPromptChoice := createConfigPromptChoice + oldDiscoverPlugins := createConfigDiscoverPlugins oldVerifyRemote := createConfigVerifyRemote oldProjectDirExists := createConfigProjectDirExists oldRunComposePS := createConfigRunComposePS t.Cleanup(func() { createConfigInput = oldInput createConfigPromptChoice = oldPromptChoice + createConfigDiscoverPlugins = oldDiscoverPlugins createConfigVerifyRemote = oldVerifyRemote createConfigProjectDirExists = oldProjectDirExists createConfigRunComposePS = oldRunComposePS }) + createConfigDiscoverPlugins = func() []plugin.InstalledPlugin { + return []plugin.InstalledPlugin{{Name: "isle", Description: "Islandora utilities"}} + } createConfigVerifyRemote = func(ctx *config.Context) error { return nil } createConfigProjectDirExists = func(ctx *config.Context) (bool, error) { return true, nil } createConfigRunComposePS = func(ctx *config.Context) error { return nil } choiceCalls := 0 createConfigPromptChoice = func(name string, choices []corecomponent.Choice, defaultValue string, input corecomponent.InputFunc, sections ...string) (string, error) { + if name == "plugin" { + return "isle", nil + } if name != "add-environment" { t.Fatalf("unexpected choice prompt: %s", name) } @@ -207,6 +222,249 @@ func TestRunCreateConfigAutodetectsCurrentComposeProjectAndAddsRemoteEnvironment if !strings.Contains(out.String(), "ENVIRONMENT CONTEXT CREATED SUCCESSFULLY") { t.Fatalf("expected remote environment output, got:\n%s", out.String()) } + if localCtx.Plugin != "isle" { + t.Fatalf("expected local plugin isle, got %q", localCtx.Plugin) + } + if localCtx.Environment != "local" { + t.Fatalf("expected local environment local, got %q", localCtx.Environment) + } + if localCtx.ComposeProjectName == "" { + t.Fatalf("expected compose project name to be derived") + } +} + +func TestRunCreateConfigPromptsForContextWhenCwdIsNotComposeProject(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + projectDir := filepath.Join(tempHome, "existing-site") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("MkdirAll(projectDir) error = %v", err) + } + if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil { + t.Fatalf("WriteFile(docker-compose.yml) error = %v", err) + } + + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + if err := os.Chdir(tempHome); err != nil { + t.Fatalf("Chdir(tempHome) error = %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(oldWd) + }) + + oldInput := createConfigInput + oldPromptChoice := createConfigPromptChoice + oldDiscoverPlugins := createConfigDiscoverPlugins + oldVerifyRemote := createConfigVerifyRemote + oldProjectDirExists := createConfigProjectDirExists + oldRunComposePS := createConfigRunComposePS + t.Cleanup(func() { + createConfigInput = oldInput + createConfigPromptChoice = oldPromptChoice + createConfigDiscoverPlugins = oldDiscoverPlugins + createConfigVerifyRemote = oldVerifyRemote + createConfigProjectDirExists = oldProjectDirExists + createConfigRunComposePS = oldRunComposePS + }) + + createConfigDiscoverPlugins = func() []plugin.InstalledPlugin { return nil } + createConfigVerifyRemote = func(ctx *config.Context) error { return nil } + createConfigProjectDirExists = func(ctx *config.Context) (bool, error) { return true, nil } + createConfigRunComposePS = func(ctx *config.Context) error { return nil } + createConfigPromptChoice = func(name string, choices []corecomponent.Choice, defaultValue string, input corecomponent.InputFunc, sections ...string) (string, error) { + if name == "plugin" { + return "core", nil + } + if name == "add-environment" { + return "no", nil + } + t.Fatalf("unexpected choice prompt: %s", name) + return "", nil + } + + prompts := []string{ + "museum-dev", + "", + projectDir, + } + createConfigInput = func(question ...string) (string, error) { + if len(prompts) == 0 { + t.Fatalf("unexpected prompt: %v", question) + } + value := prompts[0] + prompts = prompts[1:] + return value, nil + } + + cmd := &cobra.Command{Use: "create"} + config.SetCommandFlags(cmd.Flags()) + cmd.Flags().Bool("default", true, "") + + if err := runCreateConfig(cmd, nil); err != nil { + t.Fatalf("runCreateConfig() error = %v", err) + } + + ctx, err := config.GetContext("museum-dev") + if err != nil { + t.Fatalf("GetContext(museum-dev) error = %v", err) + } + if ctx.ProjectDir != projectDir { + t.Fatalf("expected project dir %q, got %q", projectDir, ctx.ProjectDir) + } + if ctx.DockerHostType != config.ContextLocal { + t.Fatalf("expected local context, got %q", ctx.DockerHostType) + } +} + +func TestRunCreateConfigSkipsTypeAndProjectDirPromptsWhenFlagsProvided(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + projectDir := filepath.Join(tempHome, "existing-site") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("MkdirAll(projectDir) error = %v", err) + } + if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil { + t.Fatalf("WriteFile(docker-compose.yml) error = %v", err) + } + + oldInput := createConfigInput + oldPromptChoice := createConfigPromptChoice + oldDiscoverPlugins := createConfigDiscoverPlugins + oldVerifyRemote := createConfigVerifyRemote + oldProjectDirExists := createConfigProjectDirExists + oldRunComposePS := createConfigRunComposePS + t.Cleanup(func() { + createConfigInput = oldInput + createConfigPromptChoice = oldPromptChoice + createConfigDiscoverPlugins = oldDiscoverPlugins + createConfigVerifyRemote = oldVerifyRemote + createConfigProjectDirExists = oldProjectDirExists + createConfigRunComposePS = oldRunComposePS + }) + + createConfigDiscoverPlugins = func() []plugin.InstalledPlugin { return nil } + createConfigVerifyRemote = func(ctx *config.Context) error { return nil } + createConfigProjectDirExists = func(ctx *config.Context) (bool, error) { return true, nil } + createConfigRunComposePS = func(ctx *config.Context) error { return nil } + createConfigPromptChoice = func(name string, choices []corecomponent.Choice, defaultValue string, input corecomponent.InputFunc, sections ...string) (string, error) { + if name == "plugin" { + return "core", nil + } + if name == "add-environment" { + return "no", nil + } + t.Fatalf("unexpected choice prompt: %s", name) + return "", nil + } + + promptCount := 0 + createConfigInput = func(question ...string) (string, error) { + promptCount++ + t.Fatalf("did not expect text prompt when type and project-dir are provided: %v", question) + return "", nil + } + + cmd := &cobra.Command{Use: "create"} + config.SetCommandFlags(cmd.Flags()) + cmd.Flags().Bool("default", true, "") + if err := cmd.Flags().Set("type", "local"); err != nil { + t.Fatalf("Set(type) error = %v", err) + } + if err := cmd.Flags().Set("project-dir", projectDir); err != nil { + t.Fatalf("Set(project-dir) error = %v", err) + } + + if err := runCreateConfig(cmd, []string{"museum-dev"}); err != nil { + t.Fatalf("runCreateConfig() error = %v", err) + } + if promptCount != 0 { + t.Fatalf("expected no prompts, got %d", promptCount) + } + + ctx, err := config.GetContext("museum-dev") + if err != nil { + t.Fatalf("GetContext(museum-dev) error = %v", err) + } + if ctx.DockerHostType != config.ContextLocal { + t.Fatalf("expected local context, got %q", ctx.DockerHostType) + } + if ctx.ProjectDir != projectDir { + t.Fatalf("expected project dir %q, got %q", projectDir, ctx.ProjectDir) + } +} + +func TestRunCreateConfigExpandsProjectDirFlag(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + projectDir := filepath.Join(tempHome, "existing-site") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("MkdirAll(projectDir) error = %v", err) + } + if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil { + t.Fatalf("WriteFile(docker-compose.yml) error = %v", err) + } + + oldInput := createConfigInput + oldPromptChoice := createConfigPromptChoice + oldDiscoverPlugins := createConfigDiscoverPlugins + oldVerifyRemote := createConfigVerifyRemote + oldProjectDirExists := createConfigProjectDirExists + oldRunComposePS := createConfigRunComposePS + t.Cleanup(func() { + createConfigInput = oldInput + createConfigPromptChoice = oldPromptChoice + createConfigDiscoverPlugins = oldDiscoverPlugins + createConfigVerifyRemote = oldVerifyRemote + createConfigProjectDirExists = oldProjectDirExists + createConfigRunComposePS = oldRunComposePS + }) + + createConfigDiscoverPlugins = func() []plugin.InstalledPlugin { return nil } + createConfigVerifyRemote = func(ctx *config.Context) error { return nil } + createConfigProjectDirExists = func(ctx *config.Context) (bool, error) { return true, nil } + createConfigRunComposePS = func(ctx *config.Context) error { return nil } + createConfigPromptChoice = func(name string, choices []corecomponent.Choice, defaultValue string, input corecomponent.InputFunc, sections ...string) (string, error) { + if name == "plugin" { + return "core", nil + } + if name == "add-environment" { + return "no", nil + } + t.Fatalf("unexpected choice prompt: %s", name) + return "", nil + } + createConfigInput = func(question ...string) (string, error) { + t.Fatalf("did not expect prompt: %v", question) + return "", nil + } + + cmd := &cobra.Command{Use: "create"} + config.SetCommandFlags(cmd.Flags()) + cmd.Flags().Bool("default", true, "") + if err := cmd.Flags().Set("type", "local"); err != nil { + t.Fatalf("Set(type) error = %v", err) + } + if err := cmd.Flags().Set("project-dir", "$HOME/existing-site"); err != nil { + t.Fatalf("Set(project-dir) error = %v", err) + } + + if err := runCreateConfig(cmd, []string{"museum-dev"}); err != nil { + t.Fatalf("runCreateConfig() error = %v", err) + } + + ctx, err := config.GetContext("museum-dev") + if err != nil { + t.Fatalf("GetContext(museum-dev) error = %v", err) + } + if ctx.ProjectDir != projectDir { + t.Fatalf("expected expanded project dir %q, got %q", projectDir, ctx.ProjectDir) + } } func TestRunCreateConfigRepromptsRemoteConnectionDetailsAfterVerificationFailure(t *testing.T) { @@ -234,17 +492,20 @@ func TestRunCreateConfigRepromptsRemoteConnectionDetailsAfterVerificationFailure oldInput := createConfigInput oldPromptChoice := createConfigPromptChoice + oldDiscoverPlugins := createConfigDiscoverPlugins oldVerifyRemote := createConfigVerifyRemote oldProjectDirExists := createConfigProjectDirExists oldRunComposePS := createConfigRunComposePS t.Cleanup(func() { createConfigInput = oldInput createConfigPromptChoice = oldPromptChoice + createConfigDiscoverPlugins = oldDiscoverPlugins createConfigVerifyRemote = oldVerifyRemote createConfigProjectDirExists = oldProjectDirExists createConfigRunComposePS = oldRunComposePS }) + createConfigDiscoverPlugins = func() []plugin.InstalledPlugin { return nil } verifyCalls := 0 createConfigVerifyRemote = func(ctx *config.Context) error { verifyCalls++ @@ -260,6 +521,8 @@ func TestRunCreateConfigRepromptsRemoteConnectionDetailsAfterVerificationFailure createConfigPromptChoice = func(name string, choices []corecomponent.Choice, defaultValue string, input corecomponent.InputFunc, sections ...string) (string, error) { choiceCalls[name]++ switch name { + case "plugin": + return "core", nil case "add-environment": if choiceCalls[name] == 1 { return "yes", nil @@ -347,17 +610,20 @@ func TestRunCreateConfigRepromptsDockerSettingsAfterComposePSFailure(t *testing. oldInput := createConfigInput oldPromptChoice := createConfigPromptChoice + oldDiscoverPlugins := createConfigDiscoverPlugins oldVerifyRemote := createConfigVerifyRemote oldProjectDirExists := createConfigProjectDirExists oldRunComposePS := createConfigRunComposePS t.Cleanup(func() { createConfigInput = oldInput createConfigPromptChoice = oldPromptChoice + createConfigDiscoverPlugins = oldDiscoverPlugins createConfigVerifyRemote = oldVerifyRemote createConfigProjectDirExists = oldProjectDirExists createConfigRunComposePS = oldRunComposePS }) + createConfigDiscoverPlugins = func() []plugin.InstalledPlugin { return nil } createConfigVerifyRemote = func(ctx *config.Context) error { return nil } createConfigProjectDirExists = func(ctx *config.Context) (bool, error) { return true, nil } composeCalls := 0 @@ -385,6 +651,8 @@ func TestRunCreateConfigRepromptsDockerSettingsAfterComposePSFailure(t *testing. createConfigPromptChoice = func(name string, choices []corecomponent.Choice, defaultValue string, input corecomponent.InputFunc, sections ...string) (string, error) { choiceCalls[name]++ switch name { + case "plugin": + return "core", nil case "add-environment": if choiceCalls[name] == 1 { return "yes", nil diff --git a/cmd/root.go b/cmd/root.go index 3ec3261..2c10fc3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,13 +5,13 @@ import ( "fmt" "log/slog" "os" - "os/exec" - "path/filepath" "strings" "syscall" "charm.land/fang/v2" "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/plugin" + "github.com/libops/sitectl/pkg/tui" "github.com/spf13/cobra" ) @@ -42,6 +42,9 @@ var RootCmd = &cobra.Command{ return nil }, + RunE: func(cmd *cobra.Command, args []string) error { + return tui.Run() + }, } func Execute() { @@ -76,52 +79,24 @@ func init() { } func discoverAndRegisterPlugins() { - path := os.Getenv("PATH") - paths := strings.SplitSeq(path, string(os.PathListSeparator)) - - for p := range paths { - files, err := os.ReadDir(p) - if err != nil { - continue // Ignore directories that can't be read - } - - for _, f := range files { - if !f.IsDir() && strings.HasPrefix(f.Name(), "sitectl-") && f.Name() != "sitectl" { - pluginName := strings.TrimPrefix(f.Name(), "sitectl-") - pluginPath := filepath.Join(p, f.Name()) - - // Try to get plugin description from metadata - description := getPluginDescription(pluginPath, pluginName) - - pluginCmd := &cobra.Command{ - Use: pluginName, - Short: description, - RunE: func(cmd *cobra.Command, args []string) error { - err := syscall.Exec(pluginPath, append([]string{f.Name()}, args...), os.Environ()) - if err != nil { - return fmt.Errorf("failed to execute plugin %q: %w", pluginName, err) - } - return nil - }, - DisableFlagParsing: true, + for _, discovered := range plugin.DiscoverInstalled() { + pluginName := discovered.Name + pluginPath := discovered.Path + binaryName := discovered.BinaryName + description := discovered.Description + + pluginCmd := &cobra.Command{ + Use: pluginName, + Short: description, + RunE: func(cmd *cobra.Command, args []string) error { + err := syscall.Exec(pluginPath, append([]string{binaryName}, args...), os.Environ()) + if err != nil { + return fmt.Errorf("failed to execute plugin %q: %w", pluginName, err) } - RootCmd.AddCommand(pluginCmd) - } + return nil + }, + DisableFlagParsing: true, } + RootCmd.AddCommand(pluginCmd) } } - -// getPluginDescription attempts to fetch plugin metadata -func getPluginDescription(pluginPath, pluginName string) string { - // Try to execute plugin with plugin-info command to get description - cmd := exec.Command(pluginPath, "plugin-info") - _, err := cmd.Output() - - // If we can't get metadata, use a default description - if err != nil { - return fmt.Sprintf("the %s plugin", pluginName) - } - - // For now, return default - a more complete implementation would parse the output - return fmt.Sprintf("the %s plugin", pluginName) -} diff --git a/docs/embed.go b/docs/embed.go new file mode 100644 index 0000000..844958e --- /dev/null +++ b/docs/embed.go @@ -0,0 +1,73 @@ +package docs + +import ( + "embed" + "fmt" + "io/fs" + "path/filepath" + "sort" + "strings" +) + +//go:embed index.md tour/*.md +var content embed.FS + +type TourPane struct { + Slug string + Title string + Markdown string +} + +func LoadTour() ([]TourPane, error) { + panes := make([]TourPane, 0, 4) + + indexData, err := content.ReadFile("index.md") + if err != nil { + return nil, fmt.Errorf("read embedded tour index: %w", err) + } + panes = append(panes, TourPane{ + Slug: "index", + Title: firstHeading(string(indexData), "Tour"), + Markdown: string(indexData), + }) + + entries, err := fs.ReadDir(content, "tour") + if err != nil { + return nil, fmt.Errorf("read embedded tour docs: %w", err) + } + + names := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".md" { + continue + } + names = append(names, entry.Name()) + } + sort.Strings(names) + + for _, name := range names { + path := filepath.Join("tour", name) + data, err := content.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read embedded tour doc %q: %w", path, err) + } + markdown := string(data) + panes = append(panes, TourPane{ + Slug: strings.TrimSuffix(name, filepath.Ext(name)), + Title: firstHeading(markdown, strings.TrimSuffix(name, filepath.Ext(name))), + Markdown: markdown, + }) + } + + return panes, nil +} + +func firstHeading(markdown, fallback string) string { + for line := range strings.SplitSeq(markdown, "\n") { + line = strings.TrimSpace(line) + if after, ok := strings.CutPrefix(line, "# "); ok { + return strings.TrimSpace(after) + } + } + return strings.TrimSpace(fallback) +} diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..cf3323a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,8 @@ +# sitectl + +`sitectl` has four major features: + +1. The Terminal UI +2. Contexts to help sitectl understand your sites and environments +3. Plugins for more features for common tech stacks +4. Components to help site operators manage their site diff --git a/docs/tour/01-tui.md b/docs/tour/01-tui.md new file mode 100644 index 0000000..8cf1832 --- /dev/null +++ b/docs/tour/01-tui.md @@ -0,0 +1,10 @@ +# The sitectl Terminal UI + +The `sitectl` Terminal UI is the main interface for managing a site and its environments. + +It gives you one place to: + +- inspect environments +- run commands against a specific environment +- install new sites +- move between local and remote environments for the same site diff --git a/docs/tour/02-contexts.md b/docs/tour/02-contexts.md new file mode 100644 index 0000000..1dde6b8 --- /dev/null +++ b/docs/tour/02-contexts.md @@ -0,0 +1,14 @@ +# Contexts + +`sitectl` organizes around a **site** and its **environments** into what's known as a **context** + +- A **site** is the project itself. e.g. your Drupal site +- An **environment** is where that site runs: `local`, `staging`, `prod`, and so on. +- A **context** is the saved connection information for the given site environment. + +Examples: + +- `museum-local`: the museum site on your laptop +- `museum-prod`: the same site on a remote server + +Contexts tell `sitectl` where Docker Compose lives and how to reach it. diff --git a/docs/tour/03-plugins.md b/docs/tour/03-plugins.md new file mode 100644 index 0000000..4126cea --- /dev/null +++ b/docs/tour/03-plugins.md @@ -0,0 +1,10 @@ +# Plugins + +Plugins extend `sitectl` with stack-specific commands and create flows. + +Examples: + +- `drupal` adds Drupal-oriented utilities +- `isle` adds Islandora and ISLE workflows + +The core binary discovers installed plugins and exposes them as `sitectl ...`. diff --git a/docs/tour/04-components.md b/docs/tour/04-components.md new file mode 100644 index 0000000..ac91eaf --- /dev/null +++ b/docs/tour/04-components.md @@ -0,0 +1,23 @@ +# Components + +Components describe optional parts of a stack in a structured way. + +They are how `sitectl` marries infrastructure settings and app-specific settings into one reviewed configuration. + +Instead of treating a stack as one large blob, `sitectl` lets each component carry its own defaults, follow-up questions, and operator guidance. + +Common component states are: + +- `enabled`: the component is part of the stack +- `disabled`: the component is not used +- `superseded`: another component replaces its role +- `distributed`: responsibility is moved out to external or split services + +When you change a default component, you should expect operator implications such as: + +- different infrastructure requirements +- different app-level wiring or environment values +- data movement or migration work +- different maintenance and failure modes + +That is why component review matters: changing one default can affect both platform behavior and application behavior at the same time. diff --git a/go.mod b/go.mod index 5087b0d..1553089 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,18 @@ module github.com/libops/sitectl -go 1.25.3 +go 1.25.8 require ( + charm.land/bubbles/v2 v2.0.0 + charm.land/bubbletea/v2 v2.0.2 charm.land/fang/v2 v2.0.1 + charm.land/glamour/v2 v2.0.0 charm.land/lipgloss/v2 v2.0.1 + github.com/NimbleMarkets/ntcharts/v2 v2.0.0 github.com/docker/docker v28.5.2+incompatible github.com/joho/godotenv v1.5.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 + github.com/lrstanley/bubblezone/v2 v2.0.0 github.com/pkg/sftp v1.13.10 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -19,11 +24,15 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect @@ -34,15 +43,18 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect @@ -57,7 +69,10 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect diff --git a/go.sum b/go.sum index e116eca..ac4a346 100644 --- a/go.sum +++ b/go.sum @@ -1,27 +1,47 @@ +charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= +charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= +charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/NimbleMarkets/ntcharts/v2 v2.0.0 h1:nKsiSvjsBtJvAp6Sj8SPqXUCxuBjyacYQlCuCCh0o4c= +github.com/NimbleMarkets/ntcharts/v2 v2.0.0/go.mod h1:gigw4ggjQaWojQAkSbhZ6fezCPiocBMw6MexapLfXZ4= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 h1:Af/L28Xh+pddhouT/6lJ7IAIYfu5tWJOB0iqt+mXsYM= +github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= 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/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= 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= @@ -43,6 +63,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -60,8 +82,12 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -74,10 +100,16 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lrstanley/bubblezone/v2 v2.0.0 h1:pMb9fHKs0slJF6OrzQ2hEgWusqyl9VU/S0UZ5hyh7ZA= +github.com/lrstanley/bubblezone/v2 v2.0.0/go.mod h1:yV/QTjcm4Zu5cqvGvdHi7xVUfnB36w/SafOuDp57dgY= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= @@ -113,6 +145,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -124,6 +158,11 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= diff --git a/pkg/component/compose.go b/pkg/component/compose.go index 50d36a3..eb790b3 100644 --- a/pkg/component/compose.go +++ b/pkg/component/compose.go @@ -78,6 +78,67 @@ func (c *ComposeProject) AddDefinitions(defs *ComposeDefinitions) { mergeIntoSection(c.root, "configs", defs.Configs) } +func (c *ComposeProject) SetDefinition(section, name string, value any) { + if c == nil || strings.TrimSpace(section) == "" || strings.TrimSpace(name) == "" { + return + } + target := nestedMap(c.root[section]) + if target == nil { + target = map[string]any{} + } + target[name] = value + c.root[section] = target +} + +func (c *ComposeProject) DeleteDefinition(section, name string) bool { + if c == nil || strings.TrimSpace(section) == "" || strings.TrimSpace(name) == "" { + return false + } + target := nestedMap(c.root[section]) + if target == nil { + return false + } + if _, ok := target[name]; !ok { + return false + } + delete(target, name) + if len(target) == 0 { + delete(c.root, section) + } else { + c.root[section] = target + } + return true +} + +func (d *ComposeDefinitions) Definition(section, name string) (any, bool) { + if d == nil { + return nil, false + } + entries := d.section(section) + if entries == nil { + return nil, false + } + value, ok := entries[name] + return value, ok +} + +func (d *ComposeDefinitions) section(section string) map[string]any { + switch section { + case "services": + return d.Services + case "networks": + return d.Networks + case "volumes": + return d.Volumes + case "secrets": + return d.Secrets + case "configs": + return d.Configs + default: + return nil + } +} + func (c *ComposeProject) PruneUnusedResources() { for _, section := range []string{"volumes", "networks", "secrets", "configs"} { entries := nestedMap(c.root[section]) diff --git a/pkg/component/compose_file.go b/pkg/component/compose_file.go index 7076ba3..8099ea6 100644 --- a/pkg/component/compose_file.go +++ b/pkg/component/compose_file.go @@ -127,6 +127,37 @@ func (c *ComposeFile) DeleteSectionEntry(section, key string) error { return c.deleteSectionEntry(section, key) } +func (c *ComposeFile) SectionEntryBlock(section, key string) (string, bool) { + sectionIdx, ok := findMapKey(c.lines, 0, section, 0) + if !ok { + return "", false + } + entryIdx, ok := findMapKey(c.lines, sectionIdx+1, key, 2) + if !ok { + return "", false + } + end := findBlockEnd(c.lines, entryIdx, 2) + return strings.Join(c.lines[entryIdx:end], "\n"), true +} + +func (c *ComposeFile) AddSectionEntryBlock(section, key, block string) error { + if strings.TrimSpace(block) == "" { + return fmt.Errorf("section block for %s.%s is empty", section, key) + } + if _, ok := c.SectionEntryBlock(section, key); ok { + return nil + } + + sectionIdx, ok := findMapKey(c.lines, 0, section, 0) + if !ok { + c.lines = append(c.lines, section+":") + sectionIdx = len(c.lines) - 1 + } + insertAt := findBlockEnd(c.lines, sectionIdx, 0) + c.lines = insertLines(c.lines, insertAt, strings.Split(strings.TrimRight(block, "\n"), "\n")) + return nil +} + func (c *ComposeFile) DeleteServiceEnv(service, key string) error { serviceIdx, ok := c.findService(service) if !ok { diff --git a/pkg/component/prompt.go b/pkg/component/prompt.go index e541f78..12b7aa6 100644 --- a/pkg/component/prompt.go +++ b/pkg/component/prompt.go @@ -7,6 +7,7 @@ import ( lipgloss "charm.land/lipgloss/v2" "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/ui" "golang.org/x/term" ) @@ -49,7 +50,6 @@ var ( okStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("42")) failStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("203")) infoStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")) - selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")) commandStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("238")). @@ -147,96 +147,21 @@ func promptChoiceInteractive(name string, choices []Choice, defaultValue string, if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) { return "", false, nil } - if len(choices) == 0 { - return "", false, fmt.Errorf("no choices configured for %s", name) - } - - fd := int(os.Stdin.Fd()) - oldState, err := term.MakeRaw(fd) - if err != nil { - return "", false, nil - } - defer func() { - _ = term.Restore(fd, oldState) - }() - - selected := defaultChoiceIndex(choices, defaultValue) - customInput := defaultCustomInput(choices, defaultValue) - lines := renderInteractiveChoiceLines(choices, selected, customInput) - hint := truncateStyledLine(mutedStyle.Render("Use up/down to select, Enter to confirm"), promptRenderWidth()) - - if len(sections) > 0 { - fmt.Fprint(os.Stdout, strings.Join(sections, "\r\n")+"\r\n") - } - staticLines := []string{} - staticLines = append(staticLines, hint, "") - fmt.Fprint(os.Stdout, "\r\n"+strings.Join(staticLines, "\r\n")+"\r\n") - fmt.Fprint(os.Stdout, strings.Join(lines, "\r\n")+"\r\n") - renderedLineCount := len(lines) - - for { - var buf [3]byte - n, err := os.Stdin.Read(buf[:]) - if err != nil { - fmt.Fprintln(os.Stdout) - fmt.Fprintln(os.Stdout) - return "", true, err - } - if n == 0 { - continue - } - - switch { - case n == 1 && (buf[0] == '\r' || buf[0] == '\n'): - fmt.Fprint(os.Stdout, "\r\n\r\n") - if choices[selected].AllowCustomInput { - value := strings.TrimSpace(customInput) - if value != "" { - return value, true, nil - } - } - return choices[selected].Value, true, nil - case n == 1 && buf[0] == 3: - fmt.Fprint(os.Stdout, "\r\n\r\n") - return "", true, fmt.Errorf("prompt cancelled") - case n == 1 && (buf[0] == 127 || buf[0] == 8): - if choices[selected].AllowCustomInput && len(customInput) > 0 { - customInput = customInput[:len(customInput)-1] - } - case n >= 3 && buf[0] == 27 && buf[1] == 91 && buf[2] == 65: - selected = (selected - 1 + len(choices)) % len(choices) - case n >= 3 && buf[0] == 27 && buf[1] == 91 && buf[2] == 66: - selected = (selected + 1) % len(choices) - default: - if choices[selected].AllowCustomInput && isPrintableInput(buf[:n]) { - customInput += string(buf[:n]) - } - } - - lines = renderInteractiveChoiceLines(choices, selected, customInput) - redrawInteractiveChoiceLines(lines, renderedLineCount) - renderedLineCount = len(lines) - } -} - -func redrawInteractiveChoiceLines(lines []string, previousLineCount int) { - if previousLineCount > 0 { - fmt.Fprintf(os.Stdout, "\r\x1b[%dA", previousLineCount) - } else { - fmt.Fprint(os.Stdout, "\r") - } - for i := 0; i < previousLineCount; i++ { - fmt.Fprint(os.Stdout, "\x1b[2K") - if i < previousLineCount-1 { - fmt.Fprint(os.Stdout, "\x1b[1B\r") - } - } - if previousLineCount > 0 { - fmt.Fprintf(os.Stdout, "\x1b[%dA\r", previousLineCount-1) - } else { - fmt.Fprint(os.Stdout, "\r") + uiChoices := make([]ui.Choice, 0, len(choices)) + for _, choice := range choices { + uiChoices = append(uiChoices, ui.Choice{ + Value: choice.Value, + Label: choice.Label, + Help: choice.Help, + AllowCustomInput: choice.AllowCustomInput, + }) } - fmt.Fprint(os.Stdout, strings.Join(lines, "\r\n")+"\r\n") + return ui.PromptChoice(ui.ChoicePromptOptions{ + Name: name, + Sections: sections, + Choices: uiChoices, + DefaultValue: defaultValue, + }) } func RenderSection(title, body string) string { @@ -389,41 +314,6 @@ func renderChoiceLines(name string, choices []Choice, defaultValue string) []str return lines } -func renderInteractiveChoiceLines(choices []Choice, selected int, customInput string) []string { - lines := []string{} - width := promptRenderWidth() - for index, choice := range choices { - labelStyle := infoStyle - switch choice.Value { - case string(StateOn): - labelStyle = onLabelStyle - case string(StateOff): - labelStyle = offLabelStyle - } - - prefix := " " - if index == selected { - prefix = selectedStyle.Render("> ") - } - - help := strings.TrimSpace(choice.Help) - if choice.AllowCustomInput { - help = strings.TrimSpace(customInput) - if index == selected && help == "" { - help = mutedStyle.Render("|") - } - } else if help == "" { - help = choice.Label - } - lines = append(lines, strings.Split(wrapPrefixedText( - prefix+labelStyle.Render(choice.Label)+" ", - help, - width, - ), "\n")...) - } - return lines -} - func matchChoice(choices []Choice, value string) (Choice, bool) { normalized := strings.ToLower(strings.TrimSpace(value)) for _, choice := range choices { @@ -437,15 +327,6 @@ func matchChoice(choices []Choice, value string) (Choice, bool) { return Choice{}, false } -func defaultChoiceIndex(choices []Choice, defaultValue string) int { - for index, choice := range choices { - if choice.Value == defaultValue { - return index - } - } - return 0 -} - func customChoice(choices []Choice) *Choice { for i := range choices { if choices[i].AllowCustomInput { @@ -455,35 +336,6 @@ func customChoice(choices []Choice) *Choice { return nil } -func defaultCustomInput(choices []Choice, defaultValue string) string { - custom := customChoice(choices) - if custom == nil { - return "" - } - trimmed := strings.TrimSpace(defaultValue) - if trimmed == "" || trimmed == custom.Value { - return "" - } - for _, choice := range choices { - if !choice.AllowCustomInput && trimmed == choice.Value { - return "" - } - } - return trimmed -} - -func isPrintableInput(value []byte) bool { - if len(value) == 0 { - return false - } - for _, b := range value { - if b < 32 || b == 127 { - return false - } - } - return true -} - func stateChoiceValue(state State) string { if normalizeState(state) == StateOff { return string(StateOff) @@ -507,43 +359,6 @@ func promptRenderWidth() int { return width } -func truncateStyledLine(value string, width int) string { - if width <= 0 { - return value - } - if visibleWidth(value) <= width { - return value - } - - plain := stripANSIEscape(value) - if len(plain) <= width { - return plain - } - if width <= 1 { - return plain[:width] - } - return plain[:width-1] + "…" -} - -func stripANSIEscape(value string) string { - var out strings.Builder - out.Grow(len(value)) - - inEscape := false - for i := 0; i < len(value); i++ { - ch := value[i] - switch { - case ch == '\x1b': - inEscape = true - case inEscape && ch == 'm': - inEscape = false - case !inEscape: - out.WriteByte(ch) - } - } - return out.String() -} - func wrapText(text string, width int) string { return wrapPrefixedText("", text, width) } diff --git a/pkg/config/context.go b/pkg/config/context.go index e79bfbc..0d43e22 100644 --- a/pkg/config/context.go +++ b/pkg/config/context.go @@ -29,21 +29,23 @@ const ( ) type Context struct { - Name string `yaml:"name"` - Site string `yaml:"site"` - Plugin string `yaml:"plugin"` - DockerHostType ContextType `mapstructure:"type" yaml:"type"` - Environment string `yaml:"environment,omitempty"` - DockerSocket string `yaml:"docker-socket"` - ProjectName string `yaml:"project-name"` - ProjectDir string `yaml:"project-dir"` - SSHUser string `yaml:"ssh-user"` - SSHHostname string `yaml:"ssh-hostname,omitempty"` - SSHPort uint `yaml:"ssh-port,omitempty"` - SSHKeyPath string `yaml:"ssh-key,omitempty"` - EnvFile []string `yaml:"env-file"` - ComposeFile []string `yaml:"compose-file,omitempty"` - RunSudo bool `yaml:"sudo"` + Name string `yaml:"name"` + Site string `yaml:"site"` + Plugin string `yaml:"plugin"` + DockerHostType ContextType `mapstructure:"type" yaml:"type"` + Environment string `yaml:"environment,omitempty"` + DockerSocket string `yaml:"docker-socket"` + ProjectName string `yaml:"project-name"` + ComposeProjectName string `yaml:"compose-project-name,omitempty"` + ComposeNetwork string `yaml:"compose-network,omitempty"` + ProjectDir string `yaml:"project-dir"` + SSHUser string `yaml:"ssh-user"` + SSHHostname string `yaml:"ssh-hostname,omitempty"` + SSHPort uint `yaml:"ssh-port,omitempty"` + SSHKeyPath string `yaml:"ssh-key,omitempty"` + EnvFile []string `yaml:"env-file"` + ComposeFile []string `yaml:"compose-file,omitempty"` + RunSudo bool `yaml:"sudo"` // Database connection configuration DatabaseService string `yaml:"database-service,omitempty"` @@ -235,6 +237,14 @@ func (c *Context) ReadSmallFile(filename string) string { return string(data) } +func (c Context) EffectiveComposeProjectName() string { + return firstNonEmpty(c.ComposeProjectName, c.ProjectName) +} + +func (c Context) EffectiveComposeNetwork() string { + return firstNonEmpty(c.ComposeNetwork, c.EffectiveComposeProjectName()+"_default") +} + func (c *Context) DialSSH() (*ssh.Client, error) { key, err := os.ReadFile(c.SSHKeyPath) if err != nil { @@ -431,7 +441,7 @@ func (cc *Context) VerifyRemoteInput(existingSite bool) error { fmt.Println("Tested SSH connection OK!") } - if cc.ProjectName == "docker-compose" { + if cc.EffectiveComposeProjectName() == "docker-compose" || cc.EffectiveComposeProjectName() == "" { question := []string{ "What is the docker compose project name (COMPOSE_PROJECT_NAME in your .env)? [docker-compose]: ", } @@ -440,7 +450,7 @@ func (cc *Context) VerifyRemoteInput(existingSite bool) error { return fmt.Errorf("error reading input") } if pn != "" { - cc.ProjectName = pn + cc.ComposeProjectName = pn } } diff --git a/pkg/config/context_test.go b/pkg/config/context_test.go index 7e8e71f..e7a11fb 100644 --- a/pkg/config/context_test.go +++ b/pkg/config/context_test.go @@ -102,6 +102,8 @@ func contextsEqual(a, b Context) bool { a.Environment == b.Environment && a.DockerSocket == b.DockerSocket && a.ProjectName == b.ProjectName && + a.ComposeProjectName == b.ComposeProjectName && + a.ComposeNetwork == b.ComposeNetwork && a.ProjectDir == b.ProjectDir && a.SSHUser == b.SSHUser && a.SSHHostname == b.SSHHostname && @@ -118,13 +120,14 @@ func contextsEqual(a, b Context) bool { func TestContextString(t *testing.T) { ctx := Context{ - Name: "test", - Site: "museum", - Plugin: "isle", - DockerHostType: ContextLocal, - DockerSocket: "/var/run/docker.sock", - ProjectName: "project", - ProjectDir: "/tmp", + Name: "test", + Site: "museum", + Plugin: "isle", + DockerHostType: ContextLocal, + DockerSocket: "/var/run/docker.sock", + ProjectName: "project", + ComposeProjectName: "project-compose", + ProjectDir: "/tmp", } s, err := ctx.String() if err != nil { @@ -172,6 +175,7 @@ func TestSaveContext(t *testing.T) { // Test updating context. ctx.ProjectName = "updated-project" + ctx.ComposeProjectName = "updated-compose" err = SaveContext(&ctx, false) if err != nil { t.Fatalf("SaveContext error: %v", err) @@ -183,6 +187,9 @@ func TestSaveContext(t *testing.T) { if loadedCfg.Contexts[0].ProjectName != "updated-project" { t.Fatalf("expected updated project name, got %s", loadedCfg.Contexts[0].ProjectName) } + if loadedCfg.Contexts[0].ComposeProjectName != "updated-compose" { + t.Fatalf("expected updated compose project name, got %s", loadedCfg.Contexts[0].ComposeProjectName) + } } func TestCurrentContext(t *testing.T) { diff --git a/pkg/config/discovery.go b/pkg/config/discovery.go index 17fc615..fabf1d3 100644 --- a/pkg/config/discovery.go +++ b/pkg/config/discovery.go @@ -1,9 +1,14 @@ package config import ( + "fmt" "os" "path/filepath" + "sort" "strings" + + "github.com/joho/godotenv" + yaml "gopkg.in/yaml.v3" ) var composeProjectCandidates = []string{ @@ -26,6 +31,241 @@ func LooksLikeComposeProject(projectDir string) bool { return false } +func DetectComposeProjectName(projectDir string) string { + projectDir = filepath.Clean(strings.TrimSpace(projectDir)) + if projectDir == "" { + return "" + } + + envPath := filepath.Join(projectDir, ".env") + if values, err := godotenv.Read(envPath); err == nil { + if value := strings.TrimSpace(values["COMPOSE_PROJECT_NAME"]); value != "" { + return value + } + } + + for _, name := range composeProjectCandidates { + path := filepath.Join(projectDir, name) + data, err := os.ReadFile(path) + if err != nil { + continue + } + var doc struct { + Name string `yaml:"name"` + } + if err := yaml.Unmarshal(data, &doc); err != nil { + continue + } + if value := strings.TrimSpace(doc.Name); value != "" { + return value + } + } + + return "" +} + +type composeDiscoveryDoc struct { + Name string `yaml:"name"` + Services map[string]composeDiscoveryService `yaml:"services"` + Networks map[string]composeDiscoveryNetwork `yaml:"networks"` +} + +type composeDiscoveryService struct { + Networks any `yaml:"networks"` +} + +type composeDiscoveryNetwork struct { + Name string `yaml:"name"` +} + +func DetectComposeNetworkName(projectDir, composeProjectName string) string { + projectDir = filepath.Clean(strings.TrimSpace(projectDir)) + composeProjectName = strings.TrimSpace(composeProjectName) + if projectDir == "" || composeProjectName == "" { + return "" + } + + doc, ok := readComposeDiscoveryDoc(projectDir) + if !ok { + return composeProjectName + "_default" + } + return preferredComposeNetworkName(doc, composeProjectName) +} + +func DetectContextComposeNetwork(ctx *Context) string { + if ctx == nil { + return "" + } + composeProjectName := ctx.EffectiveComposeProjectName() + if composeProjectName == "" { + return "" + } + doc, ok := readComposeDiscoveryDocForContext(ctx) + if !ok { + return ctx.EffectiveComposeNetwork() + } + return preferredComposeNetworkName(doc, composeProjectName) +} + +func readComposeDiscoveryDoc(projectDir string) (composeDiscoveryDoc, bool) { + for _, name := range composeProjectCandidates { + path := filepath.Join(projectDir, name) + data, err := os.ReadFile(path) + if err != nil { + continue + } + var doc composeDiscoveryDoc + if err := yaml.Unmarshal(data, &doc); err != nil { + continue + } + return doc, true + } + return composeDiscoveryDoc{}, false +} + +func readComposeDiscoveryDocForContext(ctx *Context) (composeDiscoveryDoc, bool) { + if ctx == nil { + return composeDiscoveryDoc{}, false + } + files := ctx.ComposeFile + if len(files) == 0 { + files = composeProjectCandidates + } + for _, name := range files { + path := ctx.ResolveProjectPath(name) + exists, err := ctx.FileExists(path) + if err != nil || !exists { + continue + } + data := ctx.ReadSmallFile(path) + if strings.TrimSpace(data) == "" { + continue + } + var doc composeDiscoveryDoc + if err := yaml.Unmarshal([]byte(data), &doc); err != nil { + continue + } + return doc, true + } + return composeDiscoveryDoc{}, false +} + +func preferredComposeNetworkName(doc composeDiscoveryDoc, composeProjectName string) string { + if network, ok := doc.Networks["default"]; ok { + if value := strings.TrimSpace(network.Name); value != "" { + return value + } + return composeProjectName + "_default" + } + + if usesImplicitDefaultNetwork(doc.Services) { + return composeProjectName + "_default" + } + + common := commonServiceNetwork(doc.Services) + if common != "" { + return effectiveComposeNetworkName(common, doc.Networks[common], composeProjectName) + } + + keys := sortedComposeNetworkKeys(doc.Networks) + if len(keys) == 1 { + key := keys[0] + return effectiveComposeNetworkName(key, doc.Networks[key], composeProjectName) + } + + if len(keys) > 0 { + key := keys[0] + return effectiveComposeNetworkName(key, doc.Networks[key], composeProjectName) + } + + return composeProjectName + "_default" +} + +func usesImplicitDefaultNetwork(services map[string]composeDiscoveryService) bool { + if len(services) == 0 { + return true + } + for _, service := range services { + if len(serviceNetworkKeys(service.Networks)) == 0 { + return true + } + } + return false +} + +func commonServiceNetwork(services map[string]composeDiscoveryService) string { + if len(services) == 0 { + return "" + } + counts := map[string]int{} + total := 0 + for _, service := range services { + keys := serviceNetworkKeys(service.Networks) + if len(keys) == 0 { + return "" + } + total++ + for _, key := range keys { + counts[key]++ + } + } + common := "" + for key, count := range counts { + if count == total && (common == "" || key < common) { + common = key + } + } + return common +} + +func serviceNetworkKeys(value any) []string { + switch typed := value.(type) { + case nil: + return nil + case []any: + keys := make([]string, 0, len(typed)) + for _, item := range typed { + if name := strings.TrimSpace(fmt.Sprint(item)); name != "" { + keys = append(keys, name) + } + } + sort.Strings(keys) + return keys + case map[string]any: + keys := make([]string, 0, len(typed)) + for key := range typed { + if value := strings.TrimSpace(key); value != "" { + keys = append(keys, value) + } + } + sort.Strings(keys) + return keys + default: + return nil + } +} + +func sortedComposeNetworkKeys(networks map[string]composeDiscoveryNetwork) []string { + keys := make([]string, 0, len(networks)) + for key := range networks { + if value := strings.TrimSpace(key); value != "" { + keys = append(keys, value) + } + } + sort.Strings(keys) + return keys +} + +func effectiveComposeNetworkName(key string, network composeDiscoveryNetwork, composeProjectName string) string { + if value := strings.TrimSpace(network.Name); value != "" { + return value + } + if key == "default" || strings.TrimSpace(key) == "" { + return composeProjectName + "_default" + } + return composeProjectName + "_" + key +} + func FindLocalContextByProjectDir(projectDir string) (*Context, error) { projectDir = filepath.Clean(strings.TrimSpace(projectDir)) if projectDir == "" { diff --git a/pkg/config/discovery_test.go b/pkg/config/discovery_test.go new file mode 100644 index 0000000..44418f9 --- /dev/null +++ b/pkg/config/discovery_test.go @@ -0,0 +1,54 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectComposeProjectNameFromEnv(t *testing.T) { + projectDir := t.TempDir() + if err := os.WriteFile(filepath.Join(projectDir, ".env"), []byte("COMPOSE_PROJECT_NAME=lehigh-d10\n"), 0o600); err != nil { + t.Fatalf("WriteFile(.env) error = %v", err) + } + + if got := DetectComposeProjectName(projectDir); got != "lehigh-d10" { + t.Fatalf("expected lehigh-d10, got %q", got) + } +} + +func TestDetectComposeProjectNameFromComposeName(t *testing.T) { + projectDir := t.TempDir() + content := "name: isle-preserve\nservices:\n web:\n image: nginx:latest\n" + if err := os.WriteFile(filepath.Join(projectDir, "compose.yaml"), []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(compose.yaml) error = %v", err) + } + + if got := DetectComposeProjectName(projectDir); got != "isle-preserve" { + t.Fatalf("expected isle-preserve, got %q", got) + } +} + +func TestDetectComposeNetworkNameUsesDefaultNetwork(t *testing.T) { + projectDir := t.TempDir() + content := "name: isle-preserve\nservices:\n web:\n image: nginx:latest\n" + if err := os.WriteFile(filepath.Join(projectDir, "compose.yaml"), []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(compose.yaml) error = %v", err) + } + + if got := DetectComposeNetworkName(projectDir, "isle-preserve"); got != "isle-preserve_default" { + t.Fatalf("expected isle-preserve_default, got %q", got) + } +} + +func TestDetectComposeNetworkNameUsesExplicitNetworkName(t *testing.T) { + projectDir := t.TempDir() + content := "services:\n web:\n image: nginx:latest\n networks:\n - frontend\nnetworks:\n frontend:\n name: shared-frontdoor\n" + if err := os.WriteFile(filepath.Join(projectDir, "compose.yaml"), []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(compose.yaml) error = %v", err) + } + + if got := DetectComposeNetworkName(projectDir, "isle-preserve"); got != "shared-frontdoor" { + t.Fatalf("expected shared-frontdoor, got %q", got) + } +} diff --git a/pkg/config/local_context.go b/pkg/config/local_context.go index bed683c..3713157 100644 --- a/pkg/config/local_context.go +++ b/pkg/config/local_context.go @@ -21,6 +21,8 @@ type LocalContextCreateOptions struct { DefaultProjectDir string ProjectName string DefaultProjectName string + ComposeProjectName string + ComposeNetwork string Environment string DockerSocket string SetDefault bool @@ -84,20 +86,24 @@ func PromptAndSaveLocalContext(opts LocalContextCreateOptions) (*Context, error) } projectName := firstNonEmpty(opts.ProjectName, existing.ProjectName, opts.DefaultProjectName, "docker-compose") + composeProjectName := firstNonEmpty(opts.ComposeProjectName, existing.ComposeProjectName, DetectComposeProjectName(projectDir), projectName) + composeNetwork := firstNonEmpty(opts.ComposeNetwork, existing.ComposeNetwork, DetectComposeNetworkName(projectDir, composeProjectName)) site := firstNonEmpty(opts.Site, existing.Site, opts.DefaultSite, projectName, name) plugin := firstNonEmpty(opts.Plugin, existing.Plugin, opts.DefaultPlugin, "core") environment := firstNonEmpty(opts.Environment, existing.Environment, "local") dockerSocket := GetDefaultLocalDockerSocket(firstNonEmpty(opts.DockerSocket, existing.DockerSocket, "/var/run/docker.sock")) ctx := &Context{ - Name: name, - Site: site, - Plugin: plugin, - DockerHostType: ContextLocal, - Environment: environment, - DockerSocket: dockerSocket, - ProjectName: projectName, - ProjectDir: projectDir, + Name: name, + Site: site, + Plugin: plugin, + DockerHostType: ContextLocal, + Environment: environment, + DockerSocket: dockerSocket, + ProjectName: projectName, + ComposeProjectName: composeProjectName, + ComposeNetwork: composeNetwork, + ProjectDir: projectDir, } if err := SaveContext(ctx, opts.SetDefault); err != nil { @@ -245,9 +251,14 @@ func expandAndCleanProjectDir(value string) (string, error) { } value = filepath.Join(home, strings.TrimPrefix(value, "~/")) } + value = os.ExpandEnv(value) return filepath.Clean(value), nil } +func ExpandProjectDir(value string) (string, error) { + return expandAndCleanProjectDir(value) +} + func validateLocalProjectDir(projectDir string) error { info, err := os.Stat(projectDir) if err != nil { diff --git a/pkg/config/local_context_test.go b/pkg/config/local_context_test.go index f02e9af..b9862ba 100644 --- a/pkg/config/local_context_test.go +++ b/pkg/config/local_context_test.go @@ -19,6 +19,12 @@ func TestPromptAndSaveLocalContextUsesProvidedValues(t *testing.T) { if err := os.MkdirAll(projectDir, 0o755); err != nil { t.Fatalf("MkdirAll(projectDir) error = %v", err) } + if err := os.WriteFile(filepath.Join(projectDir, ".env"), []byte("COMPOSE_PROJECT_NAME=isle-local\n"), 0o600); err != nil { + t.Fatalf("WriteFile(.env) error = %v", err) + } + if err := os.WriteFile(filepath.Join(projectDir, "compose.yaml"), []byte("services:\n web:\n image: nginx\n"), 0o600); err != nil { + t.Fatalf("WriteFile(compose.yaml) error = %v", err) + } if err := os.Chdir(projectDir); err != nil { t.Fatalf("Chdir(projectDir) error = %v", err) } @@ -27,9 +33,10 @@ func TestPromptAndSaveLocalContextUsesProvidedValues(t *testing.T) { }) ctx, err := PromptAndSaveLocalContext(LocalContextCreateOptions{ - Name: "isle-local", - ProjectDir: projectDir, - SetDefault: true, + Name: "isle-local", + ProjectDir: projectDir, + SetDefault: true, + ProjectDirValidator: func(string) error { return nil }, Input: func(question ...string) (string, error) { t.Fatal("did not expect prompt") return "", nil @@ -57,6 +64,12 @@ func TestPromptAndSaveLocalContextUsesProvidedValues(t *testing.T) { if ctx.ProjectName != "docker-compose" { t.Fatalf("expected default project name docker-compose, got %q", ctx.ProjectName) } + if ctx.ComposeProjectName != "isle-local" { + t.Fatalf("expected detected compose project name isle-local, got %q", ctx.ComposeProjectName) + } + if ctx.ComposeNetwork != "isle-local_default" { + t.Fatalf("expected detected compose network isle-local_default, got %q", ctx.ComposeNetwork) + } if ctx.Environment != "local" { t.Fatalf("expected default environment local, got %q", ctx.Environment) } @@ -160,6 +173,40 @@ func TestPromptAndSaveLocalContextExpandsTildeInPromptedProjectDir(t *testing.T) } } +func TestPromptAndSaveLocalContextExpandsEnvInPromptedProjectDir(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + if err := os.Chdir(tempHome); err != nil { + t.Fatalf("Chdir(tempHome) error = %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(oldWd) + }) + + prompts := []string{"$HOME/sites/site-home"} + ctx, err := PromptAndSaveLocalContext(LocalContextCreateOptions{ + DefaultName: "default-site", + Input: func(question ...string) (string, error) { + value := prompts[0] + prompts = prompts[1:] + return value, nil + }, + }) + if err != nil { + t.Fatalf("PromptAndSaveLocalContext() error = %v", err) + } + + expected := filepath.Join(tempHome, "sites", "site-home") + if ctx.ProjectDir != expected { + t.Fatalf("expected project dir %q, got %q", expected, ctx.ProjectDir) + } +} + func TestPromptAndSaveLocalContextDeclinesOverwrite(t *testing.T) { tempHome := t.TempDir() t.Setenv("HOME", tempHome) diff --git a/pkg/config/utils.go b/pkg/config/utils.go index e13a292..cd54ebc 100644 --- a/pkg/config/utils.go +++ b/pkg/config/utils.go @@ -12,11 +12,24 @@ import ( "time" "github.com/joho/godotenv" + "github.com/libops/sitectl/pkg/ui" "github.com/spf13/pflag" + "golang.org/x/term" yaml "gopkg.in/yaml.v3" ) func GetInput(question ...string) (string, error) { + if term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) && len(question) > 0 { + prompt := question[len(question)-1] + sections := append([]string{}, question[:len(question)-1]...) + if value, ok, err := ui.PromptText(ui.TextPromptOptions{ + Sections: sections, + Prompt: prompt, + }); ok { + return value, err + } + } + reader := bufio.NewReader(os.Stdin) lastItemIndex := len(question) - 1 for i := range question { @@ -114,6 +127,8 @@ func contextHasStoredValues(context Context) bool { context.Environment != "" || context.DockerSocket != "" || context.ProjectName != "" || + context.ComposeProjectName != "" || + context.ComposeNetwork != "" || context.ProjectDir != "" || context.SSHUser != "" || context.SSHHostname != "" || @@ -185,7 +200,9 @@ func SetCommandFlags(flags *pflag.FlagSet) { flags.String("project-dir", "", "Path to docker compose project directory") flags.String("site", "", "Logical site name this context belongs to") flags.String("plugin", "core", "Owning plugin identifier for this context, such as core, isle, or drupal") - flags.String("project-name", "docker-compose", "Name of the docker compose project") + flags.String("project-name", "docker-compose", "Logical project name for this context") + flags.String("compose-project-name", "", "Docker Compose project name, matching COMPOSE_PROJECT_NAME or compose name:") + flags.String("compose-network", "", "Primary Docker Compose network name for this environment") flags.String("environment", "", "Environment name for this context, such as local, dev, staging, or prod") flags.Bool("sudo", false, "for remote contexts, run docker commands as sudo") flags.StringSlice("env-file", []string{}, "when running remote docker commands, the --env-file paths to pass to docker compose") diff --git a/pkg/config/utils_test.go b/pkg/config/utils_test.go index 9db7363..ac18f04 100644 --- a/pkg/config/utils_test.go +++ b/pkg/config/utils_test.go @@ -24,6 +24,8 @@ func TestLoadFromFlags(t *testing.T) { flags.String("site", "", "Site") flags.String("plugin", "core", "Plugin") flags.String("project-name", "foo", "Composer Project Name") + flags.String("compose-project-name", "", "Docker Compose project name") + flags.String("compose-network", "", "Docker Compose network name") flags.String("environment", "", "Environment name") flags.Bool("sudo", false, "Run commands on remote hosts as sudo") flags.StringSlice("env-file", []string{}, "path to env files to pass to docker compose") @@ -45,6 +47,8 @@ func TestLoadFromFlags(t *testing.T) { "--site", "museum", "--plugin", "isle", "--project-name", "bar", + "--compose-project-name", "bar-compose", + "--compose-network", "bar-net", "--environment", "staging", "--sudo", "true", "--env-file", ".env", @@ -93,6 +97,12 @@ func TestLoadFromFlags(t *testing.T) { if ctx.ProjectName != "bar" { t.Errorf("Expected project-name 'bar', got %q", ctx.ProjectName) } + if ctx.ComposeProjectName != "bar-compose" { + t.Errorf("Expected compose-project-name 'bar-compose', got %q", ctx.ComposeProjectName) + } + if ctx.ComposeNetwork != "bar-net" { + t.Errorf("Expected compose-network 'bar-net', got %q", ctx.ComposeNetwork) + } if ctx.Environment != "staging" { t.Errorf("Expected environment 'staging', got %q", ctx.Environment) } diff --git a/pkg/docker/docker.go b/pkg/docker/docker.go index ec9f84a..a4c84cd 100644 --- a/pkg/docker/docker.go +++ b/pkg/docker/docker.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "path/filepath" + "sort" "strings" dockercontainer "github.com/docker/docker/api/types/container" @@ -117,12 +118,21 @@ func (d *DockerClient) GetServiceIp(ctx context.Context, c *config.Context, cont if err != nil { return "", fmt.Errorf("error inspecting container %q: %v", containerName, err) } - networkName := fmt.Sprintf("%s_default", c.ProjectName) - network, ok := containerJSON.NetworkSettings.Networks[networkName] - if !ok { - return "", fmt.Errorf("network %q not found in container %q", networkName, containerName) + networkName := c.EffectiveComposeNetwork() + if network, ok := containerJSON.NetworkSettings.Networks[networkName]; ok { + return network.IPAddress, nil + } + if len(containerJSON.NetworkSettings.Networks) == 1 { + for _, network := range containerJSON.NetworkSettings.Networks { + return network.IPAddress, nil + } + } + available := make([]string, 0, len(containerJSON.NetworkSettings.Networks)) + for name := range containerJSON.NetworkSettings.Networks { + available = append(available, name) } - return network.IPAddress, nil + sort.Strings(available) + return "", fmt.Errorf("network %q not found in container %q (available: %s)", networkName, containerName, strings.Join(available, ", ")) } func (d *DockerClient) GetContainerName(c *config.Context, service string) (string, error) { @@ -130,7 +140,7 @@ func (d *DockerClient) GetContainerName(c *config.Context, service string) (stri // Define the filters based on the Docker Compose labels. filterArgs := filters.NewArgs() - filterArgs.Add("label", "com.docker.compose.project="+c.ProjectName) + filterArgs.Add("label", "com.docker.compose.project="+c.EffectiveComposeProjectName()) filterArgs.Add("label", "com.docker.compose.service="+service) slog.Debug("Querying docker", "filters", filterArgs) diff --git a/pkg/docker/docker_test.go b/pkg/docker/docker_test.go index 07451b9..0bcf770 100644 --- a/pkg/docker/docker_test.go +++ b/pkg/docker/docker_test.go @@ -15,6 +15,7 @@ import ( // FakeDockerClient implements the DockerAPI interface for testing. type FakeDockerClient struct { InspectFunc func(ctx context.Context, container string) (dockercontainer.InspectResponse, error) + ListFunc func(ctx context.Context, options dockercontainer.ListOptions) ([]dockercontainer.Summary, error) } var _ DockerAPI = (*FakeDockerClient)(nil) @@ -24,6 +25,9 @@ func (f *FakeDockerClient) ContainerInspect(ctx context.Context, container strin } func (f *FakeDockerClient) ContainerList(ctx context.Context, options dockercontainer.ListOptions) ([]dockercontainer.Summary, error) { + if f.ListFunc != nil { + return f.ListFunc(ctx, options) + } return nil, fmt.Errorf("Not implemented") } @@ -151,7 +155,8 @@ func TestGetServiceIp(t *testing.T) { }, } fakeConfig := &config.Context{ - ProjectName: "test", + ProjectName: "test", + ComposeNetwork: "test_default", } dClient := &DockerClient{ CLI: fake, @@ -164,3 +169,29 @@ func TestGetServiceIp(t *testing.T) { t.Errorf("expected %q, got %q", "172.17.0.3", ip) } } + +func TestGetServiceIpFallsBackToSingleNetwork(t *testing.T) { + fake := &FakeDockerClient{ + InspectFunc: func(ctx context.Context, container string) (dockercontainer.InspectResponse, error) { + return dockercontainer.InspectResponse{ + NetworkSettings: &dockercontainer.NetworkSettings{ + Networks: map[string]*network.EndpointSettings{ + "shared-frontdoor": {IPAddress: "172.17.0.9"}, + }, + }, + }, nil + }, + } + fakeConfig := &config.Context{ + ProjectName: "test", + ComposeNetwork: "missing-network", + } + dClient := &DockerClient{CLI: fake} + ip, err := dClient.GetServiceIp(context.Background(), fakeConfig, "dummyContainer") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ip != "172.17.0.9" { + t.Errorf("expected %q, got %q", "172.17.0.9", ip) + } +} diff --git a/pkg/docker/summary.go b/pkg/docker/summary.go new file mode 100644 index 0000000..fc835e5 --- /dev/null +++ b/pkg/docker/summary.go @@ -0,0 +1,405 @@ +package docker + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "sort" + "strconv" + "strings" + + dockercontainer "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/kballard/go-shellquote" + "github.com/libops/sitectl/pkg/config" + "golang.org/x/crypto/ssh" +) + +type ServiceSummary struct { + Service string + Name string + State string + Status string + Healthy bool +} + +type ProjectSummary struct { + Running int + Total int + Healthy int + Stopped int + CPUPercent float64 + MemoryBytes uint64 + MemoryLimitBytes uint64 + Services []ServiceSummary + Status string +} + +func SummarizeProject(ctxCfg *config.Context) (ProjectSummary, error) { + if ctxCfg == nil { + return ProjectSummary{}, fmt.Errorf("context cannot be nil") + } + + output, err := runComposePS(ctxCfg) + if err == nil { + summary, parseErr := parseComposePSOutput(output) + if parseErr != nil { + return ProjectSummary{}, parseErr + } + if statsOutput, statsErr := runDockerStats(ctxCfg); statsErr == nil { + applyDockerStats(&summary, statsOutput) + } + return summary, nil + } + + cli, cliErr := GetDockerCli(ctxCfg) + if cliErr != nil { + return ProjectSummary{}, err + } + defer cli.Close() + + summary, fallbackErr := SummarizeProjectWithClient(context.Background(), cli.CLI, ctxCfg) + if fallbackErr != nil { + return ProjectSummary{}, err + } + return summary, nil +} + +func SummarizeProjectWithClient(ctx context.Context, cli DockerAPI, ctxCfg *config.Context) (ProjectSummary, error) { + if ctxCfg == nil { + return ProjectSummary{}, fmt.Errorf("context cannot be nil") + } + + filterArgs := filters.NewArgs() + filterArgs.Add("label", "com.docker.compose.project="+ctxCfg.EffectiveComposeProjectName()) + + containers, err := cli.ContainerList(ctx, dockercontainer.ListOptions{ + All: true, + Filters: filterArgs, + }) + if err != nil { + return ProjectSummary{}, err + } + + summary := ProjectSummary{ + Services: make([]ServiceSummary, 0, len(containers)), + } + + for _, container := range containers { + service := firstNonEmpty(container.Labels["com.docker.compose.service"], trimContainerName(container.Names)) + item := ServiceSummary{ + Service: service, + Name: trimContainerName(container.Names), + State: container.State, + Status: container.Status, + Healthy: strings.Contains(strings.ToLower(container.Status), "healthy"), + } + summary.Total++ + if container.State == "running" { + summary.Running++ + } else { + summary.Stopped++ + } + if item.Healthy { + summary.Healthy++ + } + summary.Services = append(summary.Services, item) + } + + finalizeSummary(&summary) + return summary, nil +} + +func runComposePS(ctxCfg *config.Context) (string, error) { + args := composePSArgs(*ctxCfg) + if ctxCfg.DockerHostType == config.ContextLocal { + cmd := exec.Command("docker", args...) + cmd.Dir = ctxCfg.ProjectDir + output, err := cmd.CombinedOutput() + return string(output), err + } + + remoteCmd := fmt.Sprintf("cd %s && ", shellquote.Join(ctxCfg.ProjectDir)) + if ctxCfg.RunSudo { + remoteCmd += "sudo " + } + remoteCmd += shellquote.Join(append([]string{"docker"}, args...)...) + + client, err := ctxCfg.DialSSH() + if err != nil { + return "", err + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return "", err + } + defer session.Close() + + output, err := session.CombinedOutput(remoteCmd) + if err != nil { + if _, ok := err.(*ssh.ExitError); ok && len(output) > 0 { + return string(output), nil + } + return string(output), err + } + return string(output), nil +} + +func runDockerStats(ctxCfg *config.Context) (string, error) { + args := []string{"stats", "--no-stream", "--format", "{{ json . }}"} + if ctxCfg.DockerHostType == config.ContextLocal { + cmd := exec.Command("docker", args...) + cmd.Dir = ctxCfg.ProjectDir + output, err := cmd.CombinedOutput() + return string(output), err + } + + remoteCmd := fmt.Sprintf("cd %s && ", shellquote.Join(ctxCfg.ProjectDir)) + if ctxCfg.RunSudo { + remoteCmd += "sudo " + } + remoteCmd += shellquote.Join(append([]string{"docker"}, args...)...) + + client, err := ctxCfg.DialSSH() + if err != nil { + return "", err + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return "", err + } + defer session.Close() + + output, err := session.CombinedOutput(remoteCmd) + if err != nil { + if _, ok := err.(*ssh.ExitError); ok && len(output) > 0 { + return string(output), nil + } + return string(output), err + } + return string(output), nil +} + +func composePSArgs(ctxCfg config.Context) []string { + args := []string{"compose"} + for _, file := range ctxCfg.ComposeFile { + args = append(args, "-f", file) + } + for _, env := range ctxCfg.EnvFile { + args = append(args, "--env-file", env) + } + return append(args, "ps", "--all", "--format", "json") +} + +func parseComposePSOutput(output string) (ProjectSummary, error) { + trimmed := strings.TrimSpace(output) + if trimmed == "" { + return ProjectSummary{Status: "not running"}, nil + } + + var payload any + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + lines := strings.Split(trimmed, "\n") + items := make([]any, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var row any + if lineErr := json.Unmarshal([]byte(line), &row); lineErr != nil { + return ProjectSummary{}, err + } + items = append(items, row) + } + payload = items + } + + rows, ok := payload.([]any) + if !ok { + return ProjectSummary{}, fmt.Errorf("unexpected docker compose ps payload") + } + + summary := ProjectSummary{ + Services: make([]ServiceSummary, 0, len(rows)), + } + for _, raw := range rows { + row, ok := raw.(map[string]any) + if !ok { + continue + } + item := ServiceSummary{ + Service: firstNonEmpty(composeField(row, "service")), + Name: firstNonEmpty(composeField(row, "name")), + State: strings.ToLower(firstNonEmpty(composeField(row, "state"), "unknown")), + Status: firstNonEmpty(composeField(row, "status")), + } + if item.Service == "" { + item.Service = item.Name + } + item.Healthy = strings.Contains(strings.ToLower(firstNonEmpty(composeField(row, "health"), item.Status)), "healthy") + summary.Total++ + if item.State == "running" { + summary.Running++ + } else { + summary.Stopped++ + } + if item.Healthy { + summary.Healthy++ + } + summary.Services = append(summary.Services, item) + } + + finalizeSummary(&summary) + return summary, nil +} + +type dockerStatsRow struct { + Name string `json:"Name"` + CPUPerc string `json:"CPUPerc"` + MemUsage string `json:"MemUsage"` +} + +func applyDockerStats(summary *ProjectSummary, output string) { + if summary == nil { + return + } + serviceNames := map[string]struct{}{} + containerNames := map[string]struct{}{} + for _, service := range summary.Services { + if strings.TrimSpace(service.Service) != "" { + serviceNames[service.Service] = struct{}{} + } + if strings.TrimSpace(service.Name) != "" { + containerNames[service.Name] = struct{}{} + } + } + + var maxLimit uint64 + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var row dockerStatsRow + if err := json.Unmarshal([]byte(line), &row); err != nil { + continue + } + if _, ok := containerNames[row.Name]; !ok { + if _, ok := serviceNames[row.Name]; !ok { + continue + } + } + summary.CPUPercent += parsePercent(row.CPUPerc) + used, limit := parseMemUsage(row.MemUsage) + summary.MemoryBytes += used + if limit > maxLimit { + maxLimit = limit + } + } + summary.MemoryLimitBytes = maxLimit +} + +func parsePercent(value string) float64 { + value = strings.TrimSpace(strings.TrimSuffix(value, "%")) + if value == "" { + return 0 + } + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0 + } + return parsed +} + +func parseMemUsage(value string) (uint64, uint64) { + parts := strings.Split(value, "/") + if len(parts) != 2 { + return 0, 0 + } + return parseHumanBytes(parts[0]), parseHumanBytes(parts[1]) +} + +func parseHumanBytes(value string) uint64 { + value = strings.TrimSpace(strings.ToUpper(value)) + replacer := strings.NewReplacer("IB", "B", "I", "") + value = replacer.Replace(value) + multiplier := float64(1) + switch { + case strings.HasSuffix(value, "KB"): + multiplier = 1000 + value = strings.TrimSuffix(value, "KB") + case strings.HasSuffix(value, "MB"): + multiplier = 1000 * 1000 + value = strings.TrimSuffix(value, "MB") + case strings.HasSuffix(value, "GB"): + multiplier = 1000 * 1000 * 1000 + value = strings.TrimSuffix(value, "GB") + case strings.HasSuffix(value, "TB"): + multiplier = 1000 * 1000 * 1000 * 1000 + value = strings.TrimSuffix(value, "TB") + case strings.HasSuffix(value, "B"): + value = strings.TrimSuffix(value, "B") + } + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil { + return 0 + } + return uint64(parsed * multiplier) +} + +func composeField(row map[string]any, key string) string { + for candidate, value := range row { + if strings.EqualFold(candidate, key) { + if str, ok := value.(string); ok { + return strings.TrimSpace(str) + } + return strings.TrimSpace(fmt.Sprint(value)) + } + } + return "" +} + +func finalizeSummary(summary *ProjectSummary) { + sort.Slice(summary.Services, func(i, j int) bool { + if summary.Services[i].Service != summary.Services[j].Service { + return summary.Services[i].Service < summary.Services[j].Service + } + return summary.Services[i].Name < summary.Services[j].Name + }) + + switch { + case summary.Total == 0: + summary.Status = "not running" + case summary.Running == summary.Total: + summary.Status = "running" + case summary.Running > 0: + summary.Status = "degraded" + default: + summary.Status = "stopped" + } +} + +func trimContainerName(names []string) string { + for _, name := range names { + name = strings.TrimPrefix(strings.TrimSpace(name), "/") + if name != "" { + return name + } + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/pkg/docker/summary_test.go b/pkg/docker/summary_test.go new file mode 100644 index 0000000..a1db859 --- /dev/null +++ b/pkg/docker/summary_test.go @@ -0,0 +1,110 @@ +package docker + +import ( + "context" + "testing" + + dockercontainer "github.com/docker/docker/api/types/container" + "github.com/libops/sitectl/pkg/config" +) + +func TestSummarizeProjectWithClient(t *testing.T) { + fake := &FakeDockerClient{ + ListFunc: func(ctx context.Context, options dockercontainer.ListOptions) ([]dockercontainer.Summary, error) { + return []dockercontainer.Summary{ + { + Names: []string{"/museum-web-1"}, + State: "running", + Status: "Up 2 minutes (healthy)", + Labels: map[string]string{"com.docker.compose.service": "web"}, + }, + { + Names: []string{"/museum-db-1"}, + State: "exited", + Status: "Exited (1) 10 seconds ago", + Labels: map[string]string{"com.docker.compose.service": "db"}, + }, + }, nil + }, + } + + summary, err := SummarizeProjectWithClient(context.Background(), fake, &config.Context{ProjectName: "museum"}) + if err != nil { + t.Fatalf("SummarizeProjectWithClient() error = %v", err) + } + if summary.Total != 2 { + t.Fatalf("expected 2 containers, got %d", summary.Total) + } + if summary.Running != 1 { + t.Fatalf("expected 1 running container, got %d", summary.Running) + } + if summary.Status != "degraded" { + t.Fatalf("expected degraded status, got %q", summary.Status) + } + if len(summary.Services) != 2 { + t.Fatalf("expected 2 services, got %d", len(summary.Services)) + } +} + +func TestParseComposePSOutput(t *testing.T) { + output := `[ + { + "Name": "lehigh-d10-drupal-1", + "Service": "drupal", + "State": "running", + "Status": "Up 2 minutes", + "Health": "healthy" + }, + { + "Name": "lehigh-d10-fcrepo-1", + "Service": "fcrepo", + "State": "running", + "Status": "Up 2 minutes", + "Health": "healthy" + } +]` + + summary, err := parseComposePSOutput(output) + if err != nil { + t.Fatalf("parseComposePSOutput() error = %v", err) + } + if summary.Total != 2 { + t.Fatalf("expected 2 containers, got %d", summary.Total) + } + if summary.Running != 2 { + t.Fatalf("expected 2 running containers, got %d", summary.Running) + } + if summary.Healthy != 2 { + t.Fatalf("expected 2 healthy containers, got %d", summary.Healthy) + } + if summary.Status != "running" { + t.Fatalf("expected running status, got %q", summary.Status) + } +} + +func TestApplyDockerStatsUsesSingleEffectiveMemoryLimit(t *testing.T) { + summary := ProjectSummary{ + Services: []ServiceSummary{ + {Name: "lehigh-d10-drupal-1", Service: "drupal"}, + {Name: "lehigh-d10-solr-1", Service: "solr"}, + }, + } + + output := `{"Name":"lehigh-d10-drupal-1","CPUPerc":"2.5%","MemUsage":"500MiB / 15.6GiB"} +{"Name":"lehigh-d10-solr-1","CPUPerc":"1.5%","MemUsage":"750MiB / 15.6GiB"}` + + applyDockerStats(&summary, output) + + if summary.CPUPercent != 4 { + t.Fatalf("expected CPU percent 4, got %v", summary.CPUPercent) + } + if summary.MemoryBytes == 0 { + t.Fatalf("expected memory usage to be aggregated") + } + if summary.MemoryLimitBytes == 0 { + t.Fatalf("expected a memory limit to be detected") + } + if summary.MemoryLimitBytes > 20_000_000_000 { + t.Fatalf("expected effective memory limit near host total, got %d", summary.MemoryLimitBytes) + } +} diff --git a/pkg/plugin/discovery.go b/pkg/plugin/discovery.go new file mode 100644 index 0000000..46abdb1 --- /dev/null +++ b/pkg/plugin/discovery.go @@ -0,0 +1,146 @@ +package plugin + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type InstalledPlugin struct { + Name string + BinaryName string + Path string + Version string + Description string + Author string + TemplateRepo string + CanCreate bool +} + +var builtinTemplateRepos = map[string]string{ + "isle": "https://github.com/islandora-devops/isle-site-template", +} + +func DiscoverInstalled() []InstalledPlugin { + return DiscoverInstalledFromPath(os.Getenv("PATH")) +} + +func DiscoverInstalledFromPath(pathEnv string) []InstalledPlugin { + seen := map[string]bool{} + discovered := make([]InstalledPlugin, 0) + + for _, dir := range filepath.SplitList(pathEnv) { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasPrefix(name, "sitectl-") || name == "sitectl" { + continue + } + + pluginName := strings.TrimPrefix(name, "sitectl-") + if pluginName == "" || seen[pluginName] { + continue + } + seen[pluginName] = true + + path := filepath.Join(dir, name) + discovered = append(discovered, inspectInstalledPlugin(pluginName, name, path)) + } + } + + return discovered +} + +func inspectInstalledPlugin(pluginName, binaryName, pluginPath string) InstalledPlugin { + info := InstalledPlugin{ + Name: pluginName, + BinaryName: binaryName, + Path: pluginPath, + Description: fmt.Sprintf("the %s plugin", pluginName), + CanCreate: pluginSupportsCreate(pluginPath), + } + if repo := builtinTemplateRepos[pluginName]; repo != "" { + info.TemplateRepo = repo + } + + cmd := exec.Command(pluginPath, "plugin-info") + output, err := cmd.Output() + if err != nil { + return info + } + + parsed := ParsePluginInfoOutput(string(output)) + if parsed.Name == "" { + parsed.Name = pluginName + } + if parsed.BinaryName == "" { + parsed.BinaryName = binaryName + } + if parsed.Path == "" { + parsed.Path = pluginPath + } + if parsed.Description == "" { + parsed.Description = info.Description + } + if parsed.TemplateRepo == "" { + parsed.TemplateRepo = info.TemplateRepo + } + if !parsed.CanCreate { + parsed.CanCreate = info.CanCreate + } + + return parsed +} + +func pluginSupportsCreate(pluginPath string) bool { + cmd := exec.Command(pluginPath, "create", "--help") + if output, err := cmd.CombinedOutput(); err == nil { + return true + } else if strings.Contains(strings.ToLower(string(output)), "unknown command") { + return false + } + return false +} + +func ParsePluginInfoOutput(output string) InstalledPlugin { + var info InstalledPlugin + + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + key, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + key = strings.TrimSpace(strings.ToLower(key)) + value = strings.TrimSpace(value) + + switch key { + case "name": + info.Name = value + case "version": + info.Version = value + case "description": + info.Description = value + case "author": + info.Author = value + case "template-repo": + info.TemplateRepo = value + } + } + + return info +} diff --git a/pkg/plugin/discovery_test.go b/pkg/plugin/discovery_test.go new file mode 100644 index 0000000..b604e91 --- /dev/null +++ b/pkg/plugin/discovery_test.go @@ -0,0 +1,74 @@ +package plugin + +import ( + "os" + "testing" +) + +func TestParsePluginInfoOutput(t *testing.T) { + output := `Name: isle +Version: 1.2.3 +Description: Islandora support +Author: LibOps +Template-Repo: https://github.com/islandora-devops/isle-site-template +` + + info := ParsePluginInfoOutput(output) + if info.Name != "isle" { + t.Fatalf("expected name isle, got %q", info.Name) + } + if info.TemplateRepo != "https://github.com/islandora-devops/isle-site-template" { + t.Fatalf("expected template repo to be parsed, got %q", info.TemplateRepo) + } + if info.Description != "Islandora support" { + t.Fatalf("expected description to be parsed, got %q", info.Description) + } +} + +func TestDiscoverInstalledFromPathFallsBackToBuiltinTemplateRepo(t *testing.T) { + dir := t.TempDir() + pathEnv := dir + + if err := os.WriteFile(dir+"/sitectl-isle", []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + plugins := DiscoverInstalledFromPath(pathEnv) + if len(plugins) != 1 { + t.Fatalf("expected one plugin, got %d", len(plugins)) + } + if plugins[0].TemplateRepo != "https://github.com/islandora-devops/isle-site-template" { + t.Fatalf("expected builtin template repo, got %q", plugins[0].TemplateRepo) + } + if plugins[0].CanCreate { + t.Fatalf("expected failing plugin inspection to report CanCreate=false") + } +} + +func TestDiscoverInstalledFromPathDetectsCreateCommand(t *testing.T) { + dir := t.TempDir() + pathEnv := dir + + script := `#!/bin/sh +if [ "$1" = "create" ] && [ "$2" = "--help" ]; then + echo "create help" + exit 0 +fi +if [ "$1" = "plugin-info" ]; then + echo "Name: demo" + exit 0 +fi +exit 1 +` + if err := os.WriteFile(dir+"/sitectl-demo", []byte(script), 0o755); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + plugins := DiscoverInstalledFromPath(pathEnv) + if len(plugins) != 1 { + t.Fatalf("expected one plugin, got %d", len(plugins)) + } + if !plugins[0].CanCreate { + t.Fatalf("expected plugin create command to be detected") + } +} diff --git a/pkg/plugin/sdk.go b/pkg/plugin/sdk.go index 9844bae..ca05aac 100644 --- a/pkg/plugin/sdk.go +++ b/pkg/plugin/sdk.go @@ -18,10 +18,11 @@ import ( // Metadata contains information about a plugin type Metadata struct { - Name string - Version string - Description string - Author string + Name string + Version string + Description string + Author string + TemplateRepo string } var builtinPluginIncludes = map[string][]string{ @@ -154,6 +155,9 @@ func (s *SDK) GetMetadataCommand() *cobra.Command { if s.Metadata.Author != "" { fmt.Printf("Author: %s\n", s.Metadata.Author) } + if s.Metadata.TemplateRepo != "" { + fmt.Printf("Template-Repo: %s\n", s.Metadata.TemplateRepo) + } }, } } diff --git a/pkg/tui/dashboard.go b/pkg/tui/dashboard.go new file mode 100644 index 0000000..e83d9ca --- /dev/null +++ b/pkg/tui/dashboard.go @@ -0,0 +1,1819 @@ +package tui + +import ( + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "sort" + "strings" + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/glamour/v2" + "charm.land/lipgloss/v2" + "github.com/NimbleMarkets/ntcharts/v2/sparkline" + "github.com/kballard/go-shellquote" + "github.com/libops/sitectl/docs" + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/docker" + "github.com/libops/sitectl/pkg/plugin" + zone "github.com/lrstanley/bubblezone/v2" + "golang.org/x/crypto/ssh" +) + +type siteGroup struct { + Name string + Contexts []config.Context +} + +type screenMode int + +const ( + screenDashboard screenMode = iota + screenLogs + screenTour +) + +type overlayMode int + +const ( + overlayNone overlayMode = iota + overlayActions + overlaySettings + overlayChooser + overlayInfo + overlayCommands +) + +type refreshTickMsg time.Time + +type summaryLoadedMsg struct { + ContextName string + Summary docker.ProjectSummary + Err error +} + +type logsLoadedMsg struct { + ContextName string + Logs string + Err error +} + +type commandFinishedMsg struct { + Command string + Output string + Err error +} + +type commandExecFinishedMsg struct { + Command string + Err error +} + +type stateReloadedMsg struct { + Config *config.Config + Plugins []plugin.InstalledPlugin + CurrentContext string + Err error +} + +type menuItem struct { + title string + desc string + action string +} + +func (i menuItem) Title() string { return i.title } +func (i menuItem) Description() string { return i.desc } +func (i menuItem) FilterValue() string { return i.title + " " + i.desc } + +type keyMap struct { + Left key.Binding + Right key.Binding + Up key.Binding + Down key.Binding + Actions key.Binding + Settings key.Binding + NewApp key.Binding + Command key.Binding + Palette key.Binding + Terminal key.Binding + Logs key.Binding + Refresh key.Binding + Enter key.Binding + Back key.Binding + Quit key.Binding +} + +func defaultKeyMap() keyMap { + return keyMap{ + Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("h/left", "site")), + Right: key.NewBinding(key.WithKeys("right", "l", "tab"), key.WithHelp("l/right", "next site")), + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("k/up", "env up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("j/down", "env down")), + Actions: key.NewBinding(key.WithKeys("ctrl+a"), key.WithHelp("ctrl+a", "actions")), + Settings: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "settings")), + NewApp: key.NewBinding(key.WithKeys("ctrl+n"), key.WithHelp("ctrl+n", "choose app")), + Command: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "command bar")), + Palette: key.NewBinding(key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "palette")), + Terminal: key.NewBinding(key.WithKeys("ctrl+x"), key.WithHelp("ctrl+x", "run in terminal")), + Logs: key.NewBinding(key.WithKeys("ctrl+g"), key.WithHelp("ctrl+g", "logs")), + Refresh: key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("ctrl+r", "refresh")), + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), + Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + } +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Left, k.Up, k.Command, k.Palette, k.Logs, k.Refresh, k.Terminal, k.Back, k.Quit} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Left, k.Right, k.Up, k.Down}, {k.Command, k.Palette, k.Logs, k.Refresh}, {k.Actions, k.Settings, k.NewApp, k.Terminal, k.Enter, k.Back, k.Quit}} +} + +type dashboardModel struct { + cfg *config.Config + sites []siteGroup + plugins []plugin.InstalledPlugin + tourPanes []docs.TourPane + currentContext string + + siteIndex int + envIndex int + width int + height int + + screen screenMode + overlay overlayMode + + loading bool + loadingLog bool + summary docker.ProjectSummary + summaryErr error + logsErr error + + lastMessage string + infoTitle string + infoBody string + logsTitle string + + historyCPU map[string][]float64 + historyMemory map[string][]float64 + + help help.Model + keys keyMap + spin spinner.Model + detail viewport.Model + logs viewport.Model + actions list.Model + settings list.Model + chooser list.Model + commands list.Model + commandParent string + + commandInput textinput.Model + commandRunning bool + commandQuitArmed bool +} + +func Run() error { + cfg, err := config.Load() + if err != nil { + return err + } + + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{ + Level: slog.LevelError + 100, + }))) + defer slog.SetDefault(previousLogger) + + zone.NewGlobal() + defer zone.Close() + + model := newDashboardModel(cfg, plugin.DiscoverInstalled()) + program := tea.NewProgram(model) + _, err = program.Run() + return err +} + +func newDashboardModel(cfg *config.Config, plugins []plugin.InstalledPlugin) *dashboardModel { + current, _ := config.Current() + keys := defaultKeyMap() + + m := &dashboardModel{ + cfg: cfg, + sites: groupContexts(cfg), + plugins: plugins, + tourPanes: loadTourPanes(), + currentContext: current, + width: 120, + height: 36, + keys: keys, + help: help.New(), + spin: spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(spinnerStyle)), + historyCPU: map[string][]float64{}, + historyMemory: map[string][]float64{}, + } + m.help.Styles = helpStyles() + m.siteIndex, m.envIndex = defaultSelection(m.sites, current) + m.detail = viewport.New(viewport.WithWidth(40), viewport.WithHeight(10)) + m.detail.MouseWheelEnabled = true + m.detail.SetContent("Loading...") + m.logs = viewport.New(viewport.WithWidth(40), viewport.WithHeight(10)) + m.logs.MouseWheelEnabled = true + m.logs.SetContent("No logs loaded.") + m.logsTitle = "Logs" + m.actions = newMenuModel("Actions", []menuItem{ + {title: "Refresh", desc: "Reload summary for the selected environment", action: "refresh"}, + {title: "Logs", desc: "Open a log view for this environment", action: "logs"}, + {title: "Choose App", desc: "Open the plugin-backed app chooser", action: "chooser"}, + }) + m.settings = newMenuModel("Settings", []menuItem{ + {title: "Context Details", desc: "Inspect context configuration for the selected environment", action: "context-info"}, + {title: "Plugin Details", desc: "Inspect the selected plugin and template repo", action: "plugin-info"}, + }) + m.chooser = newMenuModel(chooserTitle(m.sites), chooserItems(m.sites, m.plugins)) + m.commands = newMenuModel("Commands", commandPaletteItems("", m.selectedContextName(), m.selectedSiteName(), m.selectedPluginName())) + m.commandInput = textinput.New() + m.commandInput.Prompt = "sitectl --context " + m.selectedContextName() + " " + m.commandInput.Placeholder = "compose ps" + m.commandInput.ShowSuggestions = true + m.commandInput.SetWidth(60) + m.commandInput.Focus() + m.refreshCommandSuggestions() + m.syncLayout() + return m +} + +func (m *dashboardModel) Init() tea.Cmd { + cmds := []tea.Cmd{ + m.spin.Tick, + nextRefreshCmd(), + } + if ctx, ok := m.selectedContext(); ok { + m.loading = true + cmds = append(cmds, loadSummaryCmd(ctx)) + } + return tea.Batch(cmds...) +} + +func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.syncLayout() + return m, nil + + case refreshTickMsg: + cmds := []tea.Cmd{nextRefreshCmd()} + if ctx, ok := m.selectedContext(); ok { + cmds = append(cmds, loadSummaryCmd(ctx)) + if m.screen == screenLogs && strings.HasPrefix(m.logsTitle, "Logs") { + cmds = append(cmds, loadLogsCmd(ctx)) + } + } + return m, tea.Batch(cmds...) + + case summaryLoadedMsg: + if ctx, ok := m.selectedContext(); ok && ctx.Name == msg.ContextName { + m.loading = false + m.summary = msg.Summary + m.summaryErr = msg.Err + if msg.Err == nil { + m.pushHistory( + msg.ContextName, + msg.Summary.CPUPercent, + memoryPercent(msg.Summary), + ) + } + m.syncDetailContent() + } + return m, nil + + case logsLoadedMsg: + if ctx, ok := m.selectedContext(); ok && ctx.Name == msg.ContextName { + m.loadingLog = false + m.logsErr = msg.Err + content := msg.Logs + if msg.Err != nil { + content = msg.Err.Error() + } + if strings.TrimSpace(content) == "" { + content = "No logs returned." + } + m.logs.SetContent(content) + m.logs.GotoBottom() + } + return m, nil + + case commandFinishedMsg: + m.commandRunning = false + m.commandQuitArmed = false + m.screen = screenLogs + m.logsTitle = "Command Output" + content := msg.Output + if msg.Err != nil { + if strings.TrimSpace(content) == "" { + content = msg.Err.Error() + } else { + content += "\n\n" + msg.Err.Error() + } + } + if strings.TrimSpace(content) == "" { + content = "Command completed with no output." + } + m.logs.SetContent(content) + m.logs.GotoTop() + m.syncLayout() + return m, nil + + case commandExecFinishedMsg: + m.commandRunning = false + m.commandQuitArmed = false + if msg.Err != nil { + m.lastMessage = fmt.Sprintf("Command failed: %v", msg.Err) + } else { + m.lastMessage = fmt.Sprintf("Terminal command finished: %s", msg.Command) + } + return m, reloadStateCmd() + + case stateReloadedMsg: + if msg.Err != nil { + m.lastMessage = fmt.Sprintf("Failed to reload sitectl state: %v", msg.Err) + return m, nil + } + m.cfg = msg.Config + m.sites = groupContexts(msg.Config) + m.plugins = msg.Plugins + m.currentContext = msg.CurrentContext + m.siteIndex, m.envIndex = defaultSelection(m.sites, m.currentContext) + m.summary = docker.ProjectSummary{} + m.summaryErr = nil + m.loading = false + m.loadingLog = false + m.logsErr = nil + m.logsTitle = "Logs" + m.screen = screenDashboard + m.overlay = overlayNone + m.chooser = newMenuModel(chooserTitle(m.sites), chooserItems(m.sites, m.plugins)) + m.refreshCommandSuggestions() + m.syncLayout() + if ctx, ok := m.selectedContext(); ok { + m.loading = true + return m, loadSummaryCmd(ctx) + } + return m, nil + + case spinner.TickMsg: + var cmd tea.Cmd + m.spin, cmd = m.spin.Update(msg) + return m, cmd + + case tea.MouseMsg: + if m.overlay != overlayNone { + return m.updateOverlay(msg) + } + if m.screen == screenLogs { + var cmd tea.Cmd + m.logs, cmd = m.logs.Update(msg) + return m, cmd + } + switch msg := msg.(type) { + case tea.MouseReleaseMsg: + return m.handleMouseRelease(msg) + case tea.MouseWheelMsg: + var cmd tea.Cmd + m.detail, cmd = m.detail.Update(msg) + return m, cmd + } + return m, nil + + case tea.KeyPressMsg: + return m.handleKey(msg) + } + + if m.overlay != overlayNone { + return m.updateOverlay(msg) + } + if m.screen == screenLogs { + var cmd tea.Cmd + m.logs, cmd = m.logs.Update(msg) + return m, cmd + } + + var cmd tea.Cmd + m.detail, cmd = m.detail.Update(msg) + return m, cmd +} + +func (m *dashboardModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if !m.hasContexts() { + if m.screen == screenTour { + return m.handleTourKey(msg) + } + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case msg.String() == "enter": + return m.handleOnboardingSelection() + default: + var cmd tea.Cmd + m.chooser, cmd = m.chooser.Update(msg) + return m, cmd + } + } + + if m.overlay == overlayNone && m.commandInput.Focused() { + switch { + case msg.String() == "ctrl+c": + if strings.TrimSpace(m.commandInput.Value()) != "" { + m.commandInput.SetValue("") + m.commandQuitArmed = false + m.lastMessage = "Command cleared." + return m, nil + } + if m.commandQuitArmed { + return m, tea.Quit + } + m.commandQuitArmed = true + m.lastMessage = "Command is empty. Press ctrl+c again to quit." + return m, nil + case msg.String() == "ctrl+a": + m.commandQuitArmed = false + m.commandInput.SetCursor(0) + return m, nil + case key.Matches(msg, m.keys.Back): + m.commandQuitArmed = false + m.commandInput.Blur() + return m, nil + case key.Matches(msg, m.keys.Terminal): + m.commandQuitArmed = false + return m.runCommand(true) + case msg.String() == "enter": + m.commandQuitArmed = false + return m.runCommand(false) + default: + m.commandQuitArmed = false + var cmd tea.Cmd + m.commandInput, cmd = m.commandInput.Update(msg) + return m, cmd + } + } + + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + if m.overlay != overlayNone { + if m.overlay == overlayInfo { + m.syncDetailContent() + } + m.overlay = overlayNone + return m, nil + } + if m.screen == screenLogs { + m.screen = screenDashboard + m.syncLayout() + return m, nil + } + return m, tea.Quit + } + + if m.overlay != overlayNone { + if msg.String() == "enter" { + return m.handleOverlaySelection() + } + return m.updateOverlay(msg) + } + + if m.screen == screenLogs { + switch { + case key.Matches(msg, m.keys.Refresh): + if ctx, ok := m.selectedContext(); ok && strings.HasPrefix(m.logsTitle, "Logs") { + return m, loadLogsCmd(ctx) + } + case key.Matches(msg, m.keys.Logs): + m.screen = screenDashboard + m.syncLayout() + return m, nil + case key.Matches(msg, m.keys.Terminal): + return m.runCommand(true) + case msg.String() == "enter": + return m.runCommand(false) + case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.Down): + var cmd tea.Cmd + m.logs, cmd = m.logs.Update(msg) + return m, cmd + } + var cmd tea.Cmd + m.logs, cmd = m.logs.Update(msg) + return m, cmd + } + + switch { + case key.Matches(msg, m.keys.Left): + if m.siteIndex > 0 { + m.siteIndex-- + m.envIndex = defaultEnvIndex(m.selectedSiteContexts(), m.currentContext) + return m.reloadSelected() + } + case key.Matches(msg, m.keys.Right): + if m.siteIndex < len(m.sites)-1 { + m.siteIndex++ + m.envIndex = defaultEnvIndex(m.selectedSiteContexts(), m.currentContext) + return m.reloadSelected() + } + case key.Matches(msg, m.keys.Up): + if m.envIndex > 0 { + m.envIndex-- + return m.reloadSelected() + } + case key.Matches(msg, m.keys.Down): + if contexts := m.selectedSiteContexts(); m.envIndex < len(contexts)-1 { + m.envIndex++ + return m.reloadSelected() + } + case key.Matches(msg, m.keys.Actions): + m.overlay = overlayActions + return m, nil + case key.Matches(msg, m.keys.Settings): + m.overlay = overlaySettings + return m, nil + case key.Matches(msg, m.keys.NewApp): + m.overlay = overlayChooser + return m, nil + case key.Matches(msg, m.keys.Command): + m.commandInput.Focus() + return m, nil + case key.Matches(msg, m.keys.Palette): + m.commandParent = "" + m.commands = newMenuModel("Commands", commandPaletteItems("", m.selectedContextName(), m.selectedSiteName(), m.selectedPluginName())) + m.overlay = overlayCommands + return m, nil + case key.Matches(msg, m.keys.Logs): + return m.openLogs() + case key.Matches(msg, m.keys.Refresh): + if ctx, ok := m.selectedContext(); ok { + m.loading = true + return m, loadSummaryCmd(ctx) + } + } + + switch { + case key.Matches(msg, m.keys.Terminal): + return m.runCommand(true) + case msg.String() == "enter": + return m.runCommand(false) + } + + var cmd tea.Cmd + m.commandInput, cmd = m.commandInput.Update(msg) + return m, cmd +} + +func (m *dashboardModel) handleMouseRelease(msg tea.MouseReleaseMsg) (tea.Model, tea.Cmd) { + if msg.Mouse().Button != tea.MouseLeft { + return m, nil + } + + for _, targetSite := range m.sites { + if z := zone.Get("tab:" + targetSite.Name); z != nil && z.InBounds(msg) { + for i, site := range m.sites { + if site.Name == targetSite.Name { + m.siteIndex = i + m.envIndex = defaultEnvIndex(site.Contexts, m.currentContext) + return m.reloadSelected() + } + } + } + } + + for i, ctx := range m.selectedSiteContexts() { + if z := zone.Get("env:" + ctx.Name); z != nil && z.InBounds(msg) { + m.envIndex = i + return m.reloadSelected() + } + } + + if z := zone.Get("chip:actions"); z != nil && z.InBounds(msg) { + m.overlay = overlayActions + } + if z := zone.Get("chip:settings"); z != nil && z.InBounds(msg) { + m.overlay = overlaySettings + } + if z := zone.Get("chip:new"); z != nil && z.InBounds(msg) { + m.overlay = overlayChooser + } + if z := zone.Get("chip:logs"); z != nil && z.InBounds(msg) { + return m.openLogs() + } + if z := zone.Get("chip:refresh"); z != nil && z.InBounds(msg) { + if ctx, ok := m.selectedContext(); ok { + m.loading = true + return m, loadSummaryCmd(ctx) + } + } + if z := zone.Get("chip:command"); z != nil && z.InBounds(msg) { + m.commandInput.Focus() + return m, nil + } + if z := zone.Get("chip:palette"); z != nil && z.InBounds(msg) { + m.commandParent = "" + m.commands = newMenuModel("Commands", commandPaletteItems("", m.selectedContextName(), m.selectedSiteName(), m.selectedPluginName())) + m.overlay = overlayCommands + } + + return m, nil +} + +func (m *dashboardModel) updateOverlay(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m.overlay { + case overlayActions: + var cmd tea.Cmd + m.actions, cmd = m.actions.Update(msg) + return m, cmd + case overlaySettings: + var cmd tea.Cmd + m.settings, cmd = m.settings.Update(msg) + return m, cmd + case overlayChooser: + var cmd tea.Cmd + m.chooser, cmd = m.chooser.Update(msg) + return m, cmd + case overlayCommands: + var cmd tea.Cmd + m.commands, cmd = m.commands.Update(msg) + return m, cmd + case overlayInfo: + var cmd tea.Cmd + m.detail, cmd = m.detail.Update(msg) + return m, cmd + default: + return m, nil + } +} + +func (m *dashboardModel) handleOverlaySelection() (tea.Model, tea.Cmd) { + var item menuItem + switch m.overlay { + case overlayActions: + selected, _ := m.actions.SelectedItem().(menuItem) + item = selected + case overlaySettings: + selected, _ := m.settings.SelectedItem().(menuItem) + item = selected + case overlayChooser: + selected, _ := m.chooser.SelectedItem().(menuItem) + item = selected + case overlayCommands: + selected, _ := m.commands.SelectedItem().(menuItem) + item = selected + } + + switch item.action { + case "refresh": + m.overlay = overlayNone + if ctx, ok := m.selectedContext(); ok { + m.loading = true + return m, loadSummaryCmd(ctx) + } + case "logs": + m.overlay = overlayNone + return m.openLogs() + case "chooser": + m.overlay = overlayChooser + return m, nil + case "context-info": + if ctx, ok := m.selectedContext(); ok { + m.infoTitle = "Context Details" + m.infoBody = renderContextInfo(ctx) + m.detail.SetContent(m.infoBody) + m.detail.GotoTop() + m.overlay = overlayInfo + return m, nil + } + case "plugin-info": + if ctx, ok := m.selectedContext(); ok { + m.infoTitle = "Plugin Details" + m.infoBody = renderPluginInfo(findPlugin(m.plugins, ctx.Plugin), ctx.Plugin) + m.detail.SetContent(m.infoBody) + m.detail.GotoTop() + m.overlay = overlayInfo + return m, nil + } + default: + if item.action == "config-create" || strings.HasPrefix(item.action, "plugin:") { + m.overlay = overlayNone + return m.executeChooserAction(item.action) + } + if strings.HasPrefix(item.action, "palette:") { + parent := strings.TrimPrefix(item.action, "palette:") + m.commandParent = parent + m.commands = newMenuModel(commandPaletteTitle(parent), commandPaletteItems(parent, m.selectedContextName(), m.selectedSiteName(), m.selectedPluginName())) + return m, nil + } + if strings.HasPrefix(item.action, "fill:") { + m.commandInput.SetValue(strings.TrimPrefix(item.action, "fill:")) + m.overlay = overlayNone + m.commandInput.Focus() + return m, nil + } + } + + m.overlay = overlayNone + return m, nil +} + +func (m *dashboardModel) openLogs() (tea.Model, tea.Cmd) { + ctx, ok := m.selectedContext() + if !ok { + return m, nil + } + m.screen = screenLogs + m.loadingLog = true + m.logsTitle = "Logs | tail 20 | auto-refresh" + m.syncLayout() + return m, loadLogsCmd(ctx) +} + +func (m *dashboardModel) reloadSelected() (tea.Model, tea.Cmd) { + m.summary = docker.ProjectSummary{} + m.summaryErr = nil + m.refreshCommandSuggestions() + m.syncDetailContent() + if ctx, ok := m.selectedContext(); ok { + m.loading = true + cmds := []tea.Cmd{loadSummaryCmd(ctx)} + if m.screen == screenLogs { + m.loadingLog = true + cmds = append(cmds, loadLogsCmd(ctx)) + } + return m, tea.Batch(cmds...) + } + return m, nil +} + +func (m *dashboardModel) View() tea.View { + content := m.render() + v := tea.NewView(zone.Scan(content)) + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + return v +} + +func (m *dashboardModel) render() string { + if m.width < 100 || m.height < 28 { + return docStyle.Render(panelStyle.Width(max(40, m.width-6)).Render("Terminal too small for the sitectl dashboard.\n\nResize to at least 100x28.")) + } + if !m.hasContexts() && m.screen == screenTour { + return docStyle.Render(m.renderTourArea()) + } + if !m.hasContexts() { + return docStyle.Render(m.renderOnboarding()) + } + + body := lipgloss.JoinVertical(lipgloss.Left, + m.renderTabs(), + m.renderHeaderChips(), + m.renderTitle(), + m.renderResourceHeader(), + m.renderMainArea(), + m.renderCommandFooter(), + footerStyle.Render(m.help.View(m.keys)), + ) + + if strings.TrimSpace(m.lastMessage) != "" { + body = lipgloss.JoinVertical(lipgloss.Left, body, subtleStyle.Render(m.lastMessage)) + } + + rendered := docStyle.Render(body) + if m.overlay != overlayNone { + return overlay(rendered, m.renderOverlay(), m.width, 1) + } + return rendered +} + +func (m *dashboardModel) renderTabs() string { + tabs := make([]string, 0, len(m.sites)) + for i, site := range m.sites { + label := fmt.Sprintf("%d:%s", i+1, site.Name) + tab := tabStyle.Render(label) + if i == m.siteIndex { + tab = activeTabStyle.Render(label) + } + tabs = append(tabs, zone.Mark("tab:"+site.Name, tab)) + } + return lipgloss.JoinHorizontal(lipgloss.Left, tabs...) +} + +func (m *dashboardModel) renderHeaderChips() string { + chips := []string{ + zone.Mark("chip:actions", chipStyle.Render("[ctrl+a] Actions")), + zone.Mark("chip:settings", chipStyle.Render("[ctrl+s] Settings")), + zone.Mark("chip:new", chipStyle.Render("[ctrl+n] Choose App")), + zone.Mark("chip:command", chipStyle.Render("[/] Command")), + zone.Mark("chip:palette", chipStyle.Render("[ctrl+p] Palette")), + zone.Mark("chip:logs", chipStyle.Render("[ctrl+g] Logs")), + zone.Mark("chip:refresh", chipStyle.Render("[ctrl+r] Refresh")), + } + return lipgloss.JoinHorizontal(lipgloss.Left, chips...) +} + +func (m *dashboardModel) renderTitle() string { + site := m.sites[m.siteIndex] + ctx, _ := m.selectedContext() + contextName := "-" + if ctx.Name != "" { + contextName = ctx.Name + } + line := strings.Repeat("-", max(4, m.width-len(site.Name)-len(contextName)-20)) + return titleStyle.Render(fmt.Sprintf(" Sitectl | %s | %s ", site.Name, contextName)) + subtleStyle.Render(line) +} + +func (m *dashboardModel) renderResourceHeader() string { + ctx, _ := m.selectedContext() + historyKey := ctx.Name + widths := splitWidth(max(m.width-8, 60), 2) + cpuDetail := fmt.Sprintf("%.1f%% total across %d containers", m.summary.CPUPercent, m.summary.Total) + memDetail := fmt.Sprintf("%s / %s", humanBytes(m.summary.MemoryBytes), humanBytes(m.summary.MemoryLimitBytes)) + if m.loading { + cpuDetail = "Refreshing docker stats..." + memDetail = "Refreshing docker stats..." + } + return lipgloss.JoinHorizontal( + lipgloss.Top, + renderChartBox("CPU", m.historyCPU[historyKey], cpuDetail, "#F4A261", widths[0]), + renderChartBox("Memory", m.historyMemory[historyKey], memDetail, "#98C1D9", widths[1]), + ) +} + +func (m *dashboardModel) renderMainArea() string { + switch m.screen { + case screenLogs: + return m.renderLogsArea() + case screenTour: + return m.renderTourArea() + default: + return m.renderDashboardArea() + } +} + +func (m *dashboardModel) renderDashboardArea() string { + width := max(m.width-6, 80) + return lipgloss.JoinVertical( + lipgloss.Left, + m.renderEnvironmentCards(width), + m.renderDetailsPanel(width), + ) +} + +func (m *dashboardModel) renderLogsArea() string { + ctx, _ := m.selectedContext() + hint := "Auto-refreshing the latest 20 log lines. Scroll with mouse wheel or j/k. Press esc or ctrl+g to return." + if m.logsTitle == "Command Output" { + hint = "Command output. Press esc to return to the dashboard and keep using the footer command bar." + } + header := panelStyle.Width(max(40, m.width-6)).Render(strings.Join([]string{ + m.logsTitle, + fmt.Sprintf("Context: %s", ctx.Name), + hint, + }, "\n")) + body := panelStyle.Width(max(40, m.width-6)).Height(max(10, m.height-14)).Render(renderViewportWithScrollbar(m.logs)) + if m.loadingLog { + header = panelStyle.Width(max(40, m.width-6)).Render(m.spin.View() + " Loading logs...\nContext: " + ctx.Name) + } + return lipgloss.JoinVertical(lipgloss.Left, header, body) +} + +func (m *dashboardModel) renderEnvironmentCards(width int) string { + site := m.sites[m.siteIndex] + cards := make([]string, 0, len(site.Contexts)+1) + cards = append(cards, sectionTitleStyle.Render("Environments")) + if len(site.Contexts) == 0 { + return lipgloss.JoinVertical(lipgloss.Left, cards...) + } + count := len(site.Contexts) + gapTotal := max(0, count-1) + selectedWidth := 34 + compactWidth := 18 + if count == 1 { + selectedWidth = width - 2 + } + if count > 1 && selectedWidth+compactWidth*(count-1)+gapTotal > width { + compactWidth = max(14, (width-selectedWidth-gapTotal)/(count-1)) + } + if count > 1 && selectedWidth+compactWidth*(count-1)+gapTotal > width { + selectedWidth = max(24, width-compactWidth*(count-1)-gapTotal) + } + if selectedWidth+compactWidth*(count-1)+gapTotal > width { + selectedWidth = max(18, (width-gapTotal)/count) + compactWidth = selectedWidth + } + row := make([]string, 0, len(site.Contexts)) + for i, ctx := range site.Contexts { + selected := i == m.envIndex + cardWidth := compactWidth + lines := []string{strings.ToUpper(envLabel(ctx)), ctx.Name} + if selected { + cardWidth = selectedWidth + lines = append(lines, + fmt.Sprintf("plugin: %s", firstNonEmpty(ctx.Plugin, "core")), + fmt.Sprintf("compose: %s", firstNonEmpty(ctx.EffectiveComposeProjectName(), "-")), + fmt.Sprintf("network: %s", firstNonEmpty(ctx.EffectiveComposeNetwork(), "-")), + ) + if ctx.DockerHostType == config.ContextRemote { + lines = append(lines, fmt.Sprintf("host: %s", firstNonEmpty(ctx.SSHHostname, "-"))) + } else { + lines = append(lines, fmt.Sprintf("dir: %s", firstNonEmpty(ctx.ProjectDir, "-"))) + } + } else { + lines = append(lines, firstNonEmpty(ctx.Plugin, "core")) + } + if ctx.Name == m.currentContext { + lines = append(lines, accentStyle.Render("current")) + } + body := strings.Join(lines, "\n") + style := cardStyle.Width(cardWidth) + if i == m.envIndex { + style = selectedCardStyle.Width(cardWidth) + } + row = append(row, zone.Mark("env:"+ctx.Name, style.Render(body))) + } + cards = append(cards, lipgloss.JoinHorizontal(lipgloss.Top, row...)) + return lipgloss.JoinVertical(lipgloss.Left, cards...) +} + +func (m *dashboardModel) renderDetailsPanel(width int) string { + content := renderViewportWithScrollbar(m.detail) + if m.loading { + content = m.spin.View() + " Loading Docker Compose status..." + } + + panelHeight := min(max(10, m.height-30), 16) + return panelStyle.Width(max(32, width-4)).Height(panelHeight).Render( + sectionTitleStyle.MarginBottom(0).Render("Selected Environment Status") + "\n" + content, + ) +} + +func (m *dashboardModel) renderOverlay() string { + title := "Menu" + content := "" + switch m.overlay { + case overlayActions: + title = "Actions" + content = m.actions.View() + case overlaySettings: + title = "Settings" + content = m.settings.View() + case overlayChooser: + title = "Choose An App" + content = m.chooser.View() + case overlayInfo: + title = m.infoTitle + content = renderViewportWithScrollbar(m.detail) + case overlayCommands: + title = commandPaletteTitle(m.commandParent) + content = m.commands.View() + } + return overlayPanelStyle.Width(min(72, max(48, m.width-12))).Render(sectionTitleStyle.Render(title) + "\n" + content) +} + +func (m *dashboardModel) renderOnboarding() string { + width := max(56, min(88, m.width-10)) + intro := panelStyle.Width(width).Render(strings.Join([]string{ + titleStyle.Render("Sitectl | Get Started"), + "", + "No contexts are configured yet.", + "Set up an existing Docker Compose site with sitectl, or create a new site from an installed plugin.", + "", + "Use arrow keys to choose an option and press enter to launch it in your terminal.", + }, "\n")) + + menu := panelStyle.Width(width).Render(m.chooser.View()) + footer := footerStyle.Width(width).Render("enter: launch up/down: choose q: quit") + body := lipgloss.JoinVertical(lipgloss.Left, intro, menu, footer) + if strings.TrimSpace(m.lastMessage) != "" { + body = lipgloss.JoinVertical(lipgloss.Left, body, subtleStyle.Render(m.lastMessage)) + } + return body +} + +func (m *dashboardModel) renderTourArea() string { + width := max(56, m.width-6) + header := panelStyle.Width(width).Render(strings.Join([]string{ + titleStyle.Render("Sitectl Tour"), + m.currentTourTitle(), + fmt.Sprintf("Pane %d of %d", m.currentTourIndex()+1, len(m.tourPanes)), + "left/right: next section esc: back to setup/create", + }, "\n")) + body := panelStyle.Width(width).Height(max(12, m.height-12)).Render(renderViewportWithScrollbar(m.detail)) + return lipgloss.JoinVertical(lipgloss.Left, header, body) +} + +func (m *dashboardModel) renderCommandFooter() string { + contextName := m.selectedContextName() + status := accentStyle.Render("ready") + if m.commandRunning { + status = accentStyle.Render(m.spin.View() + " running") + } + hint := subtleStyle.Render("type a sitectl subcommand enter: run here ctrl+x: terminal ctrl+p: palette") + bar := footerCommandStyle.Width(max(40, m.width-6)).Render( + fmt.Sprintf("Context: %s [%s]\n%s\n%s", contextName, status, m.commandInput.View(), hint), + ) + return bar +} + +func (m *dashboardModel) syncLayout() { + hpad, _ := docStyle.GetFrameSize() + m.help.SetWidth(max(20, m.width-hpad)) + + detailHeight := min(max(8, m.height-32), 14) + if m.screen == screenTour { + detailHeight = max(12, m.height-16) + } + m.detail.SetWidth(max(40, m.width-hpad-8)) + m.detail.SetHeight(detailHeight) + + logHeight := max(10, m.height-14) + m.logs.SetWidth(max(30, m.width-hpad-8)) + m.logs.SetHeight(logHeight) + + menuWidth := min(58, max(36, m.width/2)) + menuHeight := min(18, max(10, m.height/2)) + m.actions.SetSize(menuWidth, menuHeight) + m.settings.SetSize(menuWidth, menuHeight) + m.chooser.SetSize(menuWidth, menuHeight) + m.commands.SetSize(menuWidth, menuHeight) + m.commandInput.SetWidth(max(20, m.width-18)) + m.commandInput.Prompt = "sitectl --context " + m.selectedContextName() + " " + + m.syncDetailContent() +} + +func (m *dashboardModel) syncDetailContent() { + if m.screen == screenTour { + return + } + ctx, ok := m.selectedContext() + if !ok { + m.detail.SetContent("No context selected.") + return + } + if m.overlay == overlayInfo && strings.TrimSpace(m.infoBody) != "" { + m.detail.SetContent(m.infoBody) + return + } + if m.summaryErr != nil { + m.detail.SetContent(m.summaryErr.Error()) + return + } + + lines := []string{ + fmt.Sprintf("Status: %s", strings.ToUpper(firstNonEmpty(m.summary.Status, "unknown"))), + fmt.Sprintf("CPU: %.1f%%", m.summary.CPUPercent), + fmt.Sprintf("Memory: %s / %s", humanBytes(m.summary.MemoryBytes), humanBytes(m.summary.MemoryLimitBytes)), + fmt.Sprintf("Containers: %d total, %d running, %d healthy, %d stopped", m.summary.Total, m.summary.Running, m.summary.Healthy, m.summary.Stopped), + "", + "Services:", + } + if len(m.summary.Services) == 0 { + lines = append(lines, " No Compose containers found for this context.") + } else { + for _, service := range m.summary.Services { + lines = append(lines, fmt.Sprintf(" %s %s", service.Service, firstNonEmpty(service.Status, service.State))) + } + } + lines = append(lines, + "", + fmt.Sprintf("Context directory: %s", firstNonEmpty(ctx.ProjectDir, "-")), + fmt.Sprintf("Compose project: %s", firstNonEmpty(ctx.EffectiveComposeProjectName(), "-")), + fmt.Sprintf("Compose network: %s", firstNonEmpty(ctx.EffectiveComposeNetwork(), "-")), + ) + m.detail.SetContent(strings.Join(lines, "\n")) +} + +func (m *dashboardModel) pushHistory(contextName string, cpu, memory float64) { + m.historyCPU[contextName] = appendLimited(m.historyCPU[contextName], cpu, 24) + m.historyMemory[contextName] = appendLimited(m.historyMemory[contextName], memory, 24) +} + +func (m *dashboardModel) selectedSiteContexts() []config.Context { + if len(m.sites) == 0 || m.siteIndex >= len(m.sites) { + return nil + } + return m.sites[m.siteIndex].Contexts +} + +func (m *dashboardModel) selectedContext() (config.Context, bool) { + contexts := m.selectedSiteContexts() + if len(contexts) == 0 || m.envIndex >= len(contexts) { + return config.Context{}, false + } + return contexts[m.envIndex], true +} + +func newMenuModel(title string, items []menuItem) list.Model { + delegate := list.NewDefaultDelegate() + converted := make([]list.Item, 0, len(items)) + for _, item := range items { + converted = append(converted, item) + } + m := list.New(converted, delegate, 48, 12) + m.Title = title + m.SetFilteringEnabled(false) + m.SetShowStatusBar(false) + m.SetShowHelp(false) + m.DisableQuitKeybindings() + return m +} + +func pluginMenuItems(plugins []plugin.InstalledPlugin) []menuItem { + items := make([]menuItem, 0, len(plugins)) + for _, p := range plugins { + if !p.CanCreate || strings.TrimSpace(p.TemplateRepo) == "" { + continue + } + items = append(items, menuItem{ + title: fmt.Sprintf("Install the %s stack locally", p.Name), + desc: p.TemplateRepo, + action: "plugin:" + p.Name, + }) + } + if len(items) == 0 { + items = append(items, menuItem{ + title: "No site-create plugins found", + desc: "Install a sitectl-* plugin with a template repo and create command.", + action: "", + }) + } + return items +} + +func loadTourPanes() []docs.TourPane { + panes, err := docs.LoadTour() + if err != nil { + return nil + } + return panes +} + +func loadSummaryCmd(ctx config.Context) tea.Cmd { + return func() tea.Msg { + summary, err := docker.SummarizeProject(&ctx) + return summaryLoadedMsg{ContextName: ctx.Name, Summary: summary, Err: err} + } +} + +func loadLogsCmd(ctx config.Context) tea.Cmd { + return func() tea.Msg { + logs, err := fetchComposeLogs(ctx) + return logsLoadedMsg{ContextName: ctx.Name, Logs: logs, Err: err} + } +} + +func nextRefreshCmd() tea.Cmd { + return tea.Tick(3*time.Second, func(t time.Time) tea.Msg { return refreshTickMsg(t) }) +} + +func fetchComposeLogs(ctx config.Context) (string, error) { + args := composeArgs(ctx, "logs", "--tail", "20", "--timestamps", "--no-color") + if ctx.DockerHostType == config.ContextLocal { + cmd := exec.Command("docker", args...) + cmd.Dir = ctx.ProjectDir + output, err := cmd.CombinedOutput() + return string(output), err + } + + remoteCmd := fmt.Sprintf("cd %s && ", shellquote.Join(ctx.ProjectDir)) + if ctx.RunSudo { + remoteCmd += "sudo " + } + remoteCmd += shellquote.Join(append([]string{"docker"}, args...)...) + + client, err := ctx.DialSSH() + if err != nil { + return "", err + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return "", err + } + defer session.Close() + + output, err := session.CombinedOutput(remoteCmd) + if err != nil { + if _, ok := err.(*ssh.ExitError); ok && len(output) > 0 { + return string(output), nil + } + return string(output), err + } + return string(output), nil +} + +func composeArgs(ctx config.Context, subcommand ...string) []string { + args := []string{"compose"} + for _, file := range ctx.ComposeFile { + args = append(args, "-f", file) + } + for _, env := range ctx.EnvFile { + args = append(args, "--env-file", env) + } + args = append(args, subcommand...) + return args +} + +func groupContexts(cfg *config.Config) []siteGroup { + if cfg == nil || len(cfg.Contexts) == 0 { + return nil + } + + siteMap := map[string][]config.Context{} + for _, ctx := range cfg.Contexts { + siteName := firstNonEmpty(ctx.Site, ctx.ProjectName, ctx.Name, "default") + siteMap[siteName] = append(siteMap[siteName], ctx) + } + + names := make([]string, 0, len(siteMap)) + for name := range siteMap { + names = append(names, name) + } + sort.Strings(names) + + sites := make([]siteGroup, 0, len(names)) + for _, name := range names { + contexts := siteMap[name] + sort.Slice(contexts, func(i, j int) bool { + leftEnv := envLabel(contexts[i]) + rightEnv := envLabel(contexts[j]) + leftRank := envSortRank(leftEnv) + rightRank := envSortRank(rightEnv) + if leftRank != rightRank { + return leftRank < rightRank + } + if leftEnv != rightEnv { + return leftEnv < rightEnv + } + return contexts[i].Name < contexts[j].Name + }) + sites = append(sites, siteGroup{Name: name, Contexts: contexts}) + } + + return sites +} + +func defaultSelection(sites []siteGroup, current string) (int, int) { + for i, site := range sites { + for j, ctx := range site.Contexts { + if ctx.Name == current { + return i, j + } + } + } + return 0, 0 +} + +func defaultEnvIndex(contexts []config.Context, current string) int { + for i, ctx := range contexts { + if ctx.Name == current { + return i + } + } + return 0 +} + +func envLabel(ctx config.Context) string { + return firstNonEmpty(ctx.Environment, "unknown") +} + +func envSortRank(value string) int { + value = strings.ToLower(strings.TrimSpace(value)) + switch value { + case "local": + return 0 + case "dev", "development": + return 1 + case "test", "testing", "stage", "staging": + return 2 + case "prod", "production": + return 3 + default: + return 4 + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func findPlugin(plugins []plugin.InstalledPlugin, name string) plugin.InstalledPlugin { + for _, p := range plugins { + if p.Name == name { + return p + } + } + return plugin.InstalledPlugin{Name: name} +} + +func (m *dashboardModel) selectedContextName() string { + if ctx, ok := m.selectedContext(); ok { + return ctx.Name + } + return "-" +} + +func (m *dashboardModel) selectedSiteName() string { + if len(m.sites) == 0 || m.siteIndex >= len(m.sites) { + return "-" + } + return m.sites[m.siteIndex].Name +} + +func (m *dashboardModel) selectedPluginName() string { + if ctx, ok := m.selectedContext(); ok { + return ctx.Plugin + } + return "" +} + +func (m *dashboardModel) refreshCommandSuggestions() { + m.commandInput.SetSuggestions(commandSuggestions(m.selectedContextName(), m.selectedSiteName(), m.selectedPluginName())) + if m.commandParent != "" { + m.commands = newMenuModel(commandPaletteTitle(m.commandParent), commandPaletteItems(m.commandParent, m.selectedContextName(), m.selectedSiteName(), m.selectedPluginName())) + } +} + +func (m *dashboardModel) hasContexts() bool { + return len(m.sites) > 0 +} + +func chooserTitle(sites []siteGroup) string { + if len(sites) == 0 { + return "Get Started" + } + return "Choose An App" +} + +func chooserItems(sites []siteGroup, plugins []plugin.InstalledPlugin) []menuItem { + if len(sites) == 0 { + items := []menuItem{ + { + title: "Take the Tour", + desc: "Overview of contexts, plugins, and components.", + action: "tour", + }, + { + title: "Set Up Existing Project", + desc: "Register an existing Docker Compose site with sitectl.", + action: "config-create", + }, + } + items = append(items, pluginMenuItems(plugins)...) + return items + } + return pluginMenuItems(plugins) +} + +func (m *dashboardModel) handleOnboardingSelection() (tea.Model, tea.Cmd) { + selected, ok := m.chooser.SelectedItem().(menuItem) + if !ok || strings.TrimSpace(selected.action) == "" { + return m, nil + } + return m.executeChooserAction(selected.action) +} + +func renderContextInfo(ctx config.Context) string { + lines := []string{ + fmt.Sprintf("Name: %s", ctx.Name), + fmt.Sprintf("Site: %s", firstNonEmpty(ctx.Site, "-")), + fmt.Sprintf("Environment: %s", envLabel(ctx)), + fmt.Sprintf("Plugin: %s", firstNonEmpty(ctx.Plugin, "-")), + fmt.Sprintf("Docker Host Type: %s", firstNonEmpty(string(ctx.DockerHostType), "-")), + fmt.Sprintf("Project Name: %s", firstNonEmpty(ctx.ProjectName, "-")), + fmt.Sprintf("Compose Project: %s", firstNonEmpty(ctx.EffectiveComposeProjectName(), "-")), + fmt.Sprintf("Compose Network: %s", firstNonEmpty(ctx.EffectiveComposeNetwork(), "-")), + fmt.Sprintf("Project Dir: %s", firstNonEmpty(ctx.ProjectDir, "-")), + fmt.Sprintf("Docker Socket: %s", firstNonEmpty(ctx.DockerSocket, "-")), + } + if ctx.DockerHostType == config.ContextRemote { + lines = append(lines, + fmt.Sprintf("SSH Host: %s", firstNonEmpty(ctx.SSHHostname, "-")), + fmt.Sprintf("SSH User: %s", firstNonEmpty(ctx.SSHUser, "-")), + fmt.Sprintf("SSH Port: %d", ctx.SSHPort), + ) + } + if len(ctx.ComposeFile) > 0 { + lines = append(lines, "", "Compose Files:") + for _, file := range ctx.ComposeFile { + lines = append(lines, " "+file) + } + } + if len(ctx.EnvFile) > 0 { + lines = append(lines, "", "Env Files:") + for _, file := range ctx.EnvFile { + lines = append(lines, " "+file) + } + } + return strings.Join(lines, "\n") +} + +func renderPluginInfo(p plugin.InstalledPlugin, fallbackName string) string { + name := firstNonEmpty(p.Name, fallbackName, "unknown") + lines := []string{ + fmt.Sprintf("Name: %s", name), + fmt.Sprintf("Description: %s", firstNonEmpty(p.Description, "-")), + fmt.Sprintf("Version: %s", firstNonEmpty(p.Version, "-")), + fmt.Sprintf("Author: %s", firstNonEmpty(p.Author, "-")), + fmt.Sprintf("Binary: %s", firstNonEmpty(p.BinaryName, "-")), + fmt.Sprintf("Path: %s", firstNonEmpty(p.Path, "-")), + fmt.Sprintf("Template Repo: %s", firstNonEmpty(p.TemplateRepo, "-")), + } + return strings.Join(lines, "\n") +} + +func commandPaletteTitle(parent string) string { + if strings.TrimSpace(parent) == "" { + return "Commands" + } + return strings.ToUpper(parent[:1]) + parent[1:] + " Commands" +} + +func commandPaletteItems(parent, contextName, siteName, pluginName string) []menuItem { + switch parent { + case "compose": + return []menuItem{ + {title: "ps", desc: "Show compose service status", action: "fill:compose ps"}, + {title: "logs", desc: "Fetch recent compose logs", action: "fill:compose logs --tail 80 --no-color"}, + {title: "up", desc: "Start services in detached mode", action: "fill:compose up"}, + {title: "down", desc: "Stop and remove services", action: "fill:compose down"}, + {title: "restart", desc: "Restart all services", action: "fill:compose restart"}, + {title: "exec", desc: "Open a shell in a service container", action: "fill:compose exec -it drupal bash"}, + } + case "config": + return []menuItem{ + {title: "validate", desc: "Validate the selected context", action: "fill:config validate"}, + {title: "current-context", desc: "Show active context resolution", action: "fill:config current-context"}, + {title: "get-environments", desc: "List environments for this site", action: "fill:config get-environments " + siteName}, + {title: "get-sites", desc: "List configured sites", action: "fill:config get-sites"}, + } + case "port-forward": + return []menuItem{ + {title: "traefik", desc: "Forward a common HTTP admin port", action: "fill:port-forward 8080:traefik:8080"}, + {title: "solr", desc: "Forward Solr admin for a remote site", action: "fill:port-forward 8983:solr:8983"}, + } + case "plugin": + return []menuItem{ + {title: pluginName, desc: "Open plugin help", action: "fill:" + pluginName + " --help"}, + } + default: + items := []menuItem{ + {title: "compose", desc: "Docker Compose commands for the selected environment", action: "palette:compose"}, + {title: "config", desc: "Context-aware configuration commands", action: "palette:config"}, + {title: "make", desc: "Run project make targets through sitectl", action: "fill:make"}, + {title: "port-forward", desc: "Forward ports to remote services", action: "palette:port-forward"}, + {title: "sequelace", desc: "Open database tooling for this context", action: "fill:sequelace"}, + } + if strings.TrimSpace(pluginName) != "" && pluginName != "core" { + items = append(items, menuItem{title: pluginName, desc: "Plugin-specific commands", action: "palette:plugin"}) + } + if strings.TrimSpace(contextName) != "" { + items = append(items, menuItem{title: "help", desc: "Show sitectl help", action: "fill:--help"}) + } + return items + } +} + +func commandSuggestions(contextName, siteName, pluginName string) []string { + items := []string{ + "compose ps", + "compose logs --tail 80 --no-color", + "compose up", + "compose down", + "compose restart", + "compose exec -it drupal bash", + "config validate", + "config current-context", + "config get-sites", + "config get-environments " + siteName, + "make", + "port-forward 8080:traefik:8080", + "sequelace", + } + if strings.TrimSpace(pluginName) != "" && pluginName != "core" { + items = append(items, pluginName+" --help") + } + return items +} + +func (m *dashboardModel) runCommand(interactive bool) (tea.Model, tea.Cmd) { + raw := strings.TrimSpace(m.commandInput.Value()) + if raw == "" { + return m, nil + } + display, args, err := normalizeSitectlCommand(raw, m.selectedContextName()) + if err != nil { + m.lastMessage = err.Error() + return m, nil + } + + if interactive || isInteractiveArgs(args) { + m.commandRunning = true + m.commandInput.SetValue("") + return m, runSitectlInteractiveCmd(display, args) + } + + m.commandRunning = true + m.logsTitle = "Command Output" + m.logs.SetContent("Running " + display + "...") + m.screen = screenLogs + m.commandInput.SetValue("") + return m, runSitectlCaptureCmd(display, args) +} + +func (m *dashboardModel) executeChooserAction(action string) (tea.Model, tea.Cmd) { + switch { + case action == "tour": + if len(m.tourPanes) == 0 { + m.lastMessage = "No embedded tour content found." + return m, nil + } + m.screen = screenTour + m.envIndex = 0 + m.syncLayout() + m.syncTourContent() + return m, nil + case action == "config-create": + m.commandRunning = true + return m, runSitectlInteractiveCmd("sitectl config create", []string{"config", "create"}) + case strings.HasPrefix(action, "plugin:"): + pluginName := strings.TrimPrefix(action, "plugin:") + if strings.TrimSpace(pluginName) == "" { + return m, nil + } + m.commandRunning = true + return m, runSitectlInteractiveCmd("sitectl "+pluginName+" create", []string{pluginName, "create"}) + default: + return m, nil + } +} + +func (m *dashboardModel) handleTourKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Back): + m.screen = screenDashboard + m.syncLayout() + return m, nil + case key.Matches(msg, m.keys.Left), key.Matches(msg, m.keys.Up): + if m.envIndex > 0 { + m.envIndex-- + m.syncTourContent() + } + return m, nil + case key.Matches(msg, m.keys.Right), key.Matches(msg, m.keys.Down): + if m.envIndex < len(m.tourPanes)-1 { + m.envIndex++ + m.syncTourContent() + } + return m, nil + } + + var cmd tea.Cmd + m.detail, cmd = m.detail.Update(msg) + return m, cmd +} + +func (m *dashboardModel) currentTourIndex() int { + if len(m.tourPanes) == 0 { + return 0 + } + if m.envIndex < 0 { + return 0 + } + if m.envIndex >= len(m.tourPanes) { + return len(m.tourPanes) - 1 + } + return m.envIndex +} + +func (m *dashboardModel) currentTourTitle() string { + if len(m.tourPanes) == 0 { + return "-" + } + return m.tourPanes[m.currentTourIndex()].Title +} + +func (m *dashboardModel) syncTourContent() { + if len(m.tourPanes) == 0 { + m.detail.SetContent("No embedded tour content found.") + return + } + rendered, err := glamour.Render(m.tourPanes[m.currentTourIndex()].Markdown, "dark") + if err != nil { + m.detail.SetContent(err.Error()) + return + } + m.detail.SetContent(rendered) + m.detail.GotoTop() +} + +func renderViewportWithScrollbar(v viewport.Model) string { + body := v.View() + total := v.TotalLineCount() + height := v.Height() + if total <= height || height <= 0 { + return body + } + + lines := strings.Split(body, "\n") + if len(lines) < height { + lines = append(lines, make([]string, height-len(lines))...) + } else if len(lines) > height { + lines = lines[:height] + } + + thumbHeight := max(1, (height*height)/max(total, 1)) + maxOffset := max(total-height, 1) + offset := min(max(v.YOffset(), 0), maxOffset) + thumbTop := 0 + if height > thumbHeight { + thumbTop = (offset * (height - thumbHeight)) / maxOffset + } + + rows := make([]string, height) + for i := 0; i < height; i++ { + bar := subtleStyle.Render("│") + if i >= thumbTop && i < thumbTop+thumbHeight { + bar = accentStyle.Render("█") + } + rows[i] = lipgloss.JoinHorizontal(lipgloss.Top, lines[i], " ", bar) + } + return strings.Join(rows, "\n") +} + +func reloadStateCmd() tea.Cmd { + return func() tea.Msg { + cfg, err := config.Load() + if err != nil { + return stateReloadedMsg{Err: err} + } + current, err := config.Current() + if err != nil { + return stateReloadedMsg{Err: err} + } + return stateReloadedMsg{ + Config: cfg, + Plugins: plugin.DiscoverInstalled(), + CurrentContext: current, + } + } +} + +func normalizeSitectlCommand(raw, contextName string) (string, []string, error) { + args, err := shellquote.Split(raw) + if err != nil { + return "", nil, fmt.Errorf("parse command: %w", err) + } + if len(args) == 0 { + return "", nil, fmt.Errorf("command cannot be empty") + } + if args[0] == "sitectl" { + args = args[1:] + } + if len(args) == 0 { + return "", nil, fmt.Errorf("command cannot be empty") + } + if !containsContextArg(args) && strings.TrimSpace(contextName) != "" && contextName != "-" { + args = append([]string{"--context", contextName}, args...) + } + return "sitectl " + strings.Join(args, " "), args, nil +} + +func containsContextArg(args []string) bool { + for i := 0; i < len(args); i++ { + if args[i] == "--context" { + return true + } + if strings.HasPrefix(args[i], "--context=") { + return true + } + } + return false +} + +func isInteractiveArgs(args []string) bool { + if len(args) == 0 { + return false + } + switch args[0] { + case "port-forward", "sequelace": + return true + case "compose": + if len(args) < 2 { + return false + } + switch args[1] { + case "exec", "run", "attach", "watch": + return true + case "logs": + for _, arg := range args[2:] { + if arg == "-f" || arg == "--follow" { + return true + } + } + } + } + return false +} + +func runSitectlCaptureCmd(display string, args []string) tea.Cmd { + return func() tea.Msg { + exe, err := os.Executable() + if err != nil { + return commandFinishedMsg{Command: display, Err: err} + } + cmd := exec.Command(exe, args...) + output, err := cmd.CombinedOutput() + return commandFinishedMsg{Command: display, Output: string(output), Err: err} + } +} + +func runSitectlInteractiveCmd(display string, args []string) tea.Cmd { + exe, err := os.Executable() + if err != nil { + return func() tea.Msg { return commandExecFinishedMsg{Command: display, Err: err} } + } + cmd := exec.Command(exe, args...) + return tea.ExecProcess(cmd, func(err error) tea.Msg { + return commandExecFinishedMsg{Command: display, Err: err} + }) +} + +func appendLimited(values []float64, next float64, limit int) []float64 { + values = append(values, next) + if len(values) > limit { + values = values[len(values)-limit:] + } + return values +} + +func memoryPercent(summary docker.ProjectSummary) float64 { + if summary.MemoryLimitBytes == 0 { + return 0 + } + return (float64(summary.MemoryBytes) / float64(summary.MemoryLimitBytes)) * 100 +} + +func humanBytes(value uint64) string { + if value == 0 { + return "0B" + } + const ( + kb = 1000 + mb = kb * 1000 + gb = mb * 1000 + tb = gb * 1000 + ) + switch { + case value >= tb: + return fmt.Sprintf("%.1fTB", float64(value)/tb) + case value >= gb: + return fmt.Sprintf("%.1fGB", float64(value)/gb) + case value >= mb: + return fmt.Sprintf("%.1fMB", float64(value)/mb) + case value >= kb: + return fmt.Sprintf("%.1fKB", float64(value)/kb) + default: + return fmt.Sprintf("%dB", value) + } +} + +func renderChartBox(title string, values []float64, detail, border string, width int) string { + innerWidth := max(8, width-6) + chart := sparkline.New(innerWidth, 4) + chart.PushAll(values) + chart.DrawBraille() + content := sectionTitleStyle.MarginBottom(0).Render(title) + "\n" + chart.View() + "\n" + detail + style := panelStyle.Width(width) + if strings.TrimSpace(border) != "" { + style = style.BorderForeground(lipgloss.Color(border)) + } + return style.Render(content) +} diff --git a/pkg/tui/dashboard_test.go b/pkg/tui/dashboard_test.go new file mode 100644 index 0000000..276b1b0 --- /dev/null +++ b/pkg/tui/dashboard_test.go @@ -0,0 +1,62 @@ +package tui + +import ( + "testing" + + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/plugin" +) + +func TestGroupContextsBySite(t *testing.T) { + cfg := &config.Config{ + CurrentContext: "museum-dev", + Contexts: []config.Context{ + {Name: "museum-prod", Site: "museum", Environment: "prod"}, + {Name: "museum-dev", Site: "museum", Environment: "dev"}, + {Name: "archive-local", Site: "archive", Environment: "local"}, + }, + } + + sites := groupContexts(cfg) + if len(sites) != 2 { + t.Fatalf("expected 2 sites, got %d", len(sites)) + } + if sites[0].Name != "archive" { + t.Fatalf("expected archive to sort first, got %q", sites[0].Name) + } + if sites[1].Contexts[0].Name != "museum-dev" { + t.Fatalf("expected dev env to sort before prod, got %q", sites[1].Contexts[0].Name) + } +} + +func TestDefaultSelectionUsesCurrentContext(t *testing.T) { + sites := []siteGroup{ + {Name: "archive", Contexts: []config.Context{{Name: "archive-local"}}}, + {Name: "museum", Contexts: []config.Context{{Name: "museum-dev"}, {Name: "museum-prod"}}}, + } + + siteIndex, envIndex := defaultSelection(sites, "museum-prod") + if siteIndex != 1 || envIndex != 1 { + t.Fatalf("expected museum-prod selection at 1,1 got %d,%d", siteIndex, envIndex) + } +} + +func TestChooserItemsForEmptyStateIncludesSetupAndCreatePlugins(t *testing.T) { + items := chooserItems(nil, []plugin.InstalledPlugin{ + {Name: "drupal"}, + {Name: "isle", CanCreate: true, TemplateRepo: "https://example.com/isle"}, + }) + + if len(items) != 3 { + t.Fatalf("expected tour option, setup option, plus one create plugin, got %d items", len(items)) + } + if items[0].action != "tour" { + t.Fatalf("expected first item to launch tour, got %q", items[0].action) + } + if items[1].action != "config-create" { + t.Fatalf("expected second item to launch config create, got %q", items[1].action) + } + if items[2].action != "plugin:isle" { + t.Fatalf("expected third item to launch isle create, got %q", items[2].action) + } +} diff --git a/pkg/tui/styles.go b/pkg/tui/styles.go new file mode 100644 index 0000000..2a02d9e --- /dev/null +++ b/pkg/tui/styles.go @@ -0,0 +1,128 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/lipgloss/v2" +) + +var ( + docStyle = lipgloss.NewStyle(). + Padding(1, 2). + Foreground(lipgloss.Color("#D9E2EC")). + Background(lipgloss.Color("#0D1B2A")) + + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#E0FBFC")) + + subtleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7C98B3")) + + accentStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#F4A261")) + + sectionTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#98C1D9")). + MarginBottom(1) + + panelStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#486581")). + Padding(1, 2). + MarginRight(1). + MarginBottom(1) + + overlayPanelStyle = panelStyle. + BorderForeground(lipgloss.Color("#98C1D9")). + Background(lipgloss.Color("#112235")) + + cardStyle = panelStyle.Width(40) + + selectedCardStyle = cardStyle. + BorderForeground(lipgloss.Color("#F4A261")) + + tabStyle = lipgloss.NewStyle(). + Padding(0, 1). + MarginRight(1). + Foreground(lipgloss.Color("#7C98B3")) + + activeTabStyle = tabStyle. + Bold(true). + Foreground(lipgloss.Color("#0D1B2A")). + Background(lipgloss.Color("#98C1D9")) + + chipStyle = lipgloss.NewStyle(). + Padding(0, 1). + MarginRight(1). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#34506B")). + Foreground(lipgloss.Color("#C9D6DF")) + + footerCommandStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#34506B")). + Padding(1, 2). + MarginTop(1). + MarginBottom(1) + + footerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#9FB3C8")) + + spinnerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F4A261")) +) + +func helpStyles() help.Styles { + styles := help.New().Styles + styles.ShortKey = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#98C1D9")) + styles.ShortDesc = lipgloss.NewStyle().Foreground(lipgloss.Color("#7C98B3")) + styles.ShortSeparator = lipgloss.NewStyle().Foreground(lipgloss.Color("#486581")) + styles.FullKey = styles.ShortKey + styles.FullDesc = styles.ShortDesc + styles.FullSeparator = styles.ShortSeparator + styles.Ellipsis = styles.ShortSeparator + return styles +} + +func overlay(base, top string, width int, progress float64) string { + if strings.TrimSpace(top) == "" { + return base + } + baseLines := strings.Split(base, "\n") + topLines := strings.Split(top, "\n") + x := max(2, width/2-lipgloss.Width(top)/2+int((1-progress)*10)) + y := 4 + + for i, line := range topLines { + idx := y + i + if idx >= len(baseLines) { + break + } + prefixWidth := min(x, lipgloss.Width(baseLines[idx])) + prefix := lipgloss.NewStyle().Width(prefixWidth).Render(baseLines[idx]) + baseLines[idx] = fmt.Sprintf("%s%s", prefix, line) + } + + return strings.Join(baseLines, "\n") +} + +func splitWidth(total, columns int) []int { + if columns <= 0 { + return nil + } + widths := make([]int, columns) + base := total / columns + remainder := total % columns + for i := range widths { + widths[i] = base + if i < remainder { + widths[i]++ + } + } + return widths +} diff --git a/pkg/ui/choice_prompt.go b/pkg/ui/choice_prompt.go new file mode 100644 index 0000000..d56aadd --- /dev/null +++ b/pkg/ui/choice_prompt.go @@ -0,0 +1,323 @@ +package ui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type Choice struct { + Value string + Label string + Help string + AllowCustomInput bool +} + +type ChoicePromptOptions struct { + Name string + Sections []string + Choices []Choice + DefaultValue string +} + +type choicePromptItem struct { + title string + desc string + value string + custom bool +} + +func (i choicePromptItem) Title() string { return i.title } +func (i choicePromptItem) Description() string { return i.desc } +func (i choicePromptItem) FilterValue() string { return i.title + " " + i.desc } + +type choicePromptKeys struct { + Confirm key.Binding + Cancel key.Binding +} + +func (k choicePromptKeys) ShortHelp() []key.Binding { + return []key.Binding{k.Confirm, k.Cancel} +} + +func (k choicePromptKeys) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Confirm, k.Cancel}} +} + +type choicePromptModel struct { + name string + sections []string + list list.Model + input textinput.Model + help help.Model + keys choicePromptKeys + width int + height int + cancelled bool + value string +} + +func PromptChoice(opts ChoicePromptOptions) (string, bool, error) { + model := newChoicePromptModel(opts) + resultModel, err := tea.NewProgram(model).Run() + if err != nil { + return "", true, err + } + + result, ok := resultModel.(*choicePromptModel) + if !ok { + return "", true, fmt.Errorf("unexpected prompt result type %T", resultModel) + } + if result.cancelled { + return "", true, fmt.Errorf("prompt cancelled") + } + return result.value, true, nil +} + +func newChoicePromptModel(opts ChoicePromptOptions) *choicePromptModel { + items := make([]list.Item, 0, len(opts.Choices)) + for _, choice := range opts.Choices { + desc := strings.TrimSpace(choice.Help) + if desc == "" { + desc = choice.Label + } + items = append(items, choicePromptItem{ + title: choice.Label, + desc: desc, + value: choice.Value, + custom: choice.AllowCustomInput, + }) + } + + delegate := list.NewDefaultDelegate() + l := list.New(items, delegate, 0, 0) + l.Title = opts.Name + l.SetShowTitle(false) + l.SetShowStatusBar(false) + l.SetShowHelp(false) + l.SetFilteringEnabled(false) + l.DisableQuitKeybindings() + + input := textinput.New() + input.Prompt = "custom> " + input.Placeholder = "Enter a custom value" + input.SetValue(defaultCustomInput(opts.Choices, opts.DefaultValue)) + + helpModel := help.New() + helpModel.ShowAll = false + + m := &choicePromptModel{ + name: opts.Name, + sections: append([]string{}, opts.Sections...), + list: l, + input: input, + help: helpModel, + keys: choicePromptKeys{ + Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), + Cancel: key.NewBinding(key.WithKeys("esc", "ctrl+c"), key.WithHelp("esc", "cancel")), + }, + width: 80, + height: 24, + } + m.selectDefault(opts.DefaultValue) + m.syncInputFocus() + return m +} + +func (m *choicePromptModel) Init() tea.Cmd { + if m.selectedCustom() { + return m.input.Focus() + } + return nil +} + +func (m *choicePromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.syncLayout() + return m, nil + + case tea.KeyPressMsg: + navigationKey := isTextInputNavKey(msg) + switch { + case key.Matches(msg, m.keys.Cancel): + m.cancelled = true + return m, tea.Quit + + case key.Matches(msg, m.keys.Confirm): + item, ok := m.selectedItem() + if !ok { + m.cancelled = true + return m, tea.Quit + } + if item.custom { + value := strings.TrimSpace(m.input.Value()) + if value != "" { + m.value = value + } else { + m.value = item.value + } + } else { + m.value = item.value + } + return m, tea.Quit + } + + if m.selectedCustom() && !isNavigationKey(msg.String()) { + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd + } + + prevIndex := m.list.Index() + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + if m.list.Index() != prevIndex { + m.syncInputFocus() + } + + if m.selectedCustom() && navigationKey { + var inputCmd tea.Cmd + m.input, inputCmd = m.input.Update(msg) + cmd = tea.Batch(cmd, inputCmd) + } + return m, cmd + } + + prevIndex := m.list.Index() + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + if m.list.Index() != prevIndex { + m.syncInputFocus() + } + return m, cmd +} + +func (m *choicePromptModel) View() tea.View { + headerParts := make([]string, 0, len(m.sections)+2) + headerParts = append(headerParts, m.sections...) + if len(headerParts) > 0 { + headerParts = append(headerParts, "") + } + + bodyParts := []string{m.list.View()} + if m.selectedCustom() { + bodyParts = append(bodyParts, "", customBoxStyle.Width(max(20, m.width-6)).Render(m.input.View())) + } + bodyParts = append(bodyParts, "", footerHelpStyle.Render(m.help.View(m.keys))) + + content := lipgloss.JoinVertical(lipgloss.Left, append(headerParts, bodyParts...)...) + return tea.NewView(promptDocStyle.Render(content)) +} + +func (m *choicePromptModel) syncLayout() { + w := clampInt(m.width-4, 40, 100) + m.help.SetWidth(w) + + headerHeight := len(m.sections) + if headerHeight > 0 { + headerHeight++ + } + inputHeight := 0 + if m.selectedCustom() { + inputHeight = 3 + } + listHeight := clampInt(m.height-headerHeight-inputHeight-6, 6, 18) + m.list.SetSize(w, listHeight) + m.input.SetWidth(w - 8) +} + +func (m *choicePromptModel) selectDefault(defaultValue string) { + for i, item := range m.list.Items() { + candidate, ok := item.(choicePromptItem) + if ok && candidate.value == defaultValue { + m.list.Select(i) + return + } + } +} + +func (m *choicePromptModel) selectedItem() (choicePromptItem, bool) { + item, ok := m.list.SelectedItem().(choicePromptItem) + return item, ok +} + +func (m *choicePromptModel) selectedCustom() bool { + item, ok := m.selectedItem() + return ok && item.custom +} + +func (m *choicePromptModel) syncInputFocus() { + if m.selectedCustom() { + _ = m.input.Focus() + return + } + m.input.Blur() +} + +func defaultCustomInput(choices []Choice, defaultValue string) string { + trimmed := strings.TrimSpace(defaultValue) + if trimmed == "" { + return "" + } + customSelected := false + for _, choice := range choices { + if choice.AllowCustomInput { + customSelected = true + continue + } + if trimmed == choice.Value { + return "" + } + } + if !customSelected { + return "" + } + return trimmed +} + +func isNavigationKey(key string) bool { + switch key { + case "up", "down", "j", "k", "pgup", "pgdown", "home", "end": + return true + default: + return false + } +} + +func isTextInputNavKey(msg tea.KeyPressMsg) bool { + switch msg.String() { + case "left", "right", "backspace", "delete", "ctrl+h", "ctrl+u", "ctrl+k", "home", "end": + return true + default: + return false + } +} + +func clampInt(v, low, high int) int { + if v < low { + return low + } + if v > high { + return high + } + return v +} + +var ( + promptDocStyle = lipgloss.NewStyle().Padding(1, 2) + customBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#486581")). + Padding(1, 2) + footerHelpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7C98B3")) +) diff --git a/pkg/ui/text_prompt.go b/pkg/ui/text_prompt.go new file mode 100644 index 0000000..fbeee83 --- /dev/null +++ b/pkg/ui/text_prompt.go @@ -0,0 +1,130 @@ +package ui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type TextPromptOptions struct { + Sections []string + Prompt string +} + +type textPromptKeys struct { + Confirm key.Binding + Cancel key.Binding +} + +func (k textPromptKeys) ShortHelp() []key.Binding { + return []key.Binding{k.Confirm, k.Cancel} +} + +func (k textPromptKeys) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Confirm, k.Cancel}} +} + +type textPromptModel struct { + sections []string + prompt string + input textinput.Model + help help.Model + keys textPromptKeys + width int + height int + cancelled bool + value string +} + +func PromptText(opts TextPromptOptions) (string, bool, error) { + model := newTextPromptModel(opts) + resultModel, err := tea.NewProgram(model).Run() + if err != nil { + return "", true, err + } + + result, ok := resultModel.(*textPromptModel) + if !ok { + return "", true, fmt.Errorf("unexpected prompt result type %T", resultModel) + } + if result.cancelled { + return "", true, fmt.Errorf("prompt cancelled") + } + return strings.TrimSpace(result.value), true, nil +} + +func newTextPromptModel(opts TextPromptOptions) *textPromptModel { + input := textinput.New() + input.Prompt = strings.TrimSpace(opts.Prompt) + " " + input.Focus() + + helpModel := help.New() + helpModel.ShowAll = false + + return &textPromptModel{ + sections: append([]string{}, opts.Sections...), + prompt: opts.Prompt, + input: input, + help: helpModel, + keys: textPromptKeys{ + Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), + Cancel: key.NewBinding(key.WithKeys("esc", "ctrl+c"), key.WithHelp("esc", "cancel")), + }, + width: 80, + height: 24, + } +} + +func (m *textPromptModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m *textPromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.syncLayout() + return m, nil + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keys.Cancel): + m.cancelled = true + return m, tea.Quit + case key.Matches(msg, m.keys.Confirm): + m.value = m.input.Value() + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd +} + +func (m *textPromptModel) View() tea.View { + headerParts := make([]string, 0, len(m.sections)+2) + headerParts = append(headerParts, m.sections...) + if len(headerParts) > 0 { + headerParts = append(headerParts, "") + } + + inputBox := customBoxStyle.Width(max(20, m.width-6)).Render(m.input.View()) + content := lipgloss.JoinVertical( + lipgloss.Left, + append(headerParts, inputBox, "", footerHelpStyle.Render(m.help.View(m.keys)))..., + ) + return tea.NewView(promptDocStyle.Render(content)) +} + +func (m *textPromptModel) syncLayout() { + w := clampInt(m.width-4, 40, 100) + m.help.SetWidth(w) + m.input.SetWidth(w - 8) +} diff --git a/pkg/validate/core.go b/pkg/validate/core.go index 4f4bb9a..c25f493 100644 --- a/pkg/validate/core.go +++ b/pkg/validate/core.go @@ -32,6 +32,8 @@ func requiredFieldsValidator(ctx *config.Context) ([]Result, error) { requiredStringResult("type", string(ctx.DockerHostType)), requiredStringResult("project-dir", ctx.ProjectDir), requiredStringResult("project-name", ctx.ProjectName), + requiredStringResult("compose-project-name", ctx.EffectiveComposeProjectName()), + requiredStringResult("compose-network", ctx.EffectiveComposeNetwork()), } if ctx.DockerHostType == config.ContextRemote { results = append(results,