diff --git a/Makefile b/Makefile index 3930415..8c81504 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build deps lint test work install +.PHONY: build deps lint test work install docs-snippets BINARY_NAME=sitectl-drupal INSTALL_DIR ?= $(or $(dir $(shell which $(BINARY_NAME) 2>/dev/null)),/usr/local/bin/) @@ -28,3 +28,6 @@ test: build work: ./scripts/use-go-work.sh + +docs-snippets: work + go run ./scripts/gen-docs-snippets/ diff --git a/cmd/drush.go b/cmd/drush.go index 92d3b1f..247ff8e 100644 --- a/cmd/drush.go +++ b/cmd/drush.go @@ -11,24 +11,19 @@ var drushCmd = &cobra.Command{ Use: "drush [COMMAND]", DisableFlagParsing: true, Args: cobra.ArbitraryArgs, - Short: "Run drush commands on ISLE contexts", - Long: `Run drush commands on ISLE contexts. + Short: "Run drush commands inside the Drupal container", + Long: `Run drush commands inside the Drupal container of the active context. -This is a shorthand for "sitectl compose exec drupal drush" with automatic --uri handling. -The DRUPAL_DRUSH_URI environment variable is automatically passed unless you specify --uri or -l. - -Special subcommands: - uli - Generate and auto-open a one-time login link in your browser +This wraps "docker compose exec drupal drush" and automatically injects DRUPAL_DRUSH_URI +so --uri does not need to be specified manually. Examples: - sitectl drush status # Check Drupal status - sitectl drush cr # Clear all caches - sitectl drush cex # Export configuration - sitectl drush cim # Import configuration - sitectl drush uli # Generate login link and open in browser - sitectl drush uli --uid=2 # Login link for user ID 2 - sitectl drush sqlq "SHOW TABLES" # Run SQL query - sitectl drush --context prod status # Check status on prod context`, + sitectl isle drush status # Check Drupal status + sitectl isle drush cr # Clear all caches + sitectl isle drush cex # Export configuration + sitectl isle drush cim # Import configuration + sitectl isle drush sqlq "SHOW TABLES" # Run a SQL query + sitectl isle drush --context prod status # Check status on the prod context`, RunE: func(cmd *cobra.Command, args []string) error { filteredArgs, ctx, cli, containerName, err := getDrupalContainer(cmd, args) if err != nil { diff --git a/cmd/extensions.go b/cmd/extensions.go index 7206096..b1b1799 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -12,12 +12,11 @@ import ( "strconv" "strings" - "charm.land/lipgloss/v2" "github.com/libops/sitectl/pkg/config" "github.com/libops/sitectl/pkg/docker" "github.com/libops/sitectl/pkg/plugin" + "github.com/libops/sitectl/pkg/plugin/debugui" "github.com/spf13/cobra" - "golang.org/x/term" "gopkg.in/yaml.v3" ) @@ -111,27 +110,6 @@ var drupalCoreModules = map[string]struct{}{ "workspaces": {}, } -var ( - debugPanelStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("#112235")). - Padding(1, 2) - debugTitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#98C1D9")) - 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")) - debugMutedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#9FB3C8")) - debugRowStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("#112235")) -) - var componentExtensionCmd = &cobra.Command{ Use: "__component", Short: "Internal component extension command", @@ -221,11 +199,11 @@ func renderDrupalDebug(runCtx context.Context) (string, error) { slog.Debug("resolved drupal root", "plugin", "drupal", "drupal_root", drupalRoot) configDir := filepath.Join(drupalRoot, "config", "sync") body := []string{ - debugDivider(), + debugui.Divider(), "", - debugTitleStyle.Render("General"), + debugui.Title("General"), "", - formatDebugRows([]debugRow{ + debugui.FormatRows([]debugui.Row{ {Label: "Context", Value: ctx.Name}, {Label: "Project dir", Value: ctx.ProjectDir}, {Label: "Drupal root", Value: drupalRoot}, @@ -236,7 +214,7 @@ func renderDrupalDebug(runCtx context.Context) (string, error) { if strings.TrimSpace(drupalRoot) == "" { slog.Debug("drupal root unavailable; skipping extension scan", "plugin", "drupal") body = append(body, "", "Installed modules: unavailable") - return renderDebugPanel("drupal", strings.Join(body, "\n")), nil + return debugui.RenderPanel("drupal", strings.Join(body, "\n")), nil } slog.Debug("reading core.extension.yml", "plugin", "drupal", "path", filepath.Join(configDir, "core.extension.yml")) @@ -256,12 +234,12 @@ func renderDrupalDebug(runCtx context.Context) (string, error) { slog.Debug("rendering cache_page summary", "plugin", "drupal") cachePageSummary, err := renderCachePageSummary(runCtx) if err != nil { - body = append(body, "", debugDivider(), "", debugTitleStyle.Render("Cache Page"), "", formatDebugRows([]debugRow{ - {Label: "Status", Value: renderStatus("warning")}, + body = append(body, "", debugui.Divider(), "", debugui.Title("Cache Page"), "", debugui.FormatRows([]debugui.Row{ + {Label: "Status", Value: debugui.Status("warning")}, {Label: "cache_page", Value: fmt.Sprintf("unavailable (%v)", err)}, })) } else if strings.TrimSpace(cachePageSummary) != "" { - body = append(body, "", debugDivider(), "", debugTitleStyle.Render("Cache Page"), "", cachePageSummary) + body = append(body, "", debugui.Divider(), "", debugui.Title("Cache Page"), "", cachePageSummary) } moduleLines, err := renderModuleList(runCtx, files, drupalRoot, modules, moduleVersionInfo) @@ -269,14 +247,14 @@ func renderDrupalDebug(runCtx context.Context) (string, error) { return "", err } - configLines := []string{debugDivider(), "", debugTitleStyle.Render("Installed Extensions"), "", fmt.Sprintf("Installed modules (%d):", len(modules))} + configLines := []string{debugui.Divider(), "", debugui.Title("Installed Extensions"), "", fmt.Sprintf("Installed modules (%d):", len(modules))} configLines = append(configLines, moduleLines...) configLines = append(configLines, "") configLines = append(configLines, fmt.Sprintf("Installed themes (%d):", len(themes))) configLines = append(configLines, formatListLines(themes, 3)...) body = append(body, "", strings.Join(configLines, "\n")) - patchLines := []string{debugDivider(), "", debugTitleStyle.Render("Composer Patches"), ""} + patchLines := []string{debugui.Divider(), "", debugui.Title("Composer Patches"), ""} if strings.TrimSpace(composerPatches) == "" { patchLines = append(patchLines, " none") } else { @@ -285,7 +263,7 @@ func renderDrupalDebug(runCtx context.Context) (string, error) { body = append(body, "", strings.Join(patchLines, "\n")) slog.Debug("finished plugin debug", "plugin", "drupal") - return renderDebugPanel("drupal", strings.Join(body, "\n")), nil + return debugui.RenderPanel("drupal", strings.Join(body, "\n")), nil } func renderCachePageSummary(runCtx context.Context) (string, error) { @@ -304,23 +282,25 @@ func renderCachePageSummary(runCtx context.Context) (string, error) { return "", err } - rows := []debugRow{ - {Label: "Status", Value: renderStatus("ok")}, + rows := []debugui.Row{ + {Label: "Status", Value: debugui.Status("ok")}, {Label: "cache_page", Value: humanBytes(cachePageSize)}, {Label: "cache_render", Value: humanBytes(cacheRenderSize)}, } if cachePageSize >= cachePageWarningThreshold || cacheRenderSize >= cachePageWarningThreshold { - rows[0].Value = renderStatus("warning") + rows[0].Value = debugui.Status("warning") } if cachePageSize >= cachePageWarningThreshold { - rows = append(rows, debugRow{Label: "Recommendation", Value: pageCacheExclusionURL}) + rows = append(rows, debugui.Row{Label: "Recommendation", Value: pageCacheExclusionURL}) } - return formatDebugRows(rows), nil + return debugui.FormatRows(rows), nil } func readDrupalCacheTableSize(runCtx context.Context, cli *docker.DockerClient, containerName, containerRoot, tableName string) (int64, error) { query := fmt.Sprintf("SELECT COALESCE(data_length + index_length, 0) FROM information_schema.TABLES WHERE table_schema = DATABASE() AND table_name = '%s';", strings.TrimSpace(tableName)) - output, err := execDrupalCommandCapture(runCtx, cli, containerName, containerRoot, []string{"drush", "sql:query", query, "--extra=--batch", "--extra=--skip-column-names"}) + cmd := []string{"drush", "sql:query", query, "--extra=--batch", "--extra=--skip-column-names"} + slog.Debug(strings.Join(cmd, " "), "plugin", "drupal", "container", containerName) + output, err := docker.ExecCapture(runCtx, cli, containerName, containerRoot, cmd) if err != nil { return 0, err } @@ -351,37 +331,6 @@ func getDrupalContainerForSDK(runCtx context.Context) (ctx *config.Context, cli return ctx, cli, containerName, nil } -func execDrupalCommandCapture(runCtx context.Context, cli *docker.DockerClient, containerName, containerRoot string, cmd []string) (string, error) { - slog.Debug(strings.Join(cmd, " "), "plugin", "drupal", "container", containerName) - var stdout bytes.Buffer - var stderr bytes.Buffer - - exitCode, err := cli.Exec(runCtx, docker.ExecOptions{ - Container: containerName, - Cmd: cmd, - WorkingDir: containerRoot, - 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("drupal command failed with exit code %d: %s", exitCode, detail) - } - return "", fmt.Errorf("drupal command failed with exit code %d", exitCode) - } - - return strings.TrimSpace(stdout.String()), nil -} - func parseFirstInt(output string) (int64, error) { for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) @@ -650,87 +599,3 @@ func formatListLines(values []string, perLine int) []string { } return lines } - -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 width := len(strings.TrimSpace(row.Label)); width > labelWidth { - labelWidth = width - } - } - 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 renderStatus(state string) string { - switch strings.ToLower(strings.TrimSpace(state)) { - case "ok": - return debugStatusOKStyle.Render("OK") - case "warning": - return debugStatusWarningStyle.Render("WARNING") - default: - return debugMutedStyle.Render(strings.ToUpper(strings.TrimSpace(state))) - } -} - -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 max(a, b int) int { - if a > b { - return a - } - return b -} - -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())) -} diff --git a/cmd/root.go b/cmd/root.go index 9e10ee3..0e89ea8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,7 +11,7 @@ var ( ) func init() { - loginCmd.Flags().Uint("uid", 1, "Drupal user ID to provide a direct login link for") + loginCmd.Flags().Uint("uid", 1, "Drupal user ID to generate the login link for.") } // RegisterCommands registers all drupal commands with the plugin SDK diff --git a/cmd/sync.go b/cmd/sync.go index ef82d64..a9a09b6 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -24,12 +24,22 @@ var ( var syncCmd = &cobra.Command{ Use: "sync", Short: "Sync Drupal artifacts between contexts", + Long: `Copy Drupal artifacts from one sitectl context to another. + +Available artifacts: database, config (Drupal config/sync directory)`, } var syncDatabaseCmd = &cobra.Command{ Use: "database", Aliases: []string{"db"}, Short: "Sync the Drupal database from one context to another", + Long: `Copy the Drupal database from a source context to a target context. + +This backs up the database on the source, transfers the artifact, and imports it into the +target. The command asks for confirmation before importing. Pass --yolo to skip the prompt +in automation. + +Use --fresh to always take a new backup rather than reusing one from today.`, RunE: func(cmd *cobra.Command, args []string) error { progress := plugin.NewProgressLine(cmd.ErrOrStderr(), "Syncing Drupal Database", "Resolving contexts") defer progress.Close() @@ -82,6 +92,10 @@ var syncDatabaseCmd = &cobra.Command{ var syncConfigCmd = &cobra.Command{ Use: "config", Short: "Sync the Drupal config/sync directory from one context to another", + Long: `Copy the Drupal configuration from a source context to a target context. + +This exports config as a tarball from the source, transfers it, and imports it on the target +using drush cim.`, RunE: func(cmd *cobra.Command, args []string) error { progress := plugin.NewProgressLine(cmd.ErrOrStderr(), "Syncing Drupal Config", "Resolving contexts") defer progress.Close() @@ -132,17 +146,17 @@ var syncConfigCmd = &cobra.Command{ } func init() { - syncDatabaseCmd.Flags().StringVar(&syncSourceContext, "source", "", "Source sitectl context") - syncDatabaseCmd.Flags().StringVar(&syncTargetContext, "target", "", "Target sitectl context") - syncDatabaseCmd.Flags().BoolVar(&syncFresh, "fresh", false, "Always run a fresh source database backup instead of reusing today/yesterday if available") - syncDatabaseCmd.Flags().StringVar(&syncBackupDir, "backup-dir", "/tmp/sitectl-drupal-jobs/db-backup", "Source host directory used to cache database backup artifacts for sync") - syncDatabaseCmd.Flags().BoolVar(&syncYolo, "yolo", false, "Apply destructive database changes without confirmation") + syncDatabaseCmd.Flags().StringVar(&syncSourceContext, "source", "", "Source sitectl context.") + syncDatabaseCmd.Flags().StringVar(&syncTargetContext, "target", "", "Target sitectl context.") + syncDatabaseCmd.Flags().BoolVar(&syncFresh, "fresh", false, "Always take a fresh backup instead of reusing one from today.") + syncDatabaseCmd.Flags().StringVar(&syncBackupDir, "backup-dir", "/tmp/sitectl-drupal-jobs/db-backup", "Directory on the source host used to cache backup artifacts.") + syncDatabaseCmd.Flags().BoolVar(&syncYolo, "yolo", false, "Skip the confirmation prompt before importing.") must(syncDatabaseCmd.MarkFlagRequired("source")) must(syncDatabaseCmd.MarkFlagRequired("target")) - syncConfigCmd.Flags().StringVar(&syncSourceContext, "source", "", "Source sitectl context") - syncConfigCmd.Flags().StringVar(&syncTargetContext, "target", "", "Target sitectl context") - syncConfigCmd.Flags().StringVar(&syncDrupalRootfs, "drupal-rootfs", "", "Drupal rootfs relative to the target context project dir") + syncConfigCmd.Flags().StringVar(&syncSourceContext, "source", "", "Source sitectl context.") + syncConfigCmd.Flags().StringVar(&syncTargetContext, "target", "", "Target sitectl context.") + syncConfigCmd.Flags().StringVar(&syncDrupalRootfs, "drupal-rootfs", "", "Path to the Drupal web root on the target, relative to the project directory.") must(syncConfigCmd.MarkFlagRequired("source")) must(syncConfigCmd.MarkFlagRequired("target")) diff --git a/cmd/uli.go b/cmd/uli.go index 7ce6fe3..c801779 100644 --- a/cmd/uli.go +++ b/cmd/uli.go @@ -14,14 +14,15 @@ import ( // login runs drush uli var loginCmd = &cobra.Command{ Use: "uli", - Short: "Generate a one-time login link", + Short: "Generate a one-time login link and open it in your browser", Long: `Generate a one-time login link and automatically open it in your default browser. -This runs 'drush uli' in the Drupal container and opens the resulting URL. +This runs drush uli inside the Drupal container, captures the resulting URL, and opens it. +Unlike running drush uli directly, this command handles browser launching for you. Examples: - sitectl drush uli # Login as admin (user 1) - sitectl drush uli --uid=2 # Login as user ID 2`, + sitectl isle uli # Login as admin (user 1) + sitectl isle uli --uid=2 # Login as user ID 2`, RunE: func(cmd *cobra.Command, args []string) error { ctx, cli, containerName, err := getDrupalContainerFromFlags(cmd) if err != nil { @@ -56,7 +57,7 @@ Examples: } output := strings.TrimSpace(stdout.String()) - fmt.Println(output) + fmt.Fprintln(cmd.OutOrStdout(), output) if strings.HasPrefix(output, "http") { err := helpers.OpenURL(output) diff --git a/go.mod b/go.mod index 4603841..05b2f9e 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,9 @@ module github.com/libops/sitectl-drupal go 1.25.8 require ( - charm.land/lipgloss/v2 v2.0.2 - github.com/libops/sitectl v0.13.0 + github.com/libops/sitectl v0.13.4 github.com/spf13/cobra v1.10.2 - golang.org/x/term v0.39.0 + github.com/spf13/pflag v1.0.10 gopkg.in/yaml.v3 v3.0.1 ) @@ -14,6 +13,7 @@ require ( charm.land/bubbles/v2 v2.0.0 // indirect charm.land/bubbletea/v2 v2.0.2 // indirect charm.land/fang/v2 v2.0.1 // indirect + charm.land/lipgloss/v2 v2.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/atotto/clipboard v0.1.4 // indirect @@ -56,7 +56,6 @@ require ( github.com/pkg/sftp v1.13.10 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect @@ -68,5 +67,6 @@ require ( golang.org/x/crypto v0.46.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect ) diff --git a/go.sum b/go.sum index 950368a..bce27b7 100644 --- a/go.sum +++ b/go.sum @@ -82,8 +82,8 @@ 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/libops/sitectl v0.13.0 h1:3htPRrEn5bJGJrhPOwd8/s8+C75bJWko2n3HCMiMKBg= -github.com/libops/sitectl v0.13.0/go.mod h1:QykPh7hrFKFBA1mp9euyKEWP1x5xB6bmEq6zT/tzeTU= +github.com/libops/sitectl v0.13.4 h1:x8NmFcfBzq7zVG45qiN+uoOrQaRdpo8p4JmXYljMhg0= +github.com/libops/sitectl v0.13.4/go.mod h1:Q4mIOPKbV1CJAYJ/x0e+ZxKQ2M/zOrqiWE7YmL5kaH4= 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= @@ -178,8 +178,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1: google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/jobs/register.go b/pkg/jobs/register.go index df03188..b3ce3cc 100644 --- a/pkg/jobs/register.go +++ b/pkg/jobs/register.go @@ -142,14 +142,12 @@ func RunDBBackup(cmd *cobra.Command, ctx *config.Context, outputPath string) err } func RunDBImport(cmd *cobra.Command, ctx *config.Context, inputPath string, yolo bool) error { - if !yolo { - ok, err := confirmDatabaseReplacement(ctx.Name, "Drupal", inputPath) - if err != nil { - return err - } - if !ok { - return fmt.Errorf("database import cancelled") - } + ok, err := corejob.ConfirmDatabaseReplacement(ctx.Name, "Drupal", inputPath, yolo) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("database import cancelled") } _, cli, containerName, err := getDrupalContainerForContext(cmd.Context(), ctx) @@ -199,7 +197,7 @@ func RunDBImport(cmd *cobra.Command, ctx *config.Context, inputPath string, yolo if exitCode != 0 { return fmt.Errorf("drupal sql import failed with exit code %d: %s", exitCode, strings.TrimSpace(stderr.String())) } - _, err = execDrupalCommandCapture(cmd.Context(), cli, containerName, ctx.EffectiveDrupalContainerRoot(), []string{"drush", "cr", "-y"}) + _, err = docker.ExecCapture(cmd.Context(), cli, containerName, ctx.EffectiveDrupalContainerRoot(), []string{"drush", "cr", "-y"}) return err } @@ -214,7 +212,7 @@ func RunConfigExport(cmd *cobra.Command, ctx *config.Context, outputPath string) defer cli.Close() containerRoot := ctx.EffectiveDrupalContainerRoot() - if _, err := execDrupalCommandCapture(cmd.Context(), cli, containerName, containerRoot, []string{"drush", "cex", "-y"}); err != nil { + if _, err := docker.ExecCapture(cmd.Context(), cli, containerName, containerRoot, []string{"drush", "cex", "-y"}); err != nil { return err } @@ -300,10 +298,10 @@ func RunConfigImport(cmd *cobra.Command, ctx *config.Context, inputPath, drupalR } containerRoot := ctx.EffectiveDrupalContainerRoot() - if _, err := execDrupalCommandCapture(cmd.Context(), cli, containerName, containerRoot, []string{"drush", "cim", "-y"}); err != nil { + if _, err := docker.ExecCapture(cmd.Context(), cli, containerName, containerRoot, []string{"drush", "cim", "-y"}); err != nil { return err } - _, err = execDrupalCommandCapture(cmd.Context(), cli, containerName, containerRoot, []string{"drush", "cr", "-y"}) + _, err = docker.ExecCapture(cmd.Context(), cli, containerName, containerRoot, []string{"drush", "cr", "-y"}) return err } @@ -326,35 +324,6 @@ func getDrupalContainerForContext(runCtx context.Context, ctx *config.Context) ( return ctx, cli, containerName, nil } -func execDrupalCommandCapture(runCtx context.Context, cli *docker.DockerClient, containerName, containerRoot string, command []string) (string, error) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - exitCode, err := cli.Exec(runCtx, docker.ExecOptions{ - Container: containerName, - Cmd: command, - WorkingDir: containerRoot, - 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("drupal command failed with exit code %d: %s", exitCode, detail) - } - return "", fmt.Errorf("drupal command failed with exit code %d", exitCode) - } - return strings.TrimSpace(stdout.String()), nil -} - func resolveContextDrupalConfigDir(ctx *config.Context, drupalRootfs string) (string, error) { rootfs := strings.TrimSpace(drupalRootfs) if rootfs == "" { @@ -363,26 +332,6 @@ func resolveContextDrupalConfigDir(ctx *config.Context, drupalRootfs string) (st return ctx.ResolveProjectPath(filepath.Join(rootfs, "config", "sync")), nil } -func confirmDatabaseReplacement(targetContext, databaseName, inputPath string) (bool, error) { - 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 - } -} - func uploadDirectory(files *config.FileAccessor, sourceDir, destinationDir string) error { return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error { if err != nil { diff --git a/scripts/gen-docs-snippets/main.go b/scripts/gen-docs-snippets/main.go new file mode 100644 index 0000000..13d3a3c --- /dev/null +++ b/scripts/gen-docs-snippets/main.go @@ -0,0 +1,167 @@ +// gen-docs-snippets generates MDX snippet files for each sitectl-drupal command. +// Run via: make docs-snippets +// Output goes to ../sitectl-docs/snippets/commands/ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + drupalcmd "github.com/libops/sitectl-drupal/cmd" + "github.com/libops/sitectl/pkg/plugin" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const ( + displayPrefix = "sitectl drupal" + outputDir = "../sitectl-docs/snippets/commands" + autoGenHeader = "{/* Auto-generated from source. Run `make docs-snippets` to update. */}\n\n" +) + +func main() { + sdk := plugin.NewSDK(plugin.Metadata{ + Name: "drupal", + Description: "Drupal utilities and migration tools", + }) + drupalcmd.RegisterCommands(sdk) + root := sdk.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 + } + 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 via CommandDisplayNameAnnotation + 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() +}