From 125dcb506b0ba87ca6add1062f3e9864a9afe09b Mon Sep 17 00:00:00 2001 From: Nick Goodall Date: Tue, 13 Jan 2026 13:55:36 +0000 Subject: [PATCH] consolidate into main.go --- deploy.go | 659 ------------------------------------------------------ main.go | 644 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 644 insertions(+), 659 deletions(-) delete mode 100644 deploy.go diff --git a/deploy.go b/deploy.go deleted file mode 100644 index e2f00b4..0000000 --- a/deploy.go +++ /dev/null @@ -1,659 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "crypto/sha1" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "time" - "unicode" - - "github.com/pelletier/go-toml/v2" -) - -const ( - defaultServiceDirTemplate = "/root/pb/{service}" - defaultEnvFileTemplate = "/root/pb/{service}/.env" - totalSetupSteps = 5 - remoteWindowSize = 10 - remoteIndent = " " - remoteLineColor = "\033[96m" - localTimeColor = "\033[92m" - remoteColorReset = "\033[0m" -) - -type pbToml struct { - Server serverConfig `toml:"server"` - PocketBase pocketBaseConfig `toml:"pocketbase"` -} - -type serverConfig struct { - IP string `toml:"ip"` - Port int `toml:"port"` - Domain string `toml:"domain"` -} - -type pocketBaseConfig struct { - Version string `toml:"version"` - ServiceName string `toml:"service_name"` -} - -type deploymentContext struct { - serverIP string - domain string - port int - serviceName string - version string - serviceDir string - envFile string - unitServiceDir string - unitEnvFile string -} - -func buildDeploymentContext() (*deploymentContext, error) { - cwd, err := os.Getwd() - if err != nil { - return nil, err - } - - configPath := filepath.Join(cwd, "pb.toml") - cfg, err := loadPBConfig(configPath) - if err != nil { - return nil, err - } - - serviceName := cfg.PocketBase.ServiceName - if serviceName == "" { - return nil, fmt.Errorf("pb.toml missing [pocketbase].service_name") - } - - serverIP := cfg.Server.IP - if serverIP == "" { - return nil, fmt.Errorf("pb.toml missing [server].ip") - } - - domain := cfg.Server.Domain - if domain == "" { - return nil, fmt.Errorf("pb.toml missing [server].domain") - } - - port := cfg.Server.Port - if port <= 0 { - return nil, fmt.Errorf("pb.toml server.port must be greater than zero") - } - - version := cfg.PocketBase.Version - if version == "" { - version = defaultPocketbaseVersion - } - - serviceDir := renderServiceTemplate(defaultServiceDirTemplate, serviceName) - envFile := renderServiceTemplate(defaultEnvFileTemplate, serviceName) - unitServiceDir := renderServiceTemplate(defaultServiceDirTemplate, "%i") - unitEnvFile := renderServiceTemplate(defaultEnvFileTemplate, "%i") - - return &deploymentContext{ - serverIP: serverIP, - domain: domain, - port: port, - serviceName: serviceName, - version: version, - serviceDir: serviceDir, - envFile: envFile, - unitServiceDir: unitServiceDir, - unitEnvFile: unitEnvFile, - }, nil -} - -func runSetup() error { - ctx, err := buildDeploymentContext() - if err != nil { - return err - } - defer closeSSHControlMaster(ctx.serverIP) - start := time.Now() - - if err := performSetup(ctx); err != nil { - return err - } - - fmt.Printf("\nSetup complete; PocketBase should be reachable at https://%s\n", ctx.domain) - fmt.Printf("Total setup time: %s\n", time.Since(start).Round(time.Millisecond)) - return nil -} - -func performSetup(ctx *deploymentContext) error { - step := 1 - printStep(step, totalSetupSteps, "validating configuration") - remoteOS, err := runSSHOutput(ctx.serverIP, "uname -s") - if err != nil { - return fmt.Errorf("failed to determine remote OS: %w", err) - } - if !strings.EqualFold(remoteOS, "linux") { - return fmt.Errorf("unsupported remote OS %q", remoteOS) - } - - arch, err := detectRemoteArch(ctx.serverIP) - if err != nil { - return err - } - - assetName := pocketbaseAsset(ctx.version, "linux", arch) - assetURL := fmt.Sprintf("https://github.com/pocketbase/pocketbase/releases/download/v%s/%s", ctx.version, assetName) - - step++ - printStep(step, totalSetupSteps, "configuring firewall") - if err := runSSHCommand(ctx.serverIP, firewallScript()); err != nil { - return fmt.Errorf("firewall setup failed: %w", err) - } - - step++ - printStep(step, totalSetupSteps, "installing caddy") - if err := runSSHCommand(ctx.serverIP, caddyScript(ctx.domain, ctx.port, ctx.serviceName)); err != nil { - return fmt.Errorf("caddy setup failed: %w", err) - } - - step++ - printStep(step, totalSetupSteps, "deploying PocketBase binary") - if err := runSSHCommand(ctx.serverIP, pocketbaseSetupScript(ctx.serviceDir, ctx.envFile, ctx.version, assetURL, ctx.port)); err != nil { - return fmt.Errorf("PocketBase setup failed: %w", err) - } - - step++ - printStep(step, totalSetupSteps, "configuring systemd service") - if err := runSSHCommand(ctx.serverIP, systemdScript(ctx.unitServiceDir, ctx.unitEnvFile, ctx.serviceName)); err != nil { - return fmt.Errorf("systemd setup failed: %w", err) - } - - return nil -} - -func runDeploy() error { - ctx, err := buildDeploymentContext() - if err != nil { - return err - } - defer closeSSHControlMaster(ctx.serverIP) - start := time.Now() - - binaryPath := filepath.Join(ctx.serviceDir, "pocketbase") - exists, err := remoteBinaryExists(ctx.serverIP, binaryPath) - if err != nil { - return err - } - if !exists { - fmt.Println("PocketBase binary missing on remote; running setup") - if err := performSetup(ctx); err != nil { - return err - } - } - - dirs := []string{"pb_public", "pb_migrations", "pb_hooks"} - if err := syncLocalDirectories(ctx.serverIP, ctx.serviceDir, dirs); err != nil { - return fmt.Errorf("failed to sync local directories: %w", err) - } - - if err := runSSHCommand(ctx.serverIP, systemdRestartScript(ctx.serviceName)); err != nil { - return fmt.Errorf("systemd restart failed: %w", err) - } - - fmt.Printf("\nDeployment complete; PocketBase should be reachable at https://%s\n", ctx.domain) - fmt.Printf("Total deployment time: %s%s%s\n", localTimeColor, time.Since(start).Round(time.Millisecond), remoteColorReset) - return nil -} - -func runLogs() error { - ctx, err := buildDeploymentContext() - if err != nil { - return err - } - defer closeSSHControlMaster(ctx.serverIP) - - logPath := filepath.Join(ctx.serviceDir, fmt.Sprintf("%s.log", ctx.serviceName)) - script := fmt.Sprintf(`set -euo pipefail -log=%s -if [ ! -f "$log" ]; then - echo "log file not found: $log" >&2 - exit 1 -fi -tail -n 25 -F "$log" -`, shellQuote(logPath)) - return runSSHRawCommand(ctx.serverIP, script) -} - -func remoteBinaryExists(server, path string) (bool, error) { - script := fmt.Sprintf(`if [ -f %q ]; then printf yes; else printf no; fi`, path) - output, err := runSSHOutput(server, script) - if err != nil { - return false, err - } - return strings.TrimSpace(output) == "yes", nil -} - -func loadPBConfig(path string) (*pbToml, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - var cfg pbToml - if err := toml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("failed to parse pb.toml: %w", err) - } - return &cfg, nil -} - -func printStep(idx, total int, message string) { - fmt.Printf("Step %d/%d: %s\n", idx, total, message) -} - -func renderServiceTemplate(tpl, serviceName string) string { - return strings.ReplaceAll(tpl, "{service}", serviceName) -} - -func translateMachineArch(value string) (string, error) { - machine := strings.TrimSpace(strings.ToLower(value)) - - switch machine { - case "x86_64", "amd64": - return "amd64", nil - case "i386", "i486", "i586", "i686": - return "386", nil - case "armv7l": - return "armv7", nil - case "armv6l": - return "arm", nil - case "aarch64", "arm64": - return "arm64", nil - } - return "", fmt.Errorf("unsupported remote architecture %q", value) -} - -func detectRemoteArch(server string) (string, error) { - probes := []string{ - "uname -m", - "arch", - } - var lastErr error - for _, probe := range probes { - output, err := runSSHOutput(server, probe) - if err != nil { - lastErr = fmt.Errorf("%s failed: %w", probe, err) - continue - } - - arch, err := translateMachineArch(output) - if err != nil { - lastErr = fmt.Errorf("%s -> %w", probe, err) - continue - } - return arch, nil - } - if lastErr != nil { - return "", fmt.Errorf("failed to determine remote architecture: %w", lastErr) - } - return "", fmt.Errorf("failed to determine remote architecture") -} - -func pocketbaseAsset(version, osName, arch string) string { - return fmt.Sprintf("pocketbase_%s_%s_%s.zip", version, osName, arch) -} - -func firewallScript() string { - return fmt.Sprintf(`set -euo pipefail -if ! command -v ufw >/dev/null; then - apt-get update -y - apt-get install -y ufw -fi -ufw allow OpenSSH -ufw allow 80/tcp -ufw allow 443/tcp -ufw --force enable -`) -} - -func caddyScript(domain string, port int, serviceName string) string { - return fmt.Sprintf(`set -euo pipefail -if ! command -v caddy >/dev/null; then - apt-get update -y - apt-get install -y curl gnupg2 - curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg - curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list - apt-get update -y - apt-get install -y caddy -fi -mkdir -p /etc/caddy/sites -cat <<'EOF' > /etc/caddy/Caddyfile -import /etc/caddy/sites/*.caddy -EOF -cat <<'EOF' > /etc/caddy/sites/pb-%s.caddy -%s { - request_body { - max_size 10MB - } - encode gzip - reverse_proxy 127.0.0.1:%d { - transport http { - read_timeout 360s - } - } -} -EOF -systemctl daemon-reload -systemctl enable --now caddy.service -systemctl reload-or-restart caddy.service -`, serviceName, domain, port) -} - -func pocketbaseSetupScript(serviceDir, envFile, version, assetURL string, port int) string { - return fmt.Sprintf(`set -euo pipefail -service_dir="%s" -mkdir -p "$service_dir" -apt-get update -y -apt-get install -y curl unzip -binary="%s/pocketbase" -if [ ! -x "$binary" ]; then - tmp=$(mktemp) - curl -fsSL -o "$tmp" "%s" - unzip -p "$tmp" pocketbase > "$binary" - chmod +x "$binary" - rm -f "$tmp" -fi -env_file="%s" -current_port="" -if [ -f "$env_file" ]; then - current_port=$(grep '^PORT=' "$env_file" | head -n 1 | cut -d= -f2) -fi -if [ "$current_port" != "%d" ]; then - cat <<'EOF' > "$env_file" -PORT=%d -EOF -fi -`, serviceDir, serviceDir, assetURL, envFile, port, port) -} - -func systemdScript(serviceDir, envFile, serviceName string) string { - return fmt.Sprintf(`set -euo pipefail -cat <<'EOF' > /etc/systemd/system/pb@.service -[Unit] -Description = PocketBase instance %%i -After = network.target - -[Service] -Type = simple -User = root -Group = root -LimitNOFILE = 4096 -Restart = always -RestartSec = 5s -StandardOutput = append:%s/%%i.log -StandardError = append:%s/%%i.log -WorkingDirectory = %s -EnvironmentFile = %s -ExecStart = %s/pocketbase serve --http="127.0.0.1:${PORT}" - -[Install] -WantedBy = multi-user.target -EOF - systemctl daemon-reload - systemctl --no-block enable --now pb@%s - systemctl --no-block restart pb@%s -`, serviceDir, serviceDir, serviceDir, envFile, serviceDir, serviceName, serviceName) -} - -func systemdRestartScript(serviceName string) string { - return fmt.Sprintf(`set -euo pipefail -systemctl --no-block restart pb@%s -`, serviceName) -} - -func runSSHCommand(server, script string) error { - remoteCmd := fmt.Sprintf("bash --noprofile --norc -c %s", shellQuote(script)) - cmd := exec.Command("ssh", append(sshArgs(server), remoteCmd)...) - stdoutPipe, err := cmd.StdoutPipe() - if err != nil { - return err - } - cmd.Stderr = os.Stderr - if err := cmd.Start(); err != nil { - return err - } - - done := make(chan error, 1) - go func() { - done <- filterEnvStream(stdoutPipe, os.Stdout) - }() - - waitErr := cmd.Wait() - pipeErr := <-done - if waitErr != nil { - return waitErr - } - return pipeErr -} - -func runSSHRawCommand(server, script string) error { - remoteCmd := fmt.Sprintf("bash --noprofile --norc -c %s", shellQuote(script)) - cmd := exec.Command("ssh", append(sshArgs(server), remoteCmd)...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -func runSSHOutput(server, script string) (string, error) { - remoteCmd := fmt.Sprintf("bash --noprofile --norc -c %s", shellQuote(script)) - cmd := exec.Command("ssh", append(sshArgs(server), remoteCmd)...) - var out bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return "", err - } - filtered := stripLeadingEnvLines(out.String()) - return strings.TrimSpace(filtered), nil -} - -func syncLocalDirectories(server, remoteBase string, dirs []string) error { - var toSync []string - for _, dir := range dirs { - localPath := filepath.Join(".", dir) - info, err := os.Stat(localPath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - fmt.Printf("- %s does not exist locally, skipping\n", dir) - continue - } - return err - } - if !info.IsDir() { - fmt.Printf("- %s exists but is not a directory, skipping\n", dir) - continue - } - - toSync = append(toSync, dir+"/") - } - - if len(toSync) == 0 { - return nil - } - - rsyncCmd := rsyncSSHCommand(server) - remotePath := fmt.Sprintf("root@%s:%s", server, remoteBase) - args := append([]string{"-e", rsyncCmd, "-avz", "--delete", "--relative"}, toSync...) - args = append(args, remotePath) - cmd := exec.Command("rsync", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -func rsyncSSHCommand(server string) string { - args := sshSharedArgs(server) - return fmt.Sprintf("ssh %s", strings.Join(args, " ")) -} - -func sshArgs(server string) []string { - args := append([]string(nil), sshSharedArgs(server)...) - return append(args, fmt.Sprintf("root@%s", server)) -} - -func sshSharedArgs(server string) []string { - return []string{ - "-o", "BatchMode=yes", - "-o", "StrictHostKeyChecking=accept-new", - "-o", "ControlMaster=auto", - "-o", fmt.Sprintf("ControlPath=%s", sshControlPath(server)), - "-o", "ControlPersist=5m", - } -} - -func sshControlPath(server string) string { - sum := sha1.Sum([]byte(server)) - return filepath.Join(sshControlDir(), fmt.Sprintf("pb-ssh-%x.sock", sum)) -} - -func sshControlDir() string { - if dir := os.Getenv("PB_SSH_CONTROL_DIR"); dir != "" { - return dir - } - if runtime.GOOS == "windows" { - return os.TempDir() - } - return "/tmp" -} - -func closeSSHControlMaster(server string) { - args := append([]string(nil), sshSharedArgs(server)...) - args = append(args, "-O", "exit", fmt.Sprintf("root@%s", server)) - _ = exec.Command("ssh", args...).Run() -} - -func filterEnvStream(r io.Reader, w io.Writer) error { - scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024) - skipping := true - window := newRemoteWindow(w) - for scanner.Scan() { - line := scanner.Text() - if skipping && isEnvLine(line) { - continue - } - if skipping { - skipping = false - } - if err := window.add(line); err != nil { - return err - } - } - if err := scanner.Err(); err != nil { - return err - } - return nil -} - -type remoteWindow struct { - w io.Writer - lines []string - prevCount int -} - -func newRemoteWindow(w io.Writer) *remoteWindow { - return &remoteWindow{w: w} -} - -func (rw *remoteWindow) add(line string) error { - rw.lines = append(rw.lines, line) - if len(rw.lines) > remoteWindowSize { - rw.lines = rw.lines[1:] - } - return rw.flush() -} - -func (rw *remoteWindow) flush() error { - if rw.prevCount > 0 { - if _, err := fmt.Fprintf(rw.w, "\033[%dA", rw.prevCount); err != nil { - return err - } - } - for _, line := range rw.lines { - if _, err := fmt.Fprintf(rw.w, "\033[2K\r%s%s%s%s\n", remoteLineColor, remoteIndent, line, remoteColorReset); err != nil { - return err - } - } - rw.prevCount = len(rw.lines) - return nil -} - -func stripLeadingEnvLines(input string) string { - scanner := bufio.NewScanner(strings.NewReader(input)) - scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024) - skipping := true - var builder strings.Builder - addedLine := false - for scanner.Scan() { - line := scanner.Text() - if skipping && isEnvLine(line) { - continue - } - if skipping { - skipping = false - } - if addedLine { - builder.WriteByte('\n') - } - builder.WriteString(line) - addedLine = true - } - if err := scanner.Err(); err != nil { - return input - } - return builder.String() -} - -func isEnvLine(line string) bool { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - return true - } - idx := strings.IndexRune(trimmed, '=') - if idx <= 0 { - return false - } - key := trimmed[:idx] - for i, r := range key { - if i == 0 { - if r != '_' && !unicode.IsLetter(r) { - return false - } - continue - } - if r != '_' && !unicode.IsLetter(r) && !unicode.IsDigit(r) { - return false - } - } - return true -} - -func shellQuote(value string) string { - if value == "" { - return "''" - } - var builder strings.Builder - builder.WriteByte('\'') - for _, r := range value { - if r == '\'' { - builder.WriteString("'\"'\"'") - continue - } - builder.WriteRune(r) - } - builder.WriteByte('\'') - return builder.String() -} diff --git a/main.go b/main.go index bdad531..ef1545f 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,8 @@ package main import ( "archive/zip" "bufio" + "bytes" + "crypto/sha1" "errors" "fmt" "io" @@ -16,8 +18,10 @@ import ( "sort" "strings" "time" + "unicode" tea "github.com/charmbracelet/bubbletea" + "github.com/pelletier/go-toml/v2" ) var initServiceNameArg string @@ -593,3 +597,643 @@ func mergeEnv(overrides map[string]string) []string { } return env } + +const ( + defaultServiceDirTemplate = "/root/pb/{service}" + defaultEnvFileTemplate = "/root/pb/{service}/.env" + totalSetupSteps = 5 + remoteWindowSize = 10 + remoteIndent = " " + remoteLineColor = "\033[96m" + localTimeColor = "\033[92m" + remoteColorReset = "\033[0m" +) + +type pbToml struct { + Server serverConfig `toml:"server"` + PocketBase pocketBaseConfig `toml:"pocketbase"` +} + +type serverConfig struct { + IP string `toml:"ip"` + Port int `toml:"port"` + Domain string `toml:"domain"` +} + +type pocketBaseConfig struct { + Version string `toml:"version"` + ServiceName string `toml:"service_name"` +} + +type deploymentContext struct { + serverIP string + domain string + port int + serviceName string + version string + serviceDir string + envFile string + unitServiceDir string + unitEnvFile string +} + +func buildDeploymentContext() (*deploymentContext, error) { + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + + configPath := filepath.Join(cwd, "pb.toml") + cfg, err := loadPBConfig(configPath) + if err != nil { + return nil, err + } + + serviceName := cfg.PocketBase.ServiceName + if serviceName == "" { + return nil, fmt.Errorf("pb.toml missing [pocketbase].service_name") + } + + serverIP := cfg.Server.IP + if serverIP == "" { + return nil, fmt.Errorf("pb.toml missing [server].ip") + } + + domain := cfg.Server.Domain + if domain == "" { + return nil, fmt.Errorf("pb.toml missing [server].domain") + } + + port := cfg.Server.Port + if port <= 0 { + return nil, fmt.Errorf("pb.toml server.port must be greater than zero") + } + + version := cfg.PocketBase.Version + if version == "" { + version = defaultPocketbaseVersion + } + + serviceDir := renderServiceTemplate(defaultServiceDirTemplate, serviceName) + envFile := renderServiceTemplate(defaultEnvFileTemplate, serviceName) + unitServiceDir := renderServiceTemplate(defaultServiceDirTemplate, "%i") + unitEnvFile := renderServiceTemplate(defaultEnvFileTemplate, "%i") + + return &deploymentContext{ + serverIP: serverIP, + domain: domain, + port: port, + serviceName: serviceName, + version: version, + serviceDir: serviceDir, + envFile: envFile, + unitServiceDir: unitServiceDir, + unitEnvFile: unitEnvFile, + }, nil +} + +func runSetup() error { + ctx, err := buildDeploymentContext() + if err != nil { + return err + } + defer closeSSHControlMaster(ctx.serverIP) + start := time.Now() + + if err := performSetup(ctx); err != nil { + return err + } + + fmt.Printf("\nSetup complete; PocketBase should be reachable at https://%s\n", ctx.domain) + fmt.Printf("Total setup time: %s\n", time.Since(start).Round(time.Millisecond)) + return nil +} + +func performSetup(ctx *deploymentContext) error { + step := 1 + printStep(step, totalSetupSteps, "validating configuration") + remoteOS, err := runSSHOutput(ctx.serverIP, "uname -s") + if err != nil { + return fmt.Errorf("failed to determine remote OS: %w", err) + } + if !strings.EqualFold(remoteOS, "linux") { + return fmt.Errorf("unsupported remote OS %q", remoteOS) + } + + arch, err := detectRemoteArch(ctx.serverIP) + if err != nil { + return err + } + + assetName := pocketbaseAsset(ctx.version, "linux", arch) + assetURL := fmt.Sprintf("https://github.com/pocketbase/pocketbase/releases/download/v%s/%s", ctx.version, assetName) + + step++ + printStep(step, totalSetupSteps, "configuring firewall") + if err := runSSHCommand(ctx.serverIP, firewallScript()); err != nil { + return fmt.Errorf("firewall setup failed: %w", err) + } + + step++ + printStep(step, totalSetupSteps, "installing caddy") + if err := runSSHCommand(ctx.serverIP, caddyScript(ctx.domain, ctx.port, ctx.serviceName)); err != nil { + return fmt.Errorf("caddy setup failed: %w", err) + } + + step++ + printStep(step, totalSetupSteps, "deploying PocketBase binary") + if err := runSSHCommand(ctx.serverIP, pocketbaseSetupScript(ctx.serviceDir, ctx.envFile, ctx.version, assetURL, ctx.port)); err != nil { + return fmt.Errorf("PocketBase setup failed: %w", err) + } + + step++ + printStep(step, totalSetupSteps, "configuring systemd service") + if err := runSSHCommand(ctx.serverIP, systemdScript(ctx.unitServiceDir, ctx.unitEnvFile, ctx.serviceName)); err != nil { + return fmt.Errorf("systemd setup failed: %w", err) + } + + return nil +} + +func runDeploy() error { + ctx, err := buildDeploymentContext() + if err != nil { + return err + } + defer closeSSHControlMaster(ctx.serverIP) + start := time.Now() + + binaryPath := filepath.Join(ctx.serviceDir, "pocketbase") + exists, err := remoteBinaryExists(ctx.serverIP, binaryPath) + if err != nil { + return err + } + if !exists { + fmt.Println("PocketBase binary missing on remote; running setup") + if err := performSetup(ctx); err != nil { + return err + } + } + + dirs := []string{"pb_public", "pb_migrations", "pb_hooks"} + if err := syncLocalDirectories(ctx.serverIP, ctx.serviceDir, dirs); err != nil { + return fmt.Errorf("failed to sync local directories: %w", err) + } + + if err := runSSHCommand(ctx.serverIP, systemdRestartScript(ctx.serviceName)); err != nil { + return fmt.Errorf("systemd restart failed: %w", err) + } + + fmt.Printf("\nDeployment complete; PocketBase should be reachable at https://%s\n", ctx.domain) + fmt.Printf("Total deployment time: %s%s%s\n", localTimeColor, time.Since(start).Round(time.Millisecond), remoteColorReset) + return nil +} + +func runLogs() error { + ctx, err := buildDeploymentContext() + if err != nil { + return err + } + defer closeSSHControlMaster(ctx.serverIP) + + logPath := filepath.Join(ctx.serviceDir, fmt.Sprintf("%s.log", ctx.serviceName)) + script := fmt.Sprintf(`set -euo pipefail +log=%s +if [ ! -f "$log" ]; then + echo "log file not found: $log" >&2 + exit 1 +fi +tail -n 25 -F "$log" +`, shellQuote(logPath)) + return runSSHRawCommand(ctx.serverIP, script) +} + +func remoteBinaryExists(server, path string) (bool, error) { + script := fmt.Sprintf(`if [ -f %q ]; then printf yes; else printf no; fi`, path) + output, err := runSSHOutput(server, script) + if err != nil { + return false, err + } + return strings.TrimSpace(output) == "yes", nil +} + +func loadPBConfig(path string) (*pbToml, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cfg pbToml + if err := toml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse pb.toml: %w", err) + } + return &cfg, nil +} + +func printStep(idx, total int, message string) { + fmt.Printf("Step %d/%d: %s\n", idx, total, message) +} + +func renderServiceTemplate(tpl, serviceName string) string { + return strings.ReplaceAll(tpl, "{service}", serviceName) +} + +func translateMachineArch(value string) (string, error) { + machine := strings.TrimSpace(strings.ToLower(value)) + + switch machine { + case "x86_64", "amd64": + return "amd64", nil + case "i386", "i486", "i586", "i686": + return "386", nil + case "armv7l": + return "armv7", nil + case "armv6l": + return "arm", nil + case "aarch64", "arm64": + return "arm64", nil + } + return "", fmt.Errorf("unsupported remote architecture %q", value) +} + +func detectRemoteArch(server string) (string, error) { + probes := []string{ + "uname -m", + "arch", + } + var lastErr error + for _, probe := range probes { + output, err := runSSHOutput(server, probe) + if err != nil { + lastErr = fmt.Errorf("%s failed: %w", probe, err) + continue + } + + arch, err := translateMachineArch(output) + if err != nil { + lastErr = fmt.Errorf("%s -> %w", probe, err) + continue + } + return arch, nil + } + if lastErr != nil { + return "", fmt.Errorf("failed to determine remote architecture: %w", lastErr) + } + return "", fmt.Errorf("failed to determine remote architecture") +} + +func pocketbaseAsset(version, osName, arch string) string { + return fmt.Sprintf("pocketbase_%s_%s_%s.zip", version, osName, arch) +} + +func firewallScript() string { + return fmt.Sprintf(`set -euo pipefail +if ! command -v ufw >/dev/null; then + apt-get update -y + apt-get install -y ufw +fi +ufw allow OpenSSH +ufw allow 80/tcp +ufw allow 443/tcp +ufw --force enable +`) +} + +func caddyScript(domain string, port int, serviceName string) string { + return fmt.Sprintf(`set -euo pipefail +if ! command -v caddy >/dev/null; then + apt-get update -y + apt-get install -y curl gnupg2 + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list + apt-get update -y + apt-get install -y caddy +fi +mkdir -p /etc/caddy/sites +cat <<'EOF' > /etc/caddy/Caddyfile +import /etc/caddy/sites/*.caddy +EOF +cat <<'EOF' > /etc/caddy/sites/pb-%s.caddy +%s { + request_body { + max_size 10MB + } + encode gzip + reverse_proxy 127.0.0.1:%d { + transport http { + read_timeout 360s + } + } +} +EOF +systemctl daemon-reload +systemctl enable --now caddy.service +systemctl reload-or-restart caddy.service +`, serviceName, domain, port) +} + +func pocketbaseSetupScript(serviceDir, envFile, version, assetURL string, port int) string { + return fmt.Sprintf(`set -euo pipefail +service_dir="%s" +mkdir -p "$service_dir" +apt-get update -y +apt-get install -y curl unzip +binary="%s/pocketbase" +if [ ! -x "$binary" ]; then + tmp=$(mktemp) + curl -fsSL -o "$tmp" "%s" + unzip -p "$tmp" pocketbase > "$binary" + chmod +x "$binary" + rm -f "$tmp" +fi +env_file="%s" +current_port="" +if [ -f "$env_file" ]; then + current_port=$(grep '^PORT=' "$env_file" | head -n 1 | cut -d= -f2) +fi +if [ "$current_port" != "%d" ]; then + cat <<'EOF' > "$env_file" +PORT=%d +EOF +fi +`, serviceDir, serviceDir, assetURL, envFile, port, port) +} + +func systemdScript(serviceDir, envFile, serviceName string) string { + return fmt.Sprintf(`set -euo pipefail +cat <<'EOF' > /etc/systemd/system/pb@.service +[Unit] +Description = PocketBase instance %%i +After = network.target + +[Service] +Type = simple +User = root +Group = root +LimitNOFILE = 4096 +Restart = always +RestartSec = 5s +StandardOutput = append:%s/%%i.log +StandardError = append:%s/%%i.log +WorkingDirectory = %s +EnvironmentFile = %s +ExecStart = %s/pocketbase serve --http="127.0.0.1:${PORT}" + +[Install] +WantedBy = multi-user.target +EOF + systemctl daemon-reload + systemctl --no-block enable --now pb@%s + systemctl --no-block restart pb@%s +`, serviceDir, serviceDir, serviceDir, envFile, serviceDir, serviceName, serviceName) +} + +func systemdRestartScript(serviceName string) string { + return fmt.Sprintf(`set -euo pipefail +systemctl --no-block restart pb@%s +`, serviceName) +} + +func runSSHCommand(server, script string) error { + remoteCmd := fmt.Sprintf("bash --noprofile --norc -c %s", shellQuote(script)) + cmd := exec.Command("ssh", append(sshArgs(server), remoteCmd)...) + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return err + } + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return err + } + + done := make(chan error, 1) + go func() { + done <- filterEnvStream(stdoutPipe, os.Stdout) + }() + + waitErr := cmd.Wait() + pipeErr := <-done + if waitErr != nil { + return waitErr + } + return pipeErr +} + +func runSSHRawCommand(server, script string) error { + remoteCmd := fmt.Sprintf("bash --noprofile --norc -c %s", shellQuote(script)) + cmd := exec.Command("ssh", append(sshArgs(server), remoteCmd)...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func runSSHOutput(server, script string) (string, error) { + remoteCmd := fmt.Sprintf("bash --noprofile --norc -c %s", shellQuote(script)) + cmd := exec.Command("ssh", append(sshArgs(server), remoteCmd)...) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", err + } + filtered := stripLeadingEnvLines(out.String()) + return strings.TrimSpace(filtered), nil +} + +func syncLocalDirectories(server, remoteBase string, dirs []string) error { + var toSync []string + for _, dir := range dirs { + localPath := filepath.Join(".", dir) + info, err := os.Stat(localPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + fmt.Printf("- %s does not exist locally, skipping\n", dir) + continue + } + return err + } + if !info.IsDir() { + fmt.Printf("- %s exists but is not a directory, skipping\n", dir) + continue + } + + toSync = append(toSync, dir+"/") + } + + if len(toSync) == 0 { + return nil + } + + rsyncCmd := rsyncSSHCommand(server) + remotePath := fmt.Sprintf("root@%s:%s", server, remoteBase) + args := append([]string{"-e", rsyncCmd, "-avz", "--delete", "--relative"}, toSync...) + args = append(args, remotePath) + cmd := exec.Command("rsync", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func rsyncSSHCommand(server string) string { + args := sshSharedArgs(server) + return fmt.Sprintf("ssh %s", strings.Join(args, " ")) +} + +func sshArgs(server string) []string { + args := append([]string(nil), sshSharedArgs(server)...) + return append(args, fmt.Sprintf("root@%s", server)) +} + +func sshSharedArgs(server string) []string { + return []string{ + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "ControlMaster=auto", + "-o", fmt.Sprintf("ControlPath=%s", sshControlPath(server)), + "-o", "ControlPersist=5m", + } +} + +func sshControlPath(server string) string { + sum := sha1.Sum([]byte(server)) + return filepath.Join(sshControlDir(), fmt.Sprintf("pb-ssh-%x.sock", sum)) +} + +func sshControlDir() string { + if dir := os.Getenv("PB_SSH_CONTROL_DIR"); dir != "" { + return dir + } + if runtime.GOOS == "windows" { + return os.TempDir() + } + return "/tmp" +} + +func closeSSHControlMaster(server string) { + args := append([]string(nil), sshSharedArgs(server)...) + args = append(args, "-O", "exit", fmt.Sprintf("root@%s", server)) + _ = exec.Command("ssh", args...).Run() +} + +func filterEnvStream(r io.Reader, w io.Writer) error { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024) + skipping := true + window := newRemoteWindow(w) + for scanner.Scan() { + line := scanner.Text() + if skipping && isEnvLine(line) { + continue + } + if skipping { + skipping = false + } + if err := window.add(line); err != nil { + return err + } + } + if err := scanner.Err(); err != nil { + return err + } + return nil +} + +type remoteWindow struct { + w io.Writer + lines []string + prevCount int +} + +func newRemoteWindow(w io.Writer) *remoteWindow { + return &remoteWindow{w: w} +} + +func (rw *remoteWindow) add(line string) error { + rw.lines = append(rw.lines, line) + if len(rw.lines) > remoteWindowSize { + rw.lines = rw.lines[1:] + } + return rw.flush() +} + +func (rw *remoteWindow) flush() error { + if rw.prevCount > 0 { + if _, err := fmt.Fprintf(rw.w, "\033[%dA", rw.prevCount); err != nil { + return err + } + } + for _, line := range rw.lines { + if _, err := fmt.Fprintf(rw.w, "\033[2K\r%s%s%s%s\n", remoteLineColor, remoteIndent, line, remoteColorReset); err != nil { + return err + } + } + rw.prevCount = len(rw.lines) + return nil +} + +func stripLeadingEnvLines(input string) string { + scanner := bufio.NewScanner(strings.NewReader(input)) + scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024) + skipping := true + var builder strings.Builder + addedLine := false + for scanner.Scan() { + line := scanner.Text() + if skipping && isEnvLine(line) { + continue + } + if skipping { + skipping = false + } + if addedLine { + builder.WriteByte('\n') + } + builder.WriteString(line) + addedLine = true + } + if err := scanner.Err(); err != nil { + return input + } + return builder.String() +} + +func isEnvLine(line string) bool { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return true + } + idx := strings.IndexRune(trimmed, '=') + if idx <= 0 { + return false + } + key := trimmed[:idx] + for i, r := range key { + if i == 0 { + if r != '_' && !unicode.IsLetter(r) { + return false + } + continue + } + if r != '_' && !unicode.IsLetter(r) && !unicode.IsDigit(r) { + return false + } + } + return true +} + +func shellQuote(value string) string { + if value == "" { + return "''" + } + var builder strings.Builder + builder.WriteByte('\'') + for _, r := range value { + if r == '\'' { + builder.WriteString("'\"'\"'") + continue + } + builder.WriteRune(r) + } + builder.WriteByte('\'') + return builder.String() +}