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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> # execute WP-CLI commands
fly logs --domain example.com # view logs from all containers or a single one
fly restart <container> --domain example.com # restart a container
fly exec <container> --domain example.com # execute commands inside a container. Default: "php"
fly --domain example.com exec [container] <command> # execute commands inside a container. Default: the PHP container
```

Or run the commands from within the site directory without specifying the domain:
Expand All @@ -82,20 +82,23 @@ 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 <container> # view logs from all containers or a single one
fly wp <command> # 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 <container> # restart a container
fly exec <container> # execute commands inside a container. Default: "php"
fly exec [container] <command> # execute commands inside a container. Default: the PHP container
```

### WP-CLI

**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.
Expand Down
80 changes: 65 additions & 15 deletions cmd/site.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"errors"
"fmt"
"slices"

"github.com/flywp/server-cli/internal/docker"
"github.com/flywp/server-cli/internal/utils"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -98,51 +109,90 @@ 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...)...)
},
}

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)
Expand Down
27 changes: 27 additions & 0 deletions cmd/site_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
6 changes: 4 additions & 2 deletions internal/docker/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
29 changes: 29 additions & 0 deletions internal/docker/compose_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
83 changes: 83 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>"
}{
{
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
Expand Down