Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 5 additions & 5 deletions cmd/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ package cmd

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"

"github.com/libops/sitectl/pkg/config"
Expand Down Expand Up @@ -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
Expand Down
150 changes: 137 additions & 13 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 <site>-<environment>, for example museum-local or museum-prod."), "\n"),
"",
corecomponent.RenderPromptLine("Context name [%s]: "),
),
Expand All @@ -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 <site>-<environment>, 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])
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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...)
}
Expand Down
Loading
Loading