diff --git a/Makefile b/Makefile index 0273485..5cab6bf 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build deps lint test docker integration-test plugins install-plugins publish-aptly-repo install +.PHONY: build deps lint test docker integration-test plugins install-plugins publish-aptly-repo install docs-snippets BINARY_NAME=sitectl DOCS_PORT ?= 3000 @@ -32,3 +32,6 @@ test: build publish-aptly-repo: bash ./scripts/publish-aptly-repo.sh + +docs-snippets: + go run ./scripts/gen-docs-snippets/ diff --git a/cmd/component.go b/cmd/component.go index b8d8c89..880deab 100644 --- a/cmd/component.go +++ b/cmd/component.go @@ -49,13 +49,22 @@ var ( var componentCmd = &cobra.Command{ Use: "component", - Short: "Describe and reconcile stack components for the active context", + Short: "Inspect and manage stack components for the active context", + Long: `Components are optional stack features — such as Fcrepo or Blazegraph — that can be toggled on or off. + +sitectl dispatches component commands to the plugin associated with the active context. The plugin +provides the component registry; sitectl provides a consistent entry point regardless of which stack +you are working with.`, } var componentDescribeCmd = &cobra.Command{ Use: "describe", Aliases: []string{"status"}, - Short: "Describe the current component state", + Short: "Show the current state of each component", + Long: `Show the current state of each component registered by the active context's plugin. + +Each component is reported as on, off, or drifted. A drifted component means the project files no +longer match the last recorded state — run reconcile to bring them back into alignment.`, RunE: func(cmd *cobra.Command, args []string) error { contextName, owner, name, err := resolveComponentOwner(cmd, componentDescribeName) if err != nil { @@ -86,7 +95,11 @@ var componentDescribeCmd = &cobra.Command{ var componentReconcileCmd = &cobra.Command{ Use: "reconcile", Aliases: []string{"review", "align"}, - Short: "Review and reconcile component state", + Short: "Detect and repair component configuration drift", + Long: `Inspect each component and apply any changes needed to bring the project back into alignment. + +By default the command is interactive and asks before applying changes. Pass --report to preview +what would change without applying it.`, RunE: func(cmd *cobra.Command, args []string) error { contextName, owner, name, err := resolveComponentOwner(cmd, componentReconcileName) if err != nil { @@ -119,8 +132,14 @@ var componentReconcileCmd = &cobra.Command{ var componentSetCmd = &cobra.Command{ Use: "set [disposition]", - Short: "Set a component disposition", - Args: cobra.RangeArgs(1, 2), + Short: "Enable, disable, or reconfigure a component", + Long: `Set the state or disposition of a named component in the active context's plugin. + +Prefix the component name with the plugin namespace to target it directly: + + sitectl component set isle/fcrepo off + sitectl component set isle/blazegraph off`, + Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { contextName, owner, name, err := resolveComponentOwner(cmd, args[0]) if err != nil { @@ -157,25 +176,25 @@ var componentSetCmd = &cobra.Command{ func init() { pluginSDK = plugin.NewSDK(plugin.Metadata{Name: "sitectl"}) - componentDescribeCmd.Flags().StringVarP(&componentDescribeName, "component", "c", "", "Namespaced component to describe, for example isle/fcrepo") - componentDescribeCmd.Flags().StringVar(&componentDescribePath, "path", "", "Project path override") - componentDescribeCmd.Flags().StringVar(&componentDescribeDrupalRoot, "drupal-rootfs", "", "Drupal rootfs path override") - componentDescribeCmd.Flags().BoolVar(&componentDescribeVerbose, "verbose", false, "Include verbose component details") - componentDescribeCmd.Flags().StringVar(&componentDescribeFormat, "format", "", "Output format override") - - componentReconcileCmd.Flags().StringVarP(&componentReconcileName, "component", "c", "", "Namespaced component to reconcile, for example isle/fcrepo") - componentReconcileCmd.Flags().StringVar(&componentReconcilePath, "path", "", "Project path override") - componentReconcileCmd.Flags().StringVar(&componentReconcileDrupalRoot, "drupal-rootfs", "", "Drupal rootfs path override") - componentReconcileCmd.Flags().BoolVar(&componentReconcileReport, "report", false, "Render a report instead of applying changes") - componentReconcileCmd.Flags().BoolVar(&componentReconcileVerbose, "verbose", false, "Include verbose component details") - componentReconcileCmd.Flags().StringVar(&componentReconcileFormat, "format", "", "Output format override") - - componentSetCmd.Flags().StringVar(&componentSetPath, "path", "", "Project path override") - componentSetCmd.Flags().StringVar(&componentSetDrupalRoot, "drupal-rootfs", "", "Drupal rootfs path override") - componentSetCmd.Flags().StringVar(&componentSetState, "state", "", "Explicit state override") - componentSetCmd.Flags().StringVar(&componentSetDisposition, "disposition", "", "Explicit disposition override") - componentSetCmd.Flags().StringVar(&componentSetTLSMode, "tls-mode", "", "TLS mode override") - componentSetCmd.Flags().BoolVar(&componentSetYolo, "yolo", false, "Apply without confirmation") + componentDescribeCmd.Flags().StringVarP(&componentDescribeName, "component", "c", "", "Component to describe, e.g. isle/fcrepo. Defaults to all components.") + componentDescribeCmd.Flags().StringVar(&componentDescribePath, "path", "", "Path to the project directory. Defaults to the active context project directory.") + componentDescribeCmd.Flags().StringVar(&componentDescribeDrupalRoot, "drupal-rootfs", "", "Path to the Drupal web root, relative to --path.") + componentDescribeCmd.Flags().BoolVar(&componentDescribeVerbose, "verbose", false, "Show additional details for each component.") + componentDescribeCmd.Flags().StringVar(&componentDescribeFormat, "format", "", "Output format (default: table).") + + componentReconcileCmd.Flags().StringVarP(&componentReconcileName, "component", "c", "", "Component to reconcile, e.g. isle/fcrepo. Defaults to all components.") + componentReconcileCmd.Flags().StringVar(&componentReconcilePath, "path", "", "Path to the project directory. Defaults to the active context project directory.") + componentReconcileCmd.Flags().StringVar(&componentReconcileDrupalRoot, "drupal-rootfs", "", "Path to the Drupal web root, relative to --path.") + componentReconcileCmd.Flags().BoolVar(&componentReconcileReport, "report", false, "Preview changes without applying them.") + componentReconcileCmd.Flags().BoolVar(&componentReconcileVerbose, "verbose", false, "Show additional details for each component.") + componentReconcileCmd.Flags().StringVar(&componentReconcileFormat, "format", "", "Output format (default: table).") + + componentSetCmd.Flags().StringVar(&componentSetPath, "path", "", "Path to the project directory. Defaults to the active context project directory.") + componentSetCmd.Flags().StringVar(&componentSetDrupalRoot, "drupal-rootfs", "", "Path to the Drupal web root, relative to --path.") + componentSetCmd.Flags().StringVar(&componentSetState, "state", "", "State to apply (on, off).") + componentSetCmd.Flags().StringVar(&componentSetDisposition, "disposition", "", "Disposition to apply (enabled, disabled, superceded, distributed).") + componentSetCmd.Flags().StringVar(&componentSetTLSMode, "tls-mode", "", "TLS mode (http, self-managed, mkcert, letsencrypt).") + componentSetCmd.Flags().BoolVar(&componentSetYolo, "yolo", false, "Skip the confirmation prompt.") componentCmd.AddCommand(componentDescribeCmd) componentCmd.AddCommand(componentReconcileCmd) diff --git a/cmd/compose.go b/cmd/compose.go index 561f5c2..8599e6c 100644 --- a/cmd/compose.go +++ b/cmd/compose.go @@ -79,7 +79,7 @@ Examples: "--help", } if len(filteredArgs) == 0 || !slices.Contains(validCommands, filteredArgs[0]) { - helpers.ExitOnError(fmt.Errorf("unknown docker compose command: %s", filteredArgs[0])) + return fmt.Errorf("unknown docker compose command: %s", filteredArgs[0]) } context, err := config.GetContext(sitectlContext) @@ -90,10 +90,10 @@ Examples: if context.DockerHostType == config.ContextLocal { hasComposeProject, err := context.HasComposeProject() if err != nil { - helpers.ExitOnError(fmt.Errorf("failed to inspect compose project in %s: %v", context.ProjectDir, err)) + return fmt.Errorf("failed to inspect compose project in %s: %w", 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)) + return 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 c17a301..52ff90e 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -4,8 +4,6 @@ import ( "errors" "fmt" "io" - "log" - "log/slog" "os" "os/user" "path/filepath" @@ -43,74 +41,76 @@ You can have a default context which will be used when running sitectl commands, var viewConfigCmd = &cobra.Command{ Use: "view", Short: "Print your sitectl config", - Run: func(cmd *cobra.Command, args []string) { - path := config.ConfigFilePath() + RunE: func(cmd *cobra.Command, args []string) error { + path, err := config.ConfigFilePath() + if err != nil { + return err + } info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { - fmt.Printf("File %q does not exist.\n", path) - return + fmt.Fprintf(cmd.OutOrStdout(), "File %q does not exist.\n", path) + return nil } - log.Fatalf("Error checking file: %v", err) + return fmt.Errorf("error checking file: %w", err) } - - // Check if it's a regular file. if !info.Mode().IsRegular() { - log.Fatalf("%q is not a regular file", path) + return fmt.Errorf("%q is not a regular file", path) } - data, err := os.ReadFile(path) if err != nil { - log.Fatalf("Error reading file: %v", err) + return fmt.Errorf("error reading file: %w", err) } - - fmt.Println(string(data)) + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + return nil }, } var currentContextCmd = &cobra.Command{ Use: "current-context", Short: "Display the current site context", - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { c, err := config.Current() if err != nil { - log.Fatal(err) + return err } if c == "" { - fmt.Println("No current context is set") + fmt.Fprintln(cmd.OutOrStdout(), "No current context is set") } else { - fmt.Println("Current context:", c) + fmt.Fprintln(cmd.OutOrStdout(), "Current context:", c) } + return nil }, } var getContextsCmd = &cobra.Command{ Use: "get-contexts", Short: "List all site contexts", - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { - log.Fatal(err) + return err } if len(cfg.Contexts) == 0 { - fmt.Println("No contexts available") - return + fmt.Fprintln(cmd.OutOrStdout(), "No contexts available") + return nil } writeContextTable(cmd.OutOrStdout(), cfg) + return nil }, } var getSitesCmd = &cobra.Command{ Use: "get-sites", Short: "List configured sites and their environments", - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { - log.Fatal(err) + return err } if len(cfg.Contexts) == 0 { fmt.Fprintln(cmd.OutOrStdout(), "No sites available") - return + return nil } sites := map[string][]config.Context{} @@ -152,6 +152,7 @@ var getSitesCmd = &cobra.Command{ ) } _ = w.Flush() + return nil }, } @@ -159,14 +160,14 @@ var getEnvironmentsCmd = &cobra.Command{ Use: "get-environments [site]", Short: "List environments grouped by site", Args: cobra.MaximumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { - log.Fatal(err) + return err } if len(cfg.Contexts) == 0 { fmt.Fprintln(cmd.OutOrStdout(), "No environments available") - return + return nil } siteFilter := "" @@ -187,7 +188,7 @@ var getEnvironmentsCmd = &cobra.Command{ } else { fmt.Fprintf(cmd.OutOrStdout(), "No environments found for site %q\n", siteFilter) } - return + return nil } sort.Slice(contexts, func(i, j int) bool { @@ -221,6 +222,7 @@ var getEnvironmentsCmd = &cobra.Command{ ) } _ = w.Flush() + return nil }, } @@ -308,12 +310,11 @@ var setContextCmd = &cobra.Command{ cc.SSHKeyPath = "" cc.DockerSocket = config.GetDefaultLocalDockerSocket(cc.DockerSocket) default: - slog.Error("Unknown context type", "type", cc.DockerHostType) - os.Exit(1) + return fmt.Errorf("unknown context type %q", cc.DockerHostType) } if err = config.SaveContext(cc, defaultContext); err != nil { - helpers.ExitOnError(err) + return err } return nil @@ -324,11 +325,11 @@ var useContextCmd = &cobra.Command{ Use: "use-context [context-name]", Short: "Switch to the specified context", Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { name := args[0] cfg, err := config.Load() if err != nil { - log.Fatal(err) + return err } found := false for _, ctx := range cfg.Contexts { @@ -338,13 +339,14 @@ var useContextCmd = &cobra.Command{ } } if !found { - log.Fatalf("Context %s not found", name) + return fmt.Errorf("context %q not found", name) } cfg.CurrentContext = name if err = config.Save(cfg); err != nil { - log.Fatal(err) + return err } - fmt.Println("Switched to context:", name) + fmt.Fprintln(cmd.OutOrStdout(), "Switched to context:", name) + return nil }, } @@ -352,18 +354,15 @@ var deleteContextCmd = &cobra.Command{ Use: "delete-context [context-name]", Short: "Delete a site context", Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { name := args[0] cfg, err := config.Load() if err != nil { - log.Fatal(err) + return err } - if cfg.CurrentContext == name { - slog.Error("Cannot delete the current context. You can update it or create a new context with `sitectl config set-context`") - return + return fmt.Errorf("cannot delete the current context; switch to another context first") } - found := false var newContexts []config.Context for _, ctx := range cfg.Contexts { @@ -374,14 +373,14 @@ var deleteContextCmd = &cobra.Command{ newContexts = append(newContexts, ctx) } if !found { - log.Fatalf("Context %s not found", name) + return fmt.Errorf("context %q not found", name) } cfg.Contexts = newContexts - if err = config.Save(cfg); err != nil { - log.Fatal(err) + return err } - fmt.Printf("Deleted context: %s\n", name) + fmt.Fprintf(cmd.OutOrStdout(), "Deleted context: %s\n", name) + return nil }, } diff --git a/cmd/cron.go b/cmd/cron.go index a9d5dca..3e31a8d 100644 --- a/cmd/cron.go +++ b/cmd/cron.go @@ -31,20 +31,29 @@ var ( var cronCmd = &cobra.Command{ Use: "cron", - Short: "Manage scheduled cron-style jobs", + Short: "Manage scheduled cron jobs", + Long: `Cron specs define scheduled jobs for a sitectl context. Each spec records which context +to run on, what schedule to use, which job components to run, and where to store output. + +Use render-systemd to turn a spec into systemd units you can install on the host.`, } var cronAddCmd = &cobra.Command{ Use: "add NAME", Args: cobra.ExactArgs(1), Short: "Create or update a cron spec", + Long: `Create or update a cron spec with the given name. + +A cron spec stores the schedule, target context, job components, output directory, and +retention policy for a scheduled job. Once saved, use render-systemd to generate the +systemd units and install them on the host.`, RunE: func(cmd *cobra.Command, args []string) error { name := strings.TrimSpace(args[0]) if name == "" { return fmt.Errorf("cron spec name is required") } if strings.TrimSpace(cronSpecContext) == "" { - return fmt.Errorf("--context is required") + return fmt.Errorf("--cron-context is required") } if strings.TrimSpace(cronSpecSchedule) == "" { return fmt.Errorf("--schedule is required") @@ -178,7 +187,11 @@ var cronRunCmd = &cobra.Command{ var cronRenderSystemdCmd = &cobra.Command{ Use: "render-systemd NAME", Args: cobra.ExactArgs(1), - Short: "Render setup instructions and systemd units for a cron spec", + Short: "Print systemd unit files and install instructions for a cron spec", + Long: `Print the systemd .service and .timer unit files for the named cron spec, along with +step-by-step instructions for installing them on the target host. + +For remote contexts, copy the units to the host manually and install them there.`, RunE: func(cmd *cobra.Command, args []string) error { spec, err := config.GetCronSpec(args[0]) if err != nil { @@ -450,13 +463,13 @@ func cronPluginsForContext(root string) []string { } func init() { - cronAddCmd.Flags().StringVar(&cronSpecContext, "context", "", "Context to run the cron job on") - cronAddCmd.Flags().StringVar(&cronSpecSchedule, "schedule", "", "systemd OnCalendar value, for example daily or *-*-* 03:00:00") - cronAddCmd.Flags().StringVar(&cronSpecOutputDir, "output-dir", "", "Host directory where dated outputs are stored") - cronAddCmd.Flags().StringSliceVar(&cronSpecComponents, "component", nil, "Cron component to include; repeat to select multiple components") - cronAddCmd.Flags().IntVar(&cronSpecRetentionDays, "retention-days", 14, "Delete non-monthly artifacts older than this many days") - cronAddCmd.Flags().BoolVar(&cronSpecPreserveFirstOfMonth, "preserve-first-of-month", true, "Keep dated artifacts created on day 01 when pruning") - cronAddCmd.Flags().BoolVar(&cronSpecDockerPrune, "docker-prune", false, "Run docker system prune -af after a successful cron run") + cronAddCmd.Flags().StringVar(&cronSpecContext, "cron-context", "", "sitectl context this cron spec will execute against.") + cronAddCmd.Flags().StringVar(&cronSpecSchedule, "schedule", "", "systemd OnCalendar expression, e.g. daily or *-*-* 03:00:00.") + cronAddCmd.Flags().StringVar(&cronSpecOutputDir, "output-dir", "", "Host directory where dated output artifacts are stored.") + cronAddCmd.Flags().StringSliceVar(&cronSpecComponents, "component", nil, "Job component to include. Repeat to select multiple.") + cronAddCmd.Flags().IntVar(&cronSpecRetentionDays, "retention-days", 14, "Delete non-monthly artifacts older than this many days.") + cronAddCmd.Flags().BoolVar(&cronSpecPreserveFirstOfMonth, "preserve-first-of-month", true, "Keep artifacts created on day 01 of the month when pruning.") + cronAddCmd.Flags().BoolVar(&cronSpecDockerPrune, "docker-prune", false, "Run docker system prune -af after a successful run.") cronCmd.AddCommand(cronAddCmd) cronCmd.AddCommand(cronListCmd) diff --git a/cmd/debug.go b/cmd/debug.go index 96ad7a4..b72005f 100644 --- a/cmd/debug.go +++ b/cmd/debug.go @@ -13,7 +13,6 @@ import ( "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" dockercontainer "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" dockerimage "github.com/docker/docker/api/types/image" @@ -23,6 +22,7 @@ import ( "github.com/libops/sitectl/pkg/docker" "github.com/libops/sitectl/pkg/helpers" "github.com/libops/sitectl/pkg/plugin" + "github.com/libops/sitectl/pkg/plugin/debugui" "github.com/spf13/cobra" "golang.org/x/term" ) @@ -38,33 +38,16 @@ const ( dockerPruneDocsURL = "https://docs.docker.com/engine/manage-resources/pruning/" ) -var ( - debugPanelStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("#112235")). - Padding(1, 2) - debugTitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#98C1D9")) - debugMutedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#9FB3C8")) - debugSectionDividerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#29425E")) - debugStatusOKStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#7BD389")) - debugStatusWarningStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#F4C95D")) - debugStatusFailedStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#F28482")) - debugRowStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("#112235")) -) - var debugCmd = &cobra.Command{ Use: "debug", - Short: "Collect a text support bundle for the active context", + Short: "Collect a diagnostic bundle for the active context", + Long: `Collect a diagnostic bundle for the active context and print it to stdout. + +The bundle includes: host resource summary, Compose service status, container log driver +configuration, Docker image inventory, and plugin-specific diagnostics contributed by the +context's plugin. + +Use --output to write the bundle to a file instead of printing to stdout.`, RunE: func(cmd *cobra.Command, args []string) error { contextName, err := config.ResolveCurrentContextName(cmd.Flags()) if err != nil { @@ -138,8 +121,8 @@ func collectDebugReport(runCtx context.Context, contextName string, ctx config.C } func init() { - debugCmd.Flags().StringVarP(&debugOutputPath, "output", "o", "", "Write the debug report to a file instead of stdout") - debugCmd.Flags().BoolVarP(&debugVerbose, "verbose", "v", false, "Include verbose diagnostic details") + debugCmd.Flags().StringVarP(&debugOutputPath, "output", "o", "", "Write the bundle to a file instead of stdout.") + debugCmd.Flags().BoolVarP(&debugVerbose, "verbose", "v", false, "Include additional diagnostic details.") RootCmd.AddCommand(debugCmd) } @@ -177,7 +160,7 @@ func progressEnabled() bool { func renderCoreDebug(runCtx context.Context, ctx config.Context) string { slog.Debug("starting core debug", "context", ctx.Name, "docker_host_type", ctx.DockerHostType) - meta := []debugRow{ + meta := []debugui.Row{ {Label: "Generated", Value: time.Now().UTC().Format(time.RFC3339)}, {Label: "Context", Value: ctx.Name}, {Label: "Plugin owner", Value: helpers.FirstNonEmpty(ctx.Plugin, "core")}, @@ -185,23 +168,23 @@ func renderCoreDebug(runCtx context.Context, ctx config.Context) string { {Label: "Project dir", Value: ctx.ProjectDir}, } if strings.TrimSpace(ctx.ProjectName) != "" { - meta = append(meta, debugRow{Label: "Project name", Value: ctx.ProjectName}) + meta = append(meta, debugui.Row{Label: "Project name", Value: ctx.ProjectName}) } if strings.TrimSpace(ctx.ComposeProjectName) != "" { - meta = append(meta, debugRow{Label: "Compose project", Value: ctx.ComposeProjectName}) + meta = append(meta, debugui.Row{Label: "Compose project", Value: ctx.ComposeProjectName}) } if strings.TrimSpace(ctx.DockerSocket) != "" { - meta = append(meta, debugRow{Label: "Docker socket", Value: ctx.DockerSocket}) + meta = append(meta, debugui.Row{Label: "Docker socket", Value: ctx.DockerSocket}) } coreBody := []string{ - debugMutedStyle.Render("General Docker configuration and host-level diagnostics for this context."), + debugui.Muted("General Docker configuration and host-level diagnostics for this context."), "", - debugDivider(), + debugui.Divider(), "", - debugTitleStyle.Render("General"), + debugui.Title("General"), "", - formatDebugRows(meta), + debugui.FormatRows(meta), } var sharedSession *debugreport.Session if ctx.DockerHostType == config.ContextRemote { @@ -235,33 +218,33 @@ func renderCoreDebug(runCtx context.Context, ctx config.Context) string { composeDiagnostics = debugreport.CollectComposeDiagnostics(runCtx, &ctx) logDiagnostics, logErr, imageDiagnostics, imageErr = collectCoreDockerDiagnostics(runCtx, &ctx) } - coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Host Resources"), "", formatDebugRows(hostSummaryRows(hostDiagnostics, ctx.ProjectDir))) - coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Compose Services"), "", formatDebugRows(composeSummaryRows(composeDiagnostics))) + coreBody = append(coreBody, "", debugui.Divider(), "", debugui.Title("Host Resources"), "", debugui.FormatRows(hostSummaryRows(hostDiagnostics, ctx.ProjectDir))) + coreBody = append(coreBody, "", debugui.Divider(), "", debugui.Title("Compose Services"), "", debugui.FormatRows(composeSummaryRows(composeDiagnostics))) if logErr == nil { slog.Debug("collected log diagnostics", "context", ctx.Name, "containers", len(logDiagnostics.Containers)) - coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Log Summary"), "", formatDebugRows(logSummaryRows(logDiagnostics))) + coreBody = append(coreBody, "", debugui.Divider(), "", debugui.Title("Log Summary"), "", debugui.FormatRows(logSummaryRows(logDiagnostics))) if debugVerbose { - coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Log Details"), "", renderLogDetailsBody(logDiagnostics)) + coreBody = append(coreBody, "", debugui.Divider(), "", debugui.Title("Log Details"), "", renderLogDetailsBody(logDiagnostics)) } } else { slog.Debug("log diagnostics failed", "context", ctx.Name, "error", logErr) - coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Log Summary"), "", formatDebugRows([]debugRow{ - {Label: "Log status", Value: renderStatus("warning")}, + coreBody = append(coreBody, "", debugui.Divider(), "", debugui.Title("Log Summary"), "", debugui.FormatRows([]debugui.Row{ + {Label: "Log status", Value: debugui.Status("warning")}, {Label: "Log diagnostics", Value: logErr.Error()}, })) } if imageErr == nil { slog.Debug("collected image diagnostics", "context", ctx.Name, "images", imageDiagnostics.ImageCount, "total_bytes", imageDiagnostics.TotalBytes) - coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Image Summary"), "", formatDebugRows(imageSummaryRows(imageDiagnostics))) + coreBody = append(coreBody, "", debugui.Divider(), "", debugui.Title("Image Summary"), "", debugui.FormatRows(imageSummaryRows(imageDiagnostics))) } else { slog.Debug("image diagnostics failed", "context", ctx.Name, "error", imageErr) - coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Image Summary"), "", formatDebugRows([]debugRow{ - {Label: "Image status", Value: renderStatus("warning")}, + coreBody = append(coreBody, "", debugui.Divider(), "", debugui.Title("Image Summary"), "", debugui.FormatRows([]debugui.Row{ + {Label: "Image status", Value: debugui.Status("warning")}, {Label: "Image diagnostics", Value: imageErr.Error()}, })) } slog.Debug("finished core debug", "context", ctx.Name) - return renderDebugPanel("sitectl", strings.Join(coreBody, "\n")) + return debugui.RenderPanel("sitectl", strings.Join(coreBody, "\n")) } type logDiagnostics struct { @@ -306,7 +289,7 @@ type debugSpinnerModel struct { func newDebugSpinnerModel() debugSpinnerModel { return debugSpinnerModel{ - spin: spinner.New(spinner.WithSpinner(spinner.Line), spinner.WithStyle(debugMutedStyle)), + spin: spinner.New(spinner.WithSpinner(spinner.Line), spinner.WithStyle(debugui.MutedStyle)), title: "Preparing Debug Bundle", detail: "Starting diagnostic collection", } @@ -427,32 +410,32 @@ func collectCoreDockerDiagnostics(runCtx context.Context, ctxCfg *config.Context return logs, logErr, images, imageErr } -func imageSummaryRows(diagnostics imageDiagnostics) []debugRow { +func imageSummaryRows(diagnostics imageDiagnostics) []debugui.Row { state := "ok" - rows := []debugRow{ - {Label: "Image status", Value: renderStatus(state)}, + rows := []debugui.Row{ + {Label: "Image status", Value: debugui.Status(state)}, {Label: "Total images", Value: humanBytes(diagnostics.TotalBytes)}, {Label: "Image count", Value: strconv.Itoa(diagnostics.ImageCount)}, } if diagnostics.TotalBytes >= imageSizeWarningThreshold { state = "warning" - rows[0].Value = renderStatus(state) + rows[0].Value = debugui.Status(state) rows = append(rows, - debugRow{Label: "Recommendation", Value: "run docker system prune -af periodically on development hosts"}, - debugRow{Label: "Docs", Value: dockerPruneDocsURL}, + debugui.Row{Label: "Recommendation", Value: "run docker system prune -af periodically on development hosts"}, + debugui.Row{Label: "Docs", Value: dockerPruneDocsURL}, ) } return rows } -func hostSummaryRows(diagnostics debugreport.HostDiagnostics, projectDir string) []debugRow { +func hostSummaryRows(diagnostics debugreport.HostDiagnostics, projectDir string) []debugui.Row { status := "ok" if len(diagnostics.Issues) > 0 { status = "warning" } - rows := []debugRow{ - {Label: "Host status", Value: renderStatus(status)}, + rows := []debugui.Row{ + {Label: "Host status", Value: debugui.Status(status)}, {Label: "CPUs", Value: renderDebugValue(intValueOrUnknown(diagnostics.CPUCount))}, {Label: "Memory", Value: renderDebugValue(bytesValueOrUnknown(diagnostics.MemoryBytes))}, {Label: "Swap", Value: renderDebugValue(bytesValueOrUnknown(diagnostics.SwapBytes))}, @@ -460,26 +443,26 @@ func hostSummaryRows(diagnostics debugreport.HostDiagnostics, projectDir string) {Label: "OS version", Value: renderDebugValue(diagnostics.OSVersion)}, } if len(diagnostics.Issues) > 0 { - rows = append(rows, debugRow{Label: "Diagnostics", Value: strings.Join(diagnostics.Issues, "\n")}) + rows = append(rows, debugui.Row{Label: "Diagnostics", Value: strings.Join(diagnostics.Issues, "\n")}) } return rows } -func composeSummaryRows(diagnostics debugreport.ComposeDiagnostics) []debugRow { +func composeSummaryRows(diagnostics debugreport.ComposeDiagnostics) []debugui.Row { status := "ok" if len(diagnostics.Issues) > 0 { status = "warning" } - rows := []debugRow{ - {Label: "Compose status", Value: renderStatus(status)}, + rows := []debugui.Row{ + {Label: "Compose status", Value: debugui.Status(status)}, {Label: "Compose file", Value: renderDebugValue(diagnostics.ComposePath)}, } if len(diagnostics.Services) == 0 { - rows = append(rows, debugRow{Label: "Services", Value: "none found"}) + rows = append(rows, debugui.Row{Label: "Services", Value: "none found"}) } else { for _, service := range diagnostics.Services { - rows = append(rows, debugRow{Label: service.Service, Value: renderDebugValue(service.Image)}) + rows = append(rows, debugui.Row{Label: service.Service, Value: renderDebugValue(service.Image)}) } } if len(diagnostics.BindMounts) > 0 { @@ -490,11 +473,11 @@ func composeSummaryRows(diagnostics debugreport.ComposeDiagnostics) []debugRow { } else { value += ": " + humanBytes(mount.AvailableBytes) + " available" } - rows = append(rows, debugRow{Label: "Bind mount", Value: value}) + rows = append(rows, debugui.Row{Label: "Bind mount", Value: value}) } } if len(diagnostics.Issues) > 0 { - rows = append(rows, debugRow{Label: "Diagnostics", Value: strings.Join(diagnostics.Issues, "\n")}) + rows = append(rows, debugui.Row{Label: "Diagnostics", Value: strings.Join(diagnostics.Issues, "\n")}) } return rows } @@ -567,7 +550,7 @@ func evaluateLogConfig(driver string, options map[string]string) (rotated bool, } } -func logSummaryRows(diagnostics logDiagnostics) []debugRow { +func logSummaryRows(diagnostics logDiagnostics) []debugui.Row { totalState := "ok" logHandling := "file-backed container logs appear capped" @@ -583,12 +566,12 @@ func logSummaryRows(diagnostics logDiagnostics) []debugRow { logHandling = fmt.Sprintf("%d container(s) are using unbounded file-backed logs", diagnostics.UnboundedCount) } - rows := []debugRow{ - {Label: "Log status", Value: renderStatus(totalState)}, + rows := []debugui.Row{ + {Label: "Log status", Value: debugui.Status(totalState)}, {Label: "Log handling", Value: logHandling}, } if diagnostics.UnboundedCount > 0 { - rows = append(rows, debugRow{ + rows = append(rows, debugui.Row{ Label: "Recommendation", Value: `for non-local environments, configure Docker log rotation with max-size and max-file, or ship logs to syslog, journald, or another central driver @@ -616,86 +599,6 @@ func renderLogDetailsBody(diagnostics logDiagnostics) string { return strings.Join(lines, "\n") } -type debugRow struct { - Label string - Value string -} - -func renderDebugPanel(title, body string) string { - header := debugTitleStyle.Render(strings.TrimSpace(title)) - content := header - if strings.TrimSpace(body) != "" { - content += "\n\n" + body - } - return debugPanelStyle.Width(debugPanelWidth()).Render(content) -} - -func formatDebugRows(rows []debugRow) string { - labelWidth := 0 - for _, row := range rows { - if len(strings.TrimSpace(row.Label)) > labelWidth { - labelWidth = len(strings.TrimSpace(row.Label)) - } - } - - lines := make([]string, 0, len(rows)) - rowWidth := debugContentWidth() - for _, row := range rows { - label := strings.TrimSpace(row.Label) - value := strings.TrimSpace(row.Value) - if label == "" { - lines = append(lines, renderDebugRow(rowWidth, "", value)) - continue - } - lines = append(lines, renderDebugRow(rowWidth, fmt.Sprintf("%-*s", labelWidth, label), value)) - } - return strings.Join(lines, "\n") -} - -func renderDebugRow(width int, label, value string) string { - valueWidth := max(0, width-lipgloss.Width(label)-2) - row := label - if strings.TrimSpace(label) != "" { - row += " " - } - row += lipgloss.NewStyle(). - Width(valueWidth). - Background(lipgloss.Color("#112235")). - Render(value) - return debugRowStyle.Width(width).Render(row) -} - -func debugPanelWidth() int { - if columns, err := strconv.Atoi(strings.TrimSpace(os.Getenv("COLUMNS"))); err == nil && columns > 0 { - return max(40, columns) - } - if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && width > 0 { - return max(40, width) - } - return 100 -} - -func debugContentWidth() int { - return max(20, debugPanelWidth()-4) -} - -func debugDivider() string { - return debugSectionDividerStyle.Width(debugContentWidth()).Render(strings.Repeat("─", debugContentWidth())) -} - -func renderStatus(state string) string { - switch strings.ToLower(strings.TrimSpace(state)) { - case "ok": - return debugStatusOKStyle.Render("OK") - case "warning": - return debugStatusWarningStyle.Render("WARNING") - case "failed": - return debugStatusFailedStyle.Render("FAILED") - default: - return debugMutedStyle.Render(strings.ToUpper(strings.TrimSpace(state))) - } -} - func humanBytes(size int64) string { const unit = 1024 if size < unit { diff --git a/cmd/debug_test.go b/cmd/debug_test.go index 9c3ff9f..6dd5a55 100644 --- a/cmd/debug_test.go +++ b/cmd/debug_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/libops/sitectl/internal/debugreport" + "github.com/libops/sitectl/pkg/plugin/debugui" ) func TestEvaluateLogConfigDetectsUnboundedJSONFileLogs(t *testing.T) { @@ -28,7 +29,7 @@ func TestLogSummaryRowsIncludeRecommendationWhenLogsNeedAttention(t *testing.T) }, }) - rendered := formatDebugRows(rows) + rendered := debugui.FormatRows(rows) if strings.Contains(rendered, "Total logs") { t.Fatalf("expected log summary without total log size, got:\n%s", rendered) } @@ -58,7 +59,7 @@ func TestLogSummaryRowsStayCompact(t *testing.T) { }, }) - rendered := formatDebugRows(rows) + rendered := debugui.FormatRows(rows) if strings.Contains(rendered, "drupal: driver=") { t.Fatalf("expected compact output without per-container detail, got:\n%s", rendered) } @@ -68,7 +69,7 @@ func TestLogSummaryRowsStayCompact(t *testing.T) { } func TestImageSummaryRowsWarnWhenThresholdExceeded(t *testing.T) { - rendered := formatDebugRows(imageSummaryRows(imageDiagnostics{ + rendered := debugui.FormatRows(imageSummaryRows(imageDiagnostics{ TotalBytes: imageSizeWarningThreshold + 1, ImageCount: 42, })) @@ -82,7 +83,7 @@ func TestImageSummaryRowsWarnWhenThresholdExceeded(t *testing.T) { } func TestHostSummaryRowsIncludeRequestedStats(t *testing.T) { - rendered := formatDebugRows(hostSummaryRows(debugreport.HostDiagnostics{ + rendered := debugui.FormatRows(hostSummaryRows(debugreport.HostDiagnostics{ CPUCount: 8, MemoryBytes: 16 * 1024 * 1024 * 1024, SwapBytes: 2 * 1024 * 1024 * 1024, diff --git a/cmd/job.go b/cmd/job.go index 32bb6ac..50b9c87 100644 --- a/cmd/job.go +++ b/cmd/job.go @@ -10,6 +10,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/helpers" corejob "github.com/libops/sitectl/pkg/job" "github.com/libops/sitectl/pkg/plugin" "github.com/spf13/cobra" @@ -20,11 +21,19 @@ import ( var jobCmd = &cobra.Command{ Use: "job", Short: "List and run plugin-defined jobs", + Long: `Jobs are plugin-defined operations that run against a specific context — backups, imports, +and other maintenance tasks. Plugins register jobs when loaded for the active context. + +Use list to see what jobs are available, then run to execute one.`, } var jobListCmd = &cobra.Command{ Use: "list", Short: "List available jobs for the active or selected context", + Long: `List jobs registered by the plugins associated with the active context. + +Each job shows its name, owning plugin, and a short description. Use the name with +job run to execute it.`, RunE: func(cmd *cobra.Command, args []string) error { contextName, err := config.ResolveCurrentContextName(cmd.Flags()) if err != nil { @@ -57,7 +66,7 @@ var jobExecCmd = &cobra.Command{ DisableFlagParsing: true, Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - filteredArgs, contextName, err := pluginContextArgs(args) + filteredArgs, contextName, err := helpers.GetContextFromArgs(cmd, args) if err != nil { return err } @@ -149,32 +158,6 @@ func resolveJobOwner(ctx config.Context, raw string) (string, string, error) { return matches[0].Plugin, matches[0].Name, nil } -func pluginContextArgs(args []string) ([]string, string, error) { - contextName, err := RootCmd.PersistentFlags().GetString("context") - if err != nil { - return nil, "", err - } - filtered := make([]string, 0, len(args)) - skipNext := false - for _, arg := range args { - if skipNext { - contextName = arg - skipNext = false - continue - } - if arg == "--context" { - skipNext = true - continue - } - if strings.HasPrefix(arg, "--context=") { - contextName = strings.TrimSpace(strings.TrimPrefix(arg, "--context=")) - continue - } - filtered = append(filtered, arg) - } - return filtered, contextName, nil -} - func init() { jobCmd.AddCommand(jobListCmd) jobCmd.AddCommand(jobExecCmd) diff --git a/cmd/port-forward.go b/cmd/port-forward.go index 7113082..60c8110 100644 --- a/cmd/port-forward.go +++ b/cmd/port-forward.go @@ -107,23 +107,23 @@ Be sure to run Ctrl+c in your terminal when you are done to close the connection remoteEndpoint := fmt.Sprintf("%s:%d", serviceIp, remotePort) go func(listener net.Listener, lp, remoteAddr string) { - fmt.Printf("Forwarding localhost:%s -> %s via SSH\n", lp, remoteAddr) + fmt.Fprintf(cmd.OutOrStdout(), "Forwarding localhost:%s -> %s via SSH\n", lp, remoteAddr) for { localConn, err := listener.Accept() if err != nil { if strings.Contains(err.Error(), "use of closed network connection") { return } - fmt.Fprintf(os.Stderr, "error accepting connection on port %s: %v\n", lp, err) + fmt.Fprintf(cmd.ErrOrStderr(), "error accepting connection on port %s: %v\n", lp, err) return } - go forward(cli.SshCli, localConn, remoteAddr) + go forward(cli.SshCli, localConn, remoteAddr, cmd.ErrOrStderr()) } }(listener, localPortStr, remoteEndpoint) } <-done - fmt.Println("Shutting down port forwards...") + fmt.Fprintln(cmd.OutOrStdout(), "Shutting down port forwards...") for _, listener := range listeners { listener.Close() } @@ -131,22 +131,22 @@ Be sure to run Ctrl+c in your terminal when you are done to close the connection }, } -func forward(client *ssh.Client, localConn net.Conn, remoteAddr string) { +func forward(client *ssh.Client, localConn net.Conn, remoteAddr string, errw io.Writer) { defer localConn.Close() remoteConn, err := client.Dial("tcp", remoteAddr) if err != nil { - fmt.Fprintf(os.Stderr, "failed to dial remote address %s: %v\n", remoteAddr, err) + fmt.Fprintf(errw, "failed to dial remote address %s: %v\n", remoteAddr, err) return } defer remoteConn.Close() go func() { if _, err := io.Copy(remoteConn, localConn); err != nil { - fmt.Fprintf(os.Stderr, "error while copying local to remote: %v\n", err) + fmt.Fprintf(errw, "error while copying local to remote: %v\n", err) } }() if _, err := io.Copy(localConn, remoteConn); err != nil { - fmt.Fprintf(os.Stderr, "error while copying remote to local: %v\n", err) + fmt.Fprintf(errw, "error while copying remote to local: %v\n", err) } } diff --git a/cmd/root.go b/cmd/root.go index 5ecc591..13844ca 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,7 +18,11 @@ import ( var RootCmd = &cobra.Command{ Use: "sitectl", - Short: "Interact with your docker compose site", + Short: "Manage Docker Compose sites across local and remote environments", + Long: `sitectl manages Docker Compose-based sites across local and remote environments. + +Run it with no arguments to open the interactive dashboard. Use subcommands to manage +contexts, run compose operations, toggle components, forward ports, and collect diagnostics.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { level := slog.LevelInfo ll, err := cmd.Flags().GetString("log-level") diff --git a/cmd/sequelace.go b/cmd/sequelace.go index 10650fb..52a056e 100644 --- a/cmd/sequelace.go +++ b/cmd/sequelace.go @@ -14,7 +14,11 @@ import ( var sequelAceCmd = &cobra.Command{ Use: "sequelace", - Short: "Connect to your MySQL/Mariadb database using Sequel Ace (Mac OS only)", + Short: "Open the site database in Sequel Ace (macOS only)", + Long: `Open a direct connection to the site's MySQL/MariaDB container in Sequel Ace. + +For remote contexts, sitectl establishes an SSH tunnel before launching Sequel Ace so the +database port is never exposed on the host. This command is macOS only.`, RunE: func(cmd *cobra.Command, args []string) error { if runtime.GOOS != "darwin" { return fmt.Errorf("sequelace is only supported on mac OS") @@ -54,5 +58,5 @@ var sequelAceCmd = &cobra.Command{ func init() { RootCmd.AddCommand(sequelAceCmd) - sequelAceCmd.Flags().String("sequel-ace-path", "/Applications/Sequel Ace.app/Contents/MacOS/Sequel Ace", "Full path to your Sequel Ace app") + sequelAceCmd.Flags().String("sequel-ace-path", "/Applications/Sequel Ace.app/Contents/MacOS/Sequel Ace", "Path to the Sequel Ace binary.") } diff --git a/pkg/config/config.go b/pkg/config/config.go index a6b1ed9..cb1b9b3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,7 +1,7 @@ package config import ( - "log/slog" + "fmt" "os" "path/filepath" @@ -14,28 +14,28 @@ type Config struct { CronSpecs []CronSpec `yaml:"cron-specs,omitempty"` } -func ConfigFilePath() string { +func ConfigFilePath() (string, error) { home, err := os.UserHomeDir() if err != nil { - slog.Error("Unable to detect home directory", "err", err) - os.Exit(1) + return "", fmt.Errorf("unable to detect home directory: %w", err) } baseDir := filepath.Join(home, ".sitectl") - _, err = os.Stat(baseDir) - if os.IsNotExist(err) { - err = os.Mkdir(baseDir, 0700) - if err != nil { - slog.Error("Unable to create ~/.sitectl directory", "err", err) - os.Exit(1) + if _, err := os.Stat(baseDir); os.IsNotExist(err) { + if err := os.Mkdir(baseDir, 0700); err != nil { + return "", fmt.Errorf("unable to create ~/.sitectl directory: %w", err) } } - return filepath.Join(baseDir, "config.yaml") + return filepath.Join(baseDir, "config.yaml"), nil } func Load() (*Config, error) { - data, err := os.ReadFile(ConfigFilePath()) + path, err := ConfigFilePath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) if err != nil { return &Config{}, nil } @@ -45,11 +45,15 @@ func Load() (*Config, error) { } func Save(cfg *Config) error { + path, err := ConfigFilePath() + if err != nil { + return err + } data, err := yaml.Marshal(cfg) if err != nil { return err } - return os.WriteFile(ConfigFilePath(), data, 0600) + return os.WriteFile(path, data, 0600) } func Current() (string, error) { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index acc407e..0a5976a 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -64,7 +64,11 @@ func TestLoadEmptyConfig(t *testing.T) { func TestConfigFilePath(t *testing.T) { home := os.Getenv("HOME") expected := filepath.Join(home, ".sitectl", "config.yaml") - if path := ConfigFilePath(); path != expected { + path, err := ConfigFilePath() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != expected { t.Errorf("expected config file path %s, got %s", expected, path) } } diff --git a/pkg/config/context_test.go b/pkg/config/context_test.go index cc037b3..35c34f3 100644 --- a/pkg/config/context_test.go +++ b/pkg/config/context_test.go @@ -17,7 +17,11 @@ func writeConfig(cfg *Config, t *testing.T) { if err != nil { t.Fatalf("failed to marshal config: %v", err) } - err = os.WriteFile(ConfigFilePath(), data, 0644) + cfgPath, err := ConfigFilePath() + if err != nil { + t.Fatalf("failed to get config file path: %v", err) + } + err = os.WriteFile(cfgPath, data, 0644) if err != nil { t.Fatalf("failed to write config file: %v", err) } diff --git a/pkg/config/utils.go b/pkg/config/utils.go index a4dd225..fb5f43c 100644 --- a/pkg/config/utils.go +++ b/pkg/config/utils.go @@ -180,13 +180,9 @@ func isDockerSocketAlive(socket string) bool { } func SetCommandFlags(flags *pflag.FlagSet) { - path, err := os.Getwd() - if err != nil { - slog.Error("Unable to get current working directory", "err", err) - os.Exit(1) + if path, err := os.Getwd(); err == nil { + _ = godotenv.Load(filepath.Join(path, ".env")) } - env := filepath.Join(path, ".env") - _ = godotenv.Load(env) key := filepath.Join(os.Getenv("HOME"), ".ssh", "id_rsa") diff --git a/pkg/docker/exec_capture.go b/pkg/docker/exec_capture.go new file mode 100644 index 0000000..c7a82e0 --- /dev/null +++ b/pkg/docker/exec_capture.go @@ -0,0 +1,40 @@ +package docker + +import ( + "bytes" + "context" + "fmt" + "strings" +) + +// ExecCapture runs a command in a container and returns its stdout. +// stderr is used as the error detail when the command exits non-zero. +// workingDir may be empty to use the container's default. +func ExecCapture(ctx context.Context, cli *DockerClient, container, workingDir string, cmd []string) (string, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + exitCode, err := cli.Exec(ctx, ExecOptions{ + Container: container, + Cmd: cmd, + WorkingDir: workingDir, + AttachStdout: true, + AttachStderr: true, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + return "", err + } + if exitCode != 0 { + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = strings.TrimSpace(stdout.String()) + } + if detail != "" { + return "", fmt.Errorf("command failed with exit code %d: %s", exitCode, detail) + } + return "", fmt.Errorf("command failed with exit code %d", exitCode) + } + return strings.TrimSpace(stdout.String()), nil +} diff --git a/pkg/docker/exec_capture_test.go b/pkg/docker/exec_capture_test.go new file mode 100644 index 0000000..3c6fc13 --- /dev/null +++ b/pkg/docker/exec_capture_test.go @@ -0,0 +1,68 @@ +package docker + +import ( + "context" + "strings" + "testing" +) + +func TestExecCapture_NilCliReturnsError(t *testing.T) { + // ExecCapture on a DockerClient with no API should return an error, + // not panic. This verifies the function is safe to call even when the + // underlying client has not been fully initialised. + cli := &DockerClient{} + _, err := ExecCapture(context.Background(), cli, "mycontainer", "/app", []string{"echo", "hi"}) + if err == nil { + t.Error("expected error from nil CLI, got nil") + } +} + +func TestExecCapture_ErrorMessageFormat(t *testing.T) { + // Verify the error message format produced when a command exits non-zero. + // We test this by inspecting the formatting logic directly, since a real + // Docker daemon is not available in unit tests. + cases := []struct { + exitCode int + stderr string + stdout string + wantSub string + }{ + {1, "permission denied", "", "exit code 1: permission denied"}, + {2, "", "some output", "exit code 2: some output"}, + {3, "", "", "exit code 3"}, + } + for _, tc := range cases { + detail := strings.TrimSpace(tc.stderr) + if detail == "" { + detail = strings.TrimSpace(tc.stdout) + } + var msg string + if detail != "" { + msg = "command failed with exit code " + itoa(tc.exitCode) + ": " + detail + } else { + msg = "command failed with exit code " + itoa(tc.exitCode) + } + if !strings.Contains(msg, tc.wantSub) { + t.Errorf("exitCode=%d: got %q, want substring %q", tc.exitCode, msg, tc.wantSub) + } + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + digits := []byte{} + for n > 0 { + digits = append([]byte{byte('0' + n%10)}, digits...) + n /= 10 + } + if neg { + digits = append([]byte{'-'}, digits...) + } + return string(digits) +} diff --git a/pkg/helpers/helpers.go b/pkg/helpers/helpers.go index 36d74b6..46d61dc 100644 --- a/pkg/helpers/helpers.go +++ b/pkg/helpers/helpers.go @@ -2,8 +2,6 @@ package helpers import ( "fmt" - "log/slog" - "os" "os/exec" "runtime" "strings" @@ -11,11 +9,6 @@ import ( "github.com/spf13/cobra" ) -func ExitOnError(err error) { - slog.Error(err.Error()) - os.Exit(1) -} - // FirstNonEmpty returns the first non-empty (after trimming whitespace) string // from the provided values, or empty string if all are empty. func FirstNonEmpty(values ...string) string { diff --git a/pkg/job/confirm.go b/pkg/job/confirm.go new file mode 100644 index 0000000..db7790e --- /dev/null +++ b/pkg/job/confirm.go @@ -0,0 +1,38 @@ +package job + +import ( + "fmt" + "strings" + + "github.com/libops/sitectl/pkg/config" +) + +// ConfirmDatabaseReplacement prompts the user to confirm a destructive +// database import. It returns true when the user confirms, false when they +// decline, and an error if reading input fails. +// +// yolo skips the prompt and returns true immediately, intended for +// non-interactive pipelines where the caller has already passed a --yolo flag. +func ConfirmDatabaseReplacement(targetContext, databaseName, inputPath string, yolo bool) (bool, error) { + if yolo { + return true, nil + } + + prompt := []string{ + fmt.Sprintf("About to import %s database artifact %q into context %q.", databaseName, inputPath, targetContext), + "This will wipe out the target database.", + "Continue? [y/N]: ", + } + + input, err := config.GetInput(prompt...) + if err != nil { + return false, err + } + + switch strings.ToLower(strings.TrimSpace(input)) { + case "y", "yes": + return true, nil + default: + return false, nil + } +} diff --git a/pkg/job/confirm_test.go b/pkg/job/confirm_test.go new file mode 100644 index 0000000..1935177 --- /dev/null +++ b/pkg/job/confirm_test.go @@ -0,0 +1,15 @@ +package job + +import ( + "testing" +) + +func TestConfirmDatabaseReplacement_YoloSkipsPrompt(t *testing.T) { + ok, err := ConfirmDatabaseReplacement("ctx", "drupal", "/tmp/dump.sql.gz", true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Error("expected yolo=true to return ok=true without prompting") + } +} diff --git a/pkg/plugin/debugui/debugui.go b/pkg/plugin/debugui/debugui.go index 4f7cc36..3c3e481 100644 --- a/pkg/plugin/debugui/debugui.go +++ b/pkg/plugin/debugui/debugui.go @@ -15,6 +15,10 @@ type Row struct { Value string } +// MutedStyle is the lipgloss style used for de-emphasised text. +// Exposed for callers that need the raw Style (e.g. spinner.WithStyle). +var MutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9FB3C8")) + var ( panelStyle = lipgloss.NewStyle(). Background(lipgloss.Color("#112235")). @@ -22,8 +26,7 @@ var ( titleStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#98C1D9")) - mutedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#9FB3C8")) + mutedStyle = MutedStyle sectionDividerStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#29425E")) statusOKStyle = lipgloss.NewStyle(). diff --git a/pkg/plugin/discovery.go b/pkg/plugin/discovery.go index 4a3907c..b2310ce 100644 --- a/pkg/plugin/discovery.go +++ b/pkg/plugin/discovery.go @@ -113,13 +113,8 @@ func inspectInstalledPlugin(pluginName, binaryName, pluginPath string) Installed } 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 + _, err := exec.Command(pluginPath, "create", "--help").Output() + return err == nil } func ParsePluginInfoOutput(output string) InstalledPlugin { diff --git a/pkg/plugin/sdk.go b/pkg/plugin/sdk.go index 50bdc0f..03b21a3 100644 --- a/pkg/plugin/sdk.go +++ b/pkg/plugin/sdk.go @@ -17,7 +17,6 @@ import ( "github.com/libops/sitectl/pkg/component" "github.com/libops/sitectl/pkg/config" "github.com/libops/sitectl/pkg/docker" - "github.com/libops/sitectl/pkg/helpers" "github.com/libops/sitectl/pkg/validate" "github.com/spf13/cobra" "golang.org/x/crypto/ssh" @@ -125,7 +124,7 @@ func (s *SDK) addCommonFlags() { s.RootCmd.PersistentFlags().String("log-level", ll, "The logging level for the command") c, err := config.Current() if err != nil { - helpers.ExitOnError(fmt.Errorf("unable to fetch current context: %v", err)) + slog.Warn("unable to fetch current context, defaulting to empty", "err", err) } s.RootCmd.PersistentFlags().String("context", c, "The sitectl context to use. See sitectl config --help for more info") @@ -166,19 +165,21 @@ func (s *SDK) GetMetadataCommand() *cobra.Command { Use: "plugin-info", Short: "Display plugin metadata", Hidden: true, // Hidden from normal help, used for plugin discovery - Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("Name: %s\n", s.Metadata.Name) - fmt.Printf("Version: %s\n", s.Metadata.Version) - fmt.Printf("Description: %s\n", s.Metadata.Description) + RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Name: %s\n", s.Metadata.Name) + fmt.Fprintf(out, "Version: %s\n", s.Metadata.Version) + fmt.Fprintf(out, "Description: %s\n", s.Metadata.Description) if s.Metadata.Author != "" { - fmt.Printf("Author: %s\n", s.Metadata.Author) + fmt.Fprintf(out, "Author: %s\n", s.Metadata.Author) } if s.Metadata.TemplateRepo != "" { - fmt.Printf("Template-Repo: %s\n", s.Metadata.TemplateRepo) + fmt.Fprintf(out, "Template-Repo: %s\n", s.Metadata.TemplateRepo) } if len(s.Metadata.Includes) > 0 { - fmt.Printf("Includes: %s\n", strings.Join(s.Metadata.Includes, ",")) + fmt.Fprintf(out, "Includes: %s\n", strings.Join(s.Metadata.Includes, ",")) } + return nil }, } } diff --git a/scripts/gen-docs-snippets/main.go b/scripts/gen-docs-snippets/main.go new file mode 100644 index 0000000..f1d728e --- /dev/null +++ b/scripts/gen-docs-snippets/main.go @@ -0,0 +1,166 @@ +// gen-docs-snippets generates MDX snippet files for each sitectl command. +// Run via: make docs-snippets +// Output goes to ../sitectl-docs/snippets/commands/ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + sitectlcmd "github.com/libops/sitectl/cmd" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const ( + displayPrefix = "sitectl" + outputDir = "../sitectl-docs/snippets/commands" + autoGenHeader = "{/* Auto-generated from source. Run `make docs-snippets` to update. */}\n\n" +) + +func main() { + root := sitectlcmd.RootCmd + root.DisableAutoGenTag = true + + if err := os.MkdirAll(outputDir, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "create output dir: %v\n", err) + os.Exit(1) + } + + var count int + walkCommands(root, func(cmd *cobra.Command) { + slug := commandSlug(cmd) + path := filepath.Join(outputDir, slug+".mdx") + if err := os.WriteFile(path, []byte(renderSnippet(cmd)), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "write %s: %v\n", path, err) + os.Exit(1) + } + fmt.Println(path) + count++ + }) + fmt.Printf("generated %d snippets\n", count) +} + +func walkCommands(cmd *cobra.Command, fn func(*cobra.Command)) { + for _, sub := range cmd.Commands() { + if skipCommand(sub) { + continue + } + fn(sub) + walkCommands(sub, fn) + } +} + +func skipCommand(cmd *cobra.Command) bool { + if cmd.Hidden { + return true + } + name := cmd.Name() + if name == "help" || name == "completion" { + return true + } + // Skip thin plugin-passthrough commands (no Long, DisableFlagParsing, no subcommands) + if cmd.DisableFlagParsing && strings.TrimSpace(cmd.Long) == "" && !cmd.HasAvailableSubCommands() { + return true + } + return false +} + +func commandSlug(cmd *cobra.Command) string { + path := cmd.CommandPath() + prefix := strings.ReplaceAll(displayPrefix, " ", "-") + if strings.HasPrefix(path, displayPrefix+" ") { + rel := path[len(displayPrefix)+1:] + return strings.ToLower(prefix + "-" + strings.ReplaceAll(rel, " ", "-")) + } + return strings.ToLower(prefix) +} + +func buildUseLine(cmd *cobra.Command) string { + // cmd.CommandPath() already includes the correct display name (e.g. "sitectl compose") + // for plugins it uses the CommandDisplayNameAnnotation ("sitectl isle create") + path := cmd.CommandPath() + + var fullPath string + if path == displayPrefix || strings.HasPrefix(path, displayPrefix+" ") { + fullPath = path + } else { + fullPath = displayPrefix + " " + path + } + + // Append args from Use (everything after the command name) + useParts := strings.Fields(cmd.Use) + if len(useParts) > 1 { + fullPath += " " + strings.Join(useParts[1:], " ") + } + + // For group commands (no RunE), append + if !cmd.Runnable() && cmd.HasAvailableSubCommands() { + fullPath += " " + } + + return fullPath +} + +func collectLocalFlags(cmd *cobra.Command) []*pflag.Flag { + var flags []*pflag.Flag + cmd.Flags().VisitAll(func(f *pflag.Flag) { + if !f.Hidden { + flags = append(flags, f) + } + }) + return flags +} + +func renderSnippet(cmd *cobra.Command) string { + var b strings.Builder + b.WriteString(autoGenHeader) + + // Long description, falling back to Short + desc := strings.TrimSpace(cmd.Long) + if desc == "" { + desc = strings.TrimSpace(cmd.Short) + } + if desc != "" { + b.WriteString(desc) + b.WriteString("\n\n") + } + + // Usage code block + b.WriteString("```bash\n") + b.WriteString(buildUseLine(cmd)) + b.WriteString("\n```\n") + + // Aliases + if len(cmd.Aliases) > 0 { + b.WriteString("\n**Aliases:** `") + b.WriteString(strings.Join(cmd.Aliases, "`, `")) + b.WriteString("`\n") + } + + // Flags table (skip for DisableFlagParsing commands — they accept arbitrary args) + if !cmd.DisableFlagParsing { + flags := collectLocalFlags(cmd) + if len(flags) > 0 { + b.WriteString("\n| Flag | Default | Description |\n") + b.WriteString("|------|---------|-------------|\n") + for _, f := range flags { + flagStr := "--" + f.Name + if f.Shorthand != "" { + flagStr = "-" + f.Shorthand + ", " + flagStr + } + defVal := f.DefValue + if defVal == "" { + defVal = " " + } else { + defVal = "`" + defVal + "`" + } + fmt.Fprintf(&b, "| `%s` | %s | %s |\n", flagStr, defVal, f.Usage) + } + } + } + + return b.String() +}