cmd/tilstream/cmd/deploy.go

package cmd

import (
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"

	"github.com/spf13/cobra"
)

var (
	deployTarget string
	deployBackend string
	deployDryRun bool
)

var deployCmd = &cobra.Command{
	Use:   "deploy",
	Short: "Deploy the built site via rsync or rclone",
	RunE:  runDeploy,
}

func init() {
	deployCmd.Flags().StringVar(&deployTarget, "target", "", "remote target (e.g. user@host:/var/www)")
	deployCmd.Flags().StringVar(&deployBackend, "backend", "rsync", "deployment tool: rsync|rclone")
	deployCmd.Flags().BoolVar(&deployDryRun, "dry-run", false, "print commands without executing")
}

func runDeploy(cmd *cobra.Command, args []string) error {
	if deployTarget == "" {
		return fmt.Errorf("--target is required")
	}
	src, err := filepath.Abs("public")
	if err != nil {
		return err
	}
	if _, err := os.Stat(src); err != nil {
		return fmt.Errorf("build output not found at %s: run tilstream build first", src)
	}
	switch strings.ToLower(deployBackend) {
	case "rsync":
		return runRsync(cmd, src, deployTarget)
	case "rclone":
		return runRclone(cmd, src, deployTarget)
	default:
		return fmt.Errorf("unknown backend %q", deployBackend)
	}
}

func runRsync(cmd *cobra.Command, src, target string) error {
	argv := []string{
		"rsync",
		"-avz",
		"--delete",
		"--checksum",
		src + "/",
		target,
	}
	return runOrDryRun(cmd, argv)
}

func runRclone(cmd *cobra.Command, src, target string) error {
	argv := []string{"rclone", "sync", src, target, "--progress"}
	return runOrDryRun(cmd, argv)
}

func runOrDryRun(cmd *cobra.Command, argv []string) error {
	if deployDryRun {
		fmt.Fprintln(cmd.OutOrStdout(), strings.Join(argv, " "))
		return nil
	}
	c := exec.CommandContext(cmd.Context(), argv[0], argv[1:]...)
	c.Stdout = cmd.OutOrStdout()
	c.Stderr = cmd.ErrOrStderr()
	c.Stdin = os.Stdin
	return c.Run()
}

// DeployBackends lists the supported backends. Exposed so shell completion
// can suggest them.
func DeployBackends() []string { return []string{"rsync", "rclone"} }

// validateTarget is exposed for tests.
func validateTarget(s string) error {
	if s == "" {
		return fmt.Errorf("empty target")
	}
	if !strings.ContainsAny(s, ":@/") {
		return fmt.Errorf("target %q looks like a local path; specify host:path", s)
	}
	return nil
}