From 3d17dc29a656511597efff54ab26cec005ba8336 Mon Sep 17 00:00:00 2001 From: nabil1440 <52530910+nabil1440@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:54:25 +0600 Subject: [PATCH] fix(site): pass flags through to wp-cli, exec and logs - wp and exec stop parsing fly flags at the first argument, so 'fly wp plugin list --format=json' and 'fly exec php ls -la' work. --domain goes before the command. - exec uses the site's PHP service by default: php, or openlitespeed on OpenLiteSpeed sites (DefaultService, shared with wp). - exec reports an error when only a service name is given. - logs accepts --follow/-f, --tail and several services. - README: document flag passthrough and --domain placement. Closes #4 --- README.md | 15 +++--- cmd/site.go | 80 +++++++++++++++++++++++++------ cmd/site_test.go | 27 +++++++++++ internal/docker/compose.go | 6 ++- internal/docker/compose_test.go | 29 ++++++++++++ main_test.go | 83 +++++++++++++++++++++++++++++++++ 6 files changed, 217 insertions(+), 23 deletions(-) create mode 100644 cmd/site_test.go create mode 100644 internal/docker/compose_test.go diff --git a/README.md b/README.md index f146ce4..eec72e8 100644 --- a/README.md +++ b/README.md @@ -70,10 +70,10 @@ You can run the following commands from anywhere inside a site folder or by spec fly start --domain example.com # starts the website fly stop --domain example.com # stops the website fly restart --domain example.com # restarts the website -fly wp --domain example.com # execute WP-CLI commands +fly --domain example.com wp # execute WP-CLI commands fly logs --domain example.com # view logs from all containers or a single one fly restart --domain example.com # restart a container -fly exec --domain example.com # execute commands inside a container. Default: "php" +fly --domain example.com exec [container] # execute commands inside a container. Default: the PHP container ``` Or run the commands from within the site directory without specifying the domain: @@ -82,10 +82,11 @@ Or run the commands from within the site directory without specifying the domain fly start # starts the website fly stop # stops the website fly restart # restarts the website -fly wp # execute WP-CLI commands -fly logs # view logs from all containers or a single one +fly wp # execute WP-CLI commands +fly logs [container] # view logs from all containers or a single one +fly logs -f [container] # follow the logs (--tail N shows the last N lines) fly restart # restart a container -fly exec # execute commands inside a container. Default: "php" +fly exec [container] # execute commands inside a container. Default: the PHP container ``` ### WP-CLI @@ -93,9 +94,11 @@ fly exec # execute commands inside a container. Default: "php **wp-cli**: To access `wp-cli`, use the following command from anywhere in the website folder or specify the domain name. The CLI will find the appropriate WordPress folder to execute the `wp` command. ```bash -fly wp --domain example.com +fly --domain example.com wp plugin list --format=json ``` +All arguments after the WP-CLI command (or after the command for `fly exec`) go to that command unchanged, flags included. Put `--domain` before the command. To pass a flag as the first argument, put `--` before it, for example `fly wp -- --info`. + ### Global Commands A few helper commands to debug the server installation and start/stop all sites. diff --git a/cmd/site.go b/cmd/site.go index 90716fd..299356b 100644 --- a/cmd/site.go +++ b/cmd/site.go @@ -3,6 +3,7 @@ package cmd import ( "errors" "fmt" + "slices" "github.com/flywp/server-cli/internal/docker" "github.com/flywp/server-cli/internal/utils" @@ -34,8 +35,18 @@ func siteComposePath() (string, error) { } var wpCmd = &cobra.Command{ - Use: "wp", + Use: "wp [wp-cli command] [args...]", Short: "Run wp-cli commands", + Long: `Run wp-cli commands in the site's PHP container. + +All arguments after the first wp-cli word go to wp-cli unchanged, flags included. +Put --domain before the wp-cli command: + + fly --domain example.com wp plugin list --format=json + +To pass a flag as the first argument, put -- before it: + + fly wp -- --info`, RunE: func(cmd *cobra.Command, args []string) error { composePath, err := siteComposePath() if err != nil { @@ -98,44 +109,76 @@ var restartCmd = &cobra.Command{ }, } +// execServices are the services that fly exec accepts as its first argument. +var execServices = []string{"php", "nginx", "openlitespeed"} + +// splitService splits the arguments of fly exec into a service name and a +// command. The service is empty when the first argument is not a service. +func splitService(args []string) (service string, command []string) { + if len(args) > 0 && slices.Contains(execServices, args[0]) { + return args[0], args[1:] + } + + return "", args +} + var execCmd = &cobra.Command{ Use: "exec [service] command [args...]", Short: "Execute a command in the Docker container", - Args: cobra.MinimumNArgs(1), + Long: `Execute a command in a Docker container of the site. + +If the first argument is php, nginx or openlitespeed, the command runs in that +service. Otherwise it runs in the site's PHP service (php or openlitespeed). +All arguments after the first one go to the command unchanged, flags included. +Put --domain before the command: + + fly --domain example.com exec php ls -la`, + Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { composePath, err := siteComposePath() if err != nil { return err } - // if the next argument is "php", "nginx" or "openlitespeed", use it as the service name - // otherwise, use "php" as the default service name - composeArgs := []string{"exec"} - if args[0] == "php" || args[0] == "nginx" || args[0] == "openlitespeed" { - composeArgs = append(composeArgs, args[0]) - args = args[1:] - } else { - composeArgs = append(composeArgs, "php") + service, command := splitService(args) + if service == "" { + if service, err = docker.DefaultService(composePath); err != nil { + return err + } } - composeArgs = append(composeArgs, args...) + if len(command) == 0 { + return fmt.Errorf("no command given for service %q", service) + } - return docker.RunCompose(composePath, composeArgs...) + return docker.RunCompose(composePath, append([]string{"exec", service}, command...)...) }, } +var ( + logsFollow bool + logsTail string +) + var logsCmd = &cobra.Command{ - Use: "logs", + Use: "logs [service...]", Short: "Show logs of the Docker container", Long: `Show logs of Docker container(s). If no container is specified, it shows logs for all containers.`, - Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { composePath, err := siteComposePath() if err != nil { return err } - return docker.RunCompose(composePath, append([]string{"logs"}, args...)...) + composeArgs := []string{"logs"} + if logsFollow { + composeArgs = append(composeArgs, "--follow") + } + if logsTail != "" { + composeArgs = append(composeArgs, "--tail", logsTail) + } + + return docker.RunCompose(composePath, append(composeArgs, args...)...) }, } @@ -143,6 +186,13 @@ func init() { // Add domain flag to rootCmd rootCmd.PersistentFlags().StringVar(&domain, "domain", "", "Specify domain for executing commands in a specific site") + // Flags after the first argument belong to wp-cli or to the command. + wpCmd.Flags().SetInterspersed(false) + execCmd.Flags().SetInterspersed(false) + + logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "Follow log output") + logsCmd.Flags().StringVar(&logsTail, "tail", "", "Number of lines to show from the end of the logs") + rootCmd.AddCommand(wpCmd) rootCmd.AddCommand(startCmd) rootCmd.AddCommand(stopCmd) diff --git a/cmd/site_test.go b/cmd/site_test.go new file mode 100644 index 0000000..5e39234 --- /dev/null +++ b/cmd/site_test.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "slices" + "testing" +) + +func TestSplitService(t *testing.T) { + tests := []struct { + args []string + wantService string + wantCommand []string + }{ + {args: []string{"php", "ls", "-la"}, wantService: "php", wantCommand: []string{"ls", "-la"}}, + {args: []string{"nginx", "nginx", "-t"}, wantService: "nginx", wantCommand: []string{"nginx", "-t"}}, + {args: []string{"openlitespeed", "ls"}, wantService: "openlitespeed", wantCommand: []string{"ls"}}, + {args: []string{"ls", "-la"}, wantService: "", wantCommand: []string{"ls", "-la"}}, + {args: []string{"php"}, wantService: "php", wantCommand: []string{}}, + } + + for _, tt := range tests { + service, command := splitService(tt.args) + if service != tt.wantService || !slices.Equal(command, tt.wantCommand) { + t.Errorf("splitService(%q) = %q, %q, want %q, %q", tt.args, service, command, tt.wantService, tt.wantCommand) + } + } +} diff --git a/internal/docker/compose.go b/internal/docker/compose.go index 2625f0d..8c8840e 100644 --- a/internal/docker/compose.go +++ b/internal/docker/compose.go @@ -35,7 +35,9 @@ func RunCompose(composePath string, args ...string) error { return cmd.Run() } -func getContainerName(composePath string) (string, error) { +// DefaultService returns the service that runs PHP for the site: "php" for +// PHP sites or "openlitespeed" for OpenLiteSpeed sites. +func DefaultService(composePath string) (string, error) { data, err := os.ReadFile(composePath) if err != nil { return "", err @@ -56,7 +58,7 @@ func getContainerName(composePath string) (string, error) { } func RunWPCLI(composePath string, args []string) error { - containerName, err := getContainerName(composePath) + containerName, err := DefaultService(composePath) if err != nil { return err } diff --git a/internal/docker/compose_test.go b/internal/docker/compose_test.go new file mode 100644 index 0000000..ccfabb7 --- /dev/null +++ b/internal/docker/compose_test.go @@ -0,0 +1,29 @@ +package docker + +import ( + "testing" + + "github.com/flywp/server-cli/internal/testutil" +) + +func TestDefaultService(t *testing.T) { + tests := []struct { + services []string + want string + wantErr bool + }{ + {services: []string{"php", "nginx"}, want: "php"}, + {services: []string{"openlitespeed"}, want: "openlitespeed"}, + {services: []string{"php", "openlitespeed"}, want: "php"}, + {services: []string{"nginx"}, wantErr: true}, + } + + for _, tt := range tests { + composePath := testutil.WriteSite(t, t.TempDir(), tt.services...) + + got, err := DefaultService(composePath) + if (err != nil) != tt.wantErr || got != tt.want { + t.Errorf("DefaultService(%q) = %q, %v, want %q (error: %v)", tt.services, got, err, tt.want, tt.wantErr) + } + } +} diff --git a/main_test.go b/main_test.go index ae84c18..8351241 100644 --- a/main_test.go +++ b/main_test.go @@ -210,6 +210,89 @@ func TestDomainFlag(t *testing.T) { } } +func TestFlagsPassThrough(t *testing.T) { + tests := []struct { + name string + services []string + outside bool // run outside the site directory + args []string + want string // docker arguments after "compose -f " + }{ + { + name: "wp-cli flags", + args: []string{"wp", "plugin", "list", "--format=json"}, + want: "exec -T php wp plugin list --format=json", + }, + { + name: "domain before the wp-cli command", + outside: true, + args: []string{"--domain", "example.com", "wp", "plugin", "list", "--format=json"}, + want: "exec -T php wp plugin list --format=json", + }, + { + name: "exec flags", + args: []string{"exec", "php", "ls", "-la"}, + want: "exec -T php ls -la", + }, + { + name: "exec in the default service", + args: []string{"exec", "ls", "-la"}, + want: "exec -T php ls -la", + }, + { + name: "exec on an OpenLiteSpeed site", + services: []string{"openlitespeed"}, + args: []string{"exec", "ls"}, + want: "exec -T openlitespeed ls", + }, + { + name: "wp on an OpenLiteSpeed site", + services: []string{"openlitespeed"}, + args: []string{"wp", "plugin", "list"}, + want: "exec -T --user www-data openlitespeed wp plugin list", + }, + { + name: "follow logs of one service", + args: []string{"logs", "-f", "php"}, + want: "logs --follow php", + }, + { + name: "tail logs", + args: []string{"logs", "--tail", "50"}, + want: "logs --tail 50", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := newEnv(t, tt.services...) + dir := e.site + if tt.outside { + dir = testutil.TempDir(t) + } + + res := e.run(t, dir, tt.args...) + if res.code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr %q)", res.code, res.stderr) + } + + want := "compose -f " + filepath.Join(e.site, "docker-compose.yml") + " " + tt.want + if calls := e.docker.Calls(t); len(calls) != 1 || calls[0] != want { + t.Errorf("docker calls = %q, want [%q]", calls, want) + } + }) + } +} + +func TestExecNeedsACommand(t *testing.T) { + e := newEnv(t) + + res := e.run(t, e.site, "exec", "php") + if res.code != 1 || !strings.Contains(res.stderr, "no command given") { + t.Errorf("exit %d, stderr %q, want exit 1 and a missing-command error", res.code, res.stderr) + } +} + func TestUsageErrors(t *testing.T) { tests := []struct { args []string