From 5b22058f294e916973acde6d02bd4dec6a0cd9b6 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Wed, 1 Apr 2026 11:02:25 +0200 Subject: [PATCH] feat: add headless dev mode, start/stop subcommands, and project logs Restructure `project dev` to support non-interactive usage: - `project dev` auto-detects TTY: launches TUI in terminal, starts containers in background otherwise (for AI agents and CI) - `project dev start` explicitly starts in background with spinner, elapsed time, shop/admin URLs, and discovered service URLs - `project dev stop` stops the environment with spinner and timing Add `project logs` command for reading Shopware application logs: - Reads from var/log/, defaults to most recently modified .log file - `--list` / `-l` lists available log files with size and modification time - `--lines` controls number of lines (default 100) - `--follow` / `-f` tails the log file Executor interface changes: - Add `StartEnvironment` and `StopEnvironment` methods - Add `TypeDocker`, `TypeLocal`, `TypeSymfonyCLI` constants - Docker executor captures compose output, only shows on failure - Local and Symfony CLI executors return `ErrNotSupported` Extract `DiscoverServices` as a public function in devtui for reuse. --- cmd/project/project_dev.go | 194 ++++++++++++++++++++++++---- cmd/project/project_logs.go | 183 ++++++++++++++++++++++++++ internal/devtui/model.go | 2 +- internal/devtui/tab_general.go | 123 +++++++++--------- internal/devtui/tab_general_test.go | 4 +- internal/executor/docker.go | 26 +++- internal/executor/executor.go | 17 +++ internal/executor/local.go | 10 +- internal/executor/symfony_cli.go | 10 +- 9 files changed, 482 insertions(+), 87 deletions(-) create mode 100644 cmd/project/project_logs.go diff --git a/cmd/project/project_dev.go b/cmd/project/project_dev.go index ae716221..a9da3daf 100644 --- a/cmd/project/project_dev.go +++ b/cmd/project/project_dev.go @@ -1,62 +1,210 @@ package project import ( + "context" + "fmt" + "os" + "strings" + "time" + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2/spinner" + "github.com/mattn/go-isatty" "github.com/spf13/cobra" "github.com/shopware/shopware-cli/internal/devtui" dockerpkg "github.com/shopware/shopware-cli/internal/docker" "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/internal/tui" ) +type devEnvironment struct { + projectRoot string + cfg *shop.Config + envCfg *shop.EnvironmentConfig + executor executor.Executor +} + var projectDevCmd = &cobra.Command{ Use: "dev", - Short: "Start the interactive development dashboard", + Short: "Start the development environment", + Long: "Start the development environment. Launches the interactive TUI dashboard when run in a terminal, or starts containers in the background otherwise.", RunE: func(cmd *cobra.Command, args []string) error { - projectRoot, err := findClosestShopwareProject() + env, err := setupDevEnvironment(cmd) if err != nil { return err } - cfg, err := shop.ReadConfig(cmd.Context(), projectConfigPath, true) - if err != nil { - return err + if !isatty.IsTerminal(os.Stdin.Fd()) { + return env.start(cmd) } - if cfg.IsCompatibilityDateBefore(shop.CompatibilityDevMode) { - return shop.ErrDevModeNotSupported - } + return env.runTUI() + }, +} - envCfg, err := cfg.ResolveEnvironment(environmentName) +var projectDevStartCmd = &cobra.Command{ + Use: "start", + Short: "Start the development environment in the background", + RunE: func(cmd *cobra.Command, args []string) error { + env, err := setupDevEnvironment(cmd) if err != nil { return err } - exec, err := executor.New(projectRoot, envCfg, cfg) + return env.start(cmd) + }, +} + +var projectDevStopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop the development environment", + RunE: func(cmd *cobra.Command, args []string) error { + env, err := setupDevEnvironment(cmd) if err != nil { return err } - if exec.Type() == "docker" { - if err := dockerpkg.WriteComposeFile(projectRoot, dockerpkg.ComposeOptionsFromConfig(cfg)); err != nil { - return err + return env.stop(cmd) + }, +} + +// setupDevEnvironment reads config, creates the executor, and writes the compose +// file if using Docker. It is shared by all dev subcommands. +func setupDevEnvironment(cmd *cobra.Command) (*devEnvironment, error) { + projectRoot, err := findClosestShopwareProject() + if err != nil { + return nil, err + } + + cfg, err := shop.ReadConfig(cmd.Context(), projectConfigPath, true) + if err != nil { + return nil, err + } + + if cfg.IsCompatibilityDateBefore(shop.CompatibilityDevMode) { + return nil, shop.ErrDevModeNotSupported + } + + envCfg, err := cfg.ResolveEnvironment(environmentName) + if err != nil { + return nil, err + } + + exec, err := executor.New(projectRoot, envCfg, cfg) + if err != nil { + return nil, err + } + + if exec.Type() == executor.TypeDocker { + if err := dockerpkg.WriteComposeFile(projectRoot, dockerpkg.ComposeOptionsFromConfig(cfg)); err != nil { + return nil, err + } + } + + return &devEnvironment{ + projectRoot: projectRoot, + cfg: cfg, + envCfg: envCfg, + executor: exec, + }, nil +} + +func (e *devEnvironment) start(cmd *cobra.Command) error { + start := time.Now() + + err := spinner.New(). + Title("Starting development environment..."). + Context(cmd.Context()). + ActionWithErr(func(ctx context.Context) error { + return e.executor.StartEnvironment(ctx) + }). + Run() + + if err != nil { + return fmt.Errorf("starting environment: %w", err) + } + + elapsed := time.Since(start).Round(time.Millisecond) + + fmt.Println(tui.GreenText.Bold(true).Render(fmt.Sprintf(" ✓ Development environment started in %s", elapsed))) + fmt.Println() + + shopURL := e.cfg.URL + if e.envCfg.URL != "" { + shopURL = e.envCfg.URL + } + + if shopURL != "" { + adminURL := shopURL + if !strings.HasSuffix(adminURL, "/") { + adminURL += "/" + } + adminURL += "admin" + + fmt.Println(tui.SectionTitleStyle.Render(" Shop")) + fmt.Println(tui.DimText.Render(" Shop URL: ") + tui.BoldText.Render(shopURL)) + fmt.Println(tui.DimText.Render(" Admin URL: ") + tui.BoldText.Render(adminURL)) + fmt.Println() + } + + if e.executor.Type() == executor.TypeDocker { + services, _ := devtui.DiscoverServices(cmd.Context(), e.projectRoot) + if len(services) > 0 { + fmt.Println(tui.SectionTitleStyle.Render(" Services")) + for _, svc := range services { + fmt.Println(tui.DimText.Render(" "+svc.Name+": ") + tui.BoldText.Render(svc.URL)) } + fmt.Println() } + } - m := devtui.New(devtui.Options{ - ProjectRoot: projectRoot, - Config: cfg, - EnvConfig: envCfg, - Executor: exec, - }) + fmt.Println(tui.DimText.Render(" Run ") + tui.BoldText.Render("shopware-cli project dev stop") + tui.DimText.Render(" to stop it.")) + fmt.Println(tui.DimText.Render(" Run ") + tui.BoldText.Render("shopware-cli project logs") + tui.DimText.Render(" to view application logs.")) + fmt.Println() - p := tea.NewProgram(m) - _, err = p.Run() - return err - }, + return nil +} + +func (e *devEnvironment) stop(cmd *cobra.Command) error { + start := time.Now() + + err := spinner.New(). + Title("Stopping development environment..."). + Context(cmd.Context()). + ActionWithErr(func(ctx context.Context) error { + return e.executor.StopEnvironment(ctx) + }). + Run() + + if err != nil { + return fmt.Errorf("stopping environment: %w", err) + } + + elapsed := time.Since(start).Round(time.Millisecond) + + fmt.Println(tui.GreenText.Bold(true).Render(fmt.Sprintf(" ✓ Development environment stopped in %s", elapsed))) + fmt.Println() + + return nil +} + +func (e *devEnvironment) runTUI() error { + m := devtui.New(devtui.Options{ + ProjectRoot: e.projectRoot, + Config: e.cfg, + EnvConfig: e.envCfg, + Executor: e.executor, + }) + + p := tea.NewProgram(m) + _, err := p.Run() + return err } func init() { projectRootCmd.AddCommand(projectDevCmd) + projectDevCmd.AddCommand(projectDevStartCmd) + projectDevCmd.AddCommand(projectDevStopCmd) } diff --git a/cmd/project/project_logs.go b/cmd/project/project_logs.go new file mode 100644 index 00000000..e75db39c --- /dev/null +++ b/cmd/project/project_logs.go @@ -0,0 +1,183 @@ +package project + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/shopware/shopware-cli/internal/tui" +) + +var projectLogsCmd = &cobra.Command{ + Use: "logs [filename]", + Short: "Show Shopware application logs from var/log/", + Long: "Show the last lines of a Shopware log file. Without arguments, shows the most recently modified log file. Use --list to discover available log files.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + projectRoot, err := findClosestShopwareProject() + if err != nil { + return err + } + + logDir := filepath.Join(projectRoot, "var", "log") + + list, _ := cmd.Flags().GetBool("list") + if list { + return listLogFiles(logDir) + } + + files, err := findLogFiles(logDir) + if err != nil { + return err + } + + if len(files) == 0 { + return fmt.Errorf("no log files found in %s", logDir) + } + + var target string + if len(args) > 0 { + target = filepath.Join(logDir, args[0]) + if _, err := os.Stat(target); err != nil { + return fmt.Errorf("log file not found: %s", args[0]) + } + } else { + // Most recently modified file + target = files[0].path + } + + lines, _ := cmd.Flags().GetInt("lines") + follow, _ := cmd.Flags().GetBool("follow") + + if follow { + return tailFollow(cmd, target, lines) + } + + return printLastLines(target, lines) + }, +} + +type logFileInfo struct { + path string + name string + size int64 + modTime time.Time +} + +func findLogFiles(logDir string) ([]logFileInfo, error) { + entries, err := os.ReadDir(logDir) + if err != nil { + return nil, fmt.Errorf("could not read log directory: %w", err) + } + + var files []logFileInfo + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".log") { + continue + } + + info, err := entry.Info() + if err != nil { + continue + } + + files = append(files, logFileInfo{ + path: filepath.Join(logDir, entry.Name()), + name: entry.Name(), + size: info.Size(), + modTime: info.ModTime(), + }) + } + + // Sort by modification time, most recent first + slices.SortFunc(files, func(a, b logFileInfo) int { + return b.modTime.Compare(a.modTime) + }) + + return files, nil +} + +func listLogFiles(logDir string) error { + files, err := findLogFiles(logDir) + if err != nil { + return err + } + + if len(files) == 0 { + fmt.Println(tui.DimText.Render("No log files found.")) + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + _, _ = fmt.Fprintln(w, tui.BoldText.Render("File")+"\t"+tui.BoldText.Render("Size")+"\t"+tui.BoldText.Render("Modified")) + + for _, f := range files { + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", f.name, formatSize(f.size), f.modTime.Format("2006-01-02 15:04:05")) + } + + return w.Flush() +} + +func formatSize(bytes int64) string { + switch { + case bytes >= 1<<20: + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(1<<20)) + case bytes >= 1<<10: + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(1<<10)) + default: + return fmt.Sprintf("%d B", bytes) + } +} + +func printLastLines(path string, n int) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + // Use a ring buffer to keep the last N lines + ring := make([]string, 0, n) + for scanner.Scan() { + if len(ring) < n { + ring = append(ring, scanner.Text()) + } else { + copy(ring, ring[1:]) + ring[n-1] = scanner.Text() + } + } + + if err := scanner.Err(); err != nil { + return err + } + + for _, line := range ring { + fmt.Println(line) + } + + return nil +} + +func tailFollow(cmd *cobra.Command, path string, n int) error { + tailCmd := exec.CommandContext(cmd.Context(), "tail", "-n", fmt.Sprintf("%d", n), "-f", path) + tailCmd.Stdout = cmd.OutOrStdout() + tailCmd.Stderr = cmd.ErrOrStderr() + + return tailCmd.Run() +} + +func init() { + projectRootCmd.AddCommand(projectLogsCmd) + projectLogsCmd.Flags().Int("lines", 100, "Number of lines to show") + projectLogsCmd.Flags().BoolP("follow", "f", false, "Follow the log file for new output") + projectLogsCmd.Flags().BoolP("list", "l", false, "List available log files") +} diff --git a/internal/devtui/model.go b/internal/devtui/model.go index 55a48fd5..890df712 100644 --- a/internal/devtui/model.go +++ b/internal/devtui/model.go @@ -182,7 +182,7 @@ func New(opts Options) Model { password = effectiveAdminApi.Password } - isDocker := opts.Executor.Type() == "docker" + isDocker := opts.Executor.Type() == executor.TypeDocker return Model{ activeTab: tabGeneral, diff --git a/internal/devtui/tab_general.go b/internal/devtui/tab_general.go index ec0cf1c5..a321762d 100644 --- a/internal/devtui/tab_general.go +++ b/internal/devtui/tab_general.go @@ -20,7 +20,7 @@ type GeneralModel struct { adminURL string username string password string - services []discoveredService + services []DiscoveredService projectRoot string executor executor.Executor loading bool @@ -33,7 +33,8 @@ type GeneralModel struct { sfWatchStarting bool } -type discoveredService struct { +// DiscoveredService represents an auxiliary service discovered via docker compose. +type DiscoveredService struct { Name string URL string Username string @@ -41,7 +42,7 @@ type discoveredService struct { } type servicesLoadedMsg struct { - services []discoveredService + services []DiscoveredService err error } @@ -235,74 +236,80 @@ type dockerComposePSOutput struct { } `json:"Publishers"` } -func discoverServices(projectRoot string) tea.Cmd { - return func() tea.Msg { - ctx := context.Background() - cmd := exec.CommandContext(ctx, "docker", "compose", "ps", "--format", "json") - cmd.Dir = projectRoot - output, err := cmd.Output() - if err != nil { - return servicesLoadedMsg{err: fmt.Errorf("docker compose ps: %w", err)} - } +// DiscoverServices queries docker compose for running containers and returns +// auxiliary services (Adminer, Mailpit, etc.) with their published URLs. +func DiscoverServices(ctx context.Context, projectRoot string) ([]DiscoveredService, error) { + cmd := exec.CommandContext(ctx, "docker", "compose", "ps", "--format", "json") + cmd.Dir = projectRoot + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("docker compose ps: %w", err) + } + + var services []DiscoveredService - var services []discoveredService + // Collect all containers with their published ports + type containerInfo struct { + service string + publishers map[int]int // targetPort -> publishedPort + } + var containers []containerInfo - // Collect all containers with their published ports - type containerInfo struct { - service string - publishers map[int]int // targetPort -> publishedPort + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if line == "" { + continue } - var containers []containerInfo - for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { - if line == "" { - continue - } + var container dockerComposePSOutput + if err := json.Unmarshal([]byte(line), &container); err != nil { + continue + } - var container dockerComposePSOutput - if err := json.Unmarshal([]byte(line), &container); err != nil { - continue + ports := make(map[int]int) + for _, pub := range container.Publishers { + if pub.PublishedPort != 0 { + ports[pub.TargetPort] = pub.PublishedPort } + } - ports := make(map[int]int) - for _, pub := range container.Publishers { - if pub.PublishedPort != 0 { - ports[pub.TargetPort] = pub.PublishedPort - } - } + if len(ports) > 0 { + containers = append(containers, containerInfo{ + service: container.Service, + publishers: ports, + }) + } + } - if len(ports) > 0 { - containers = append(containers, containerInfo{ - service: container.Service, - publishers: ports, - }) - } + // Match containers against known services or skip ignored ones + for _, c := range containers { + if ignoredServices[c.service] { + continue } - // Match containers against known services or skip ignored ones - for _, c := range containers { - if ignoredServices[c.service] { - continue - } + known, ok := knownServices[c.service] + if !ok { + continue + } - known, ok := knownServices[c.service] - if !ok { - continue - } + publishedPort, hasPort := c.publishers[known.TargetPort] + if !hasPort { + continue + } - publishedPort, hasPort := c.publishers[known.TargetPort] - if !hasPort { - continue - } + services = append(services, DiscoveredService{ + Name: known.Name, + URL: fmt.Sprintf("http://127.0.0.1:%d", publishedPort), + Username: known.Username, + Password: known.Password, + }) + } - services = append(services, discoveredService{ - Name: known.Name, - URL: fmt.Sprintf("http://127.0.0.1:%d", publishedPort), - Username: known.Username, - Password: known.Password, - }) - } + return services, nil +} - return servicesLoadedMsg{services: services} +func discoverServices(projectRoot string) tea.Cmd { + return func() tea.Msg { + services, err := DiscoverServices(context.Background(), projectRoot) + return servicesLoadedMsg{services: services, err: err} } } diff --git a/internal/devtui/tab_general_test.go b/internal/devtui/tab_general_test.go index a6807c4f..5d2bb6d7 100644 --- a/internal/devtui/tab_general_test.go +++ b/internal/devtui/tab_general_test.go @@ -33,7 +33,7 @@ func TestNewGeneralModel_EmptyURL(t *testing.T) { func TestServicesLoadedMsg(t *testing.T) { m := NewGeneralModel("docker", "http://localhost:8000", "", "", "/tmp/project", nil) - services := []discoveredService{ + services := []DiscoveredService{ {Name: "Adminer", URL: "http://127.0.0.1:9080", Username: "root", Password: "root"}, {Name: "Shopware", URL: "http://localhost:8000"}, } @@ -80,7 +80,7 @@ func TestKnownServices(t *testing.T) { func TestViewShowsCredentials(t *testing.T) { m := NewGeneralModel("docker", "http://localhost:8000", "", "", "/tmp/project", nil) m.loading = false - m.services = []discoveredService{ + m.services = []DiscoveredService{ {Name: "Adminer", URL: "http://127.0.0.1:9080", Username: "root", Password: "root"}, } diff --git a/internal/executor/docker.go b/internal/executor/docker.go index 4cacfc82..d5545187 100644 --- a/internal/executor/docker.go +++ b/internal/executor/docker.go @@ -74,7 +74,7 @@ func (d *DockerExecutor) NormalizePath(hostPath string) string { } func (d *DockerExecutor) Type() string { - return "docker" + return TypeDocker } func (d *DockerExecutor) WithEnv(env map[string]string) Executor { @@ -132,6 +132,30 @@ func (d *DockerExecutor) newProcess(cmd *exec.Cmd, innerArgs []string) *Process } } +func (d *DockerExecutor) StartEnvironment(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "docker", "compose", "up", "-d") + cmd.Dir = d.projectRoot + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%w\n%s", err, output) + } + + return nil +} + +func (d *DockerExecutor) StopEnvironment(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "docker", "compose", "down") + cmd.Dir = d.projectRoot + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%w\n%s", err, output) + } + + return nil +} + func (d *DockerExecutor) baseArgs() []string { args := []string{"compose", "exec"} diff --git a/internal/executor/executor.go b/internal/executor/executor.go index ad03a961..ef2bb7f8 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -2,6 +2,7 @@ package executor import ( "context" + "errors" "os" "os/exec" "path/filepath" @@ -11,6 +12,16 @@ import ( "github.com/shopware/shopware-cli/logging" ) +// ErrNotSupported is returned when the executor does not support a managed environment. +var ErrNotSupported = errors.New("operation not supported by this executor") + +// Executor type constants returned by Executor.Type(). +const ( + TypeDocker = "docker" + TypeLocal = "local" + TypeSymfonyCLI = "symfony-cli" +) + // Executor abstracts command execution across different environment types. type Executor interface { ConsoleCommand(ctx context.Context, args ...string) *Process @@ -24,6 +35,12 @@ type Executor interface { Type() string WithEnv(env map[string]string) Executor WithRelDir(relDir string) Executor + // StartEnvironment starts the backing environment (e.g. docker compose up -d). + // Returns ErrNotSupported for executors that have no managed environment. + StartEnvironment(ctx context.Context) error + // StopEnvironment stops the backing environment (e.g. docker compose down). + // Returns ErrNotSupported for executors that have no managed environment. + StopEnvironment(ctx context.Context) error } type allowBinCIKey struct{} diff --git a/internal/executor/local.go b/internal/executor/local.go index ec5b30ef..23496745 100644 --- a/internal/executor/local.go +++ b/internal/executor/local.go @@ -53,7 +53,7 @@ func (l *LocalExecutor) NormalizePath(hostPath string) string { } func (l *LocalExecutor) Type() string { - return "local" + return TypeLocal } func (l *LocalExecutor) WithEnv(env map[string]string) Executor { @@ -64,6 +64,14 @@ func (l *LocalExecutor) WithRelDir(relDir string) Executor { return &LocalExecutor{env: l.env, projectRoot: l.projectRoot, relDir: relDir} } +func (l *LocalExecutor) StartEnvironment(_ context.Context) error { + return ErrNotSupported +} + +func (l *LocalExecutor) StopEnvironment(_ context.Context) error { + return ErrNotSupported +} + // applyLocalEnv sets PROJECT_ROOT and extra environment variables on a local command. func applyLocalEnv(projectRoot string, env map[string]string, cmd *exec.Cmd) { cmd.Env = os.Environ() diff --git a/internal/executor/symfony_cli.go b/internal/executor/symfony_cli.go index 57824c2e..c2996db7 100644 --- a/internal/executor/symfony_cli.go +++ b/internal/executor/symfony_cli.go @@ -56,7 +56,7 @@ func (s *SymfonyCLIExecutor) NormalizePath(hostPath string) string { } func (s *SymfonyCLIExecutor) Type() string { - return "symfony-cli" + return TypeSymfonyCLI } func (s *SymfonyCLIExecutor) WithEnv(env map[string]string) Executor { @@ -66,3 +66,11 @@ func (s *SymfonyCLIExecutor) WithEnv(env map[string]string) Executor { func (s *SymfonyCLIExecutor) WithRelDir(relDir string) Executor { return &SymfonyCLIExecutor{BinaryPath: s.BinaryPath, env: s.env, projectRoot: s.projectRoot, relDir: relDir} } + +func (s *SymfonyCLIExecutor) StartEnvironment(_ context.Context) error { + return ErrNotSupported +} + +func (s *SymfonyCLIExecutor) StopEnvironment(_ context.Context) error { + return ErrNotSupported +}