package main import ( "archive/zip" "bufio" "bytes" "crypto/sha1" "crypto/sha256" "encoding/base64" "errors" "fmt" "io" "log" "math/rand" "net/http" "os" "os/exec" "path" "path/filepath" "runtime" "sort" "strconv" "strings" "time" "unicode" "unicode/utf8" tea "github.com/charmbracelet/bubbletea" "github.com/mattn/go-runewidth" "github.com/pelletier/go-toml/v2" "golang.org/x/term" ) var ( initServiceNameArg string invocationArgs []string ) func main() { commands := defaultCommands() args := os.Args[1:] if len(args) > 0 && args[0] == "--" { args = args[1:] } invocationArgs = args if len(args) == 0 { printUsage(commands) return } if isHelpFlag(args[0]) { printUsage(commands) return } initServiceNameArg = "" if args[0] == "init" && len(args) > 1 { initServiceNameArg = args[1] } if args[0] == "menu" { if err := tea.NewProgram(newModel()).Start(); err != nil { log.Fatal(err) } return } if err := runCommandByName(commands, args[0]); err != nil { log.Fatal(err) } } func defaultCommands() []command { return []command{ {name: "init", description: "start a new PocketBase project", action: runInit}, {name: "dev", description: "run the PocketBase binary locally", action: runDev}, {name: "setup", description: "provision the remote server and install PocketBase", action: runSetup}, {name: "deploy", description: "sync migrations/hooks/static assets (runs setup if needed)", action: runDeploy}, {name: "logs", description: "show PocketBase logs", action: runLogs}, {name: "secrets", description: "manage deployment secrets", action: runSecrets}, } } type commandAction func() error type command struct { name string description string action commandAction } func runCommandByName(commands []command, name string) error { for _, cmd := range commands { if cmd.name == name { return cmd.action() } } return fmt.Errorf("unknown command %q", name) } func printUsage(commands []command) { fmt.Println("Usage: pb [options]") fmt.Println() fmt.Println("Options:") fmt.Println(" -h, --help show this help message") fmt.Println() fmt.Println("Commands:") for _, cmd := range commands { fmt.Printf(" %-10s %s\n", cmd.name, cmd.description) } fmt.Println() fmt.Println("Use \"pb menu\" for an interactive helper.") } func isHelpFlag(arg string) bool { return arg == "-h" || arg == "--help" } func newModel() model { return model{commands: defaultCommands()} } type model struct { commands []command cursor int status string running bool lastCommand string } type commandResultMsg struct { name string err error } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "up", "k": if m.running { return m, nil } if m.cursor > 0 { m.cursor-- } else { m.cursor = len(m.commands) - 1 } return m, nil case "down", "j": if m.running { return m, nil } if m.cursor < len(m.commands)-1 { m.cursor++ } else { m.cursor = 0 } return m, nil case "enter": if m.running { return m, nil } selected := m.commands[m.cursor] m.running = true m.lastCommand = selected.name m.status = fmt.Sprintf("Running %s...", selected.name) return m, runCommand(selected) } case commandResultMsg: m.running = false if msg.err != nil { m.status = fmt.Sprintf("%s failed: %v", msg.name, msg.err) } else { m.status = fmt.Sprintf("%s finished successfully", msg.name) } return m, nil } return m, nil } func (m model) View() string { var b strings.Builder b.WriteString("PocketBase deployment helper\n\n") for idx, cmd := range m.commands { cursor := " " if m.cursor == idx { cursor = ">" } b.WriteString(fmt.Sprintf("%s %s\n %s\n", cursor, cmd.name, cmd.description)) } b.WriteString("\n") if m.status != "" { b.WriteString(fmt.Sprintf("%s\n\n", m.status)) } if m.running { b.WriteString("(running... press ctrl+c to abort)\n") } b.WriteString("↑/↓ to navigate, enter to run, q to quit\n") return b.String() } func runCommand(cmd command) tea.Cmd { return func() tea.Msg { return commandResultMsg{name: cmd.name, err: cmd.action()} } } func runInit() error { cwd, err := os.Getwd() if err != nil { return err } pbPath := filepath.Join(cwd, "pb.toml") if _, err := os.Stat(pbPath); err == nil { return fmt.Errorf("pb.toml already exists, aborting") } else if !errors.Is(err, os.ErrNotExist) { return err } defaultName := generateServiceName() serviceName, err := resolveServiceName(defaultName) if err != nil { return err } if err := writePBConfig(pbPath, serviceName); err != nil { return err } targetDir := filepath.Join(cwd, "bin") if err := os.MkdirAll(targetDir, 0o755); err != nil { return err } if err := os.WriteFile(filepath.Join(targetDir, ".keep"), []byte{}, 0o644); err != nil { return err } binaryPath := filepath.Join(targetDir, pocketbaseBinaryName(runtime.GOOS)) if err := ensurePocketbaseBinary(defaultPocketbaseVersion, runtime.GOOS, runtime.GOARCH, binaryPath); err != nil { return err } if err := ensureGitignoreEntries(filepath.Join(cwd, ".gitignore"), []string{"bin/pocketbase", "pb_data", ".env", ".DS_store"}); err != nil { return err } fmt.Printf("Initialized PocketBase project %q\n", serviceName) return nil } func runDev() error { cwd, err := os.Getwd() if err != nil { return err } binaryPath := filepath.Join(cwd, "bin", pocketbaseBinaryName(runtime.GOOS)) if err := ensurePocketbaseBinary(defaultPocketbaseVersion, runtime.GOOS, runtime.GOARCH, binaryPath); err != nil { return err } overrides, err := loadEnv(filepath.Join(cwd, ".env")) if err != nil { return err } cmd := exec.Command(binaryPath, "serve") cmd.Args = append(cmd.Args, "--dir", filepath.Join(cwd, "pb_data")) cmd.Args = append(cmd.Args, "--hooksDir", filepath.Join(cwd, "pb_hooks")) cmd.Args = append(cmd.Args, "--migrationsDir", filepath.Join(cwd, "pb_migrations")) cmd.Args = append(cmd.Args, "--publicDir", filepath.Join(cwd, "pb_public")) cmd.Dir = cwd cmd.Env = mergeEnv(overrides) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin fmt.Println("starting PocketBase development server") return cmd.Run() } const ( defaultPocketbaseVersion = "0.35.1" defaultPocketbaseVolume = "pb_data" ) func init() { rand.Seed(time.Now().UnixNano()) log.SetFlags(0) log.SetPrefix("") } func writePBConfig(path, serviceName string) error { const tmpl = `[server] ip = "127.0.0.1" port = 8090 domain = "example.com" [pocketbase] version = "%s" service = "%s" volume = "%s" ` return os.WriteFile(path, []byte(fmt.Sprintf(tmpl, defaultPocketbaseVersion, serviceName, defaultPocketbaseVolume)), 0o644) } func generateServiceName() string { adjectives := []string{"sunny", "silent", "cobalt", "rusty", "plush", "brisk", "sage", "glow"} nouns := []string{"harbor", "delta", "bloom", "atlas", "ripple", "ember", "lumen", "orbit"} adjective := adjectives[rand.Intn(len(adjectives))] noun := nouns[rand.Intn(len(nouns))] return fmt.Sprintf("%s-%s", adjective, noun) } func promptServiceName(defaultName string) (string, error) { prompt := fmt.Sprintf("%sService name%s [%s]: ", headerColor, remoteColorReset, defaultName) input, err := readEditableLine(prompt) if err != nil { return "", err } input = normalizePromptInput(input) input = strings.TrimSpace(input) if input == "" { return defaultName, nil } return input, nil } func resolveServiceName(defaultName string) (string, error) { if override := strings.TrimSpace(initServiceNameArg); override != "" { initServiceNameArg = "" return override, nil } return promptServiceName(defaultName) } func shouldConfirmServerConfig(ctx *deploymentContext) (bool, error) { exists, err := remoteDirExists(ctx.serverIP, ctx.serviceDir) if err != nil { fmt.Fprintf(os.Stderr, "warning: unable to verify remote service at %s: %v\n", ctx.serviceDir, err) return true, nil } return !exists, nil } func ensureServerConfigConfirmed(ctx *deploymentContext) (*deploymentContext, bool, error) { needsConfirm, err := shouldConfirmServerConfig(ctx) if err != nil { return nil, false, err } if !needsConfirm { return ctx, false, nil } if err := confirmServerConfig(ctx.configPath); err != nil { return nil, false, err } newCtx, err := buildDeploymentContext() if err != nil { return nil, false, err } return newCtx, true, nil } func confirmServerConfig(pbPath string) error { cfg, err := loadPBConfig(pbPath) if err != nil { return err } fmt.Printf("Confirm remote server settings (press Enter to keep the current value):\n") ipDefault := cfg.Server.IP if ipDefault == "" { ipDefault = "127.0.0.1" } ip, err := promptWithDefault("Server IP", ipDefault) if err != nil { return err } portDefault := cfg.Server.Port if portDefault <= 0 { portDefault = 8090 } port, err := promptPort("Server port", portDefault) if err != nil { return err } domainDefault := cfg.Server.Domain if domainDefault == "" { domainDefault = "example.com" } domain, err := promptWithDefault("Server domain", domainDefault) if err != nil { return err } cfg.Server.IP = ip cfg.Server.Port = port cfg.Server.Domain = domain if err := savePBConfig(pbPath, cfg); err != nil { return err } return nil } func promptWithDefault(label, defaultValue string) (string, error) { var suffix string if defaultValue != "" { suffix = fmt.Sprintf(" [%s]: ", defaultValue) } else { suffix = ": " } prompt := fmt.Sprintf("%s%s%s%s", headerColor, label, remoteColorReset, suffix) input, err := readEditableLine(prompt) if err != nil { return "", err } input = normalizePromptInput(input) input = strings.TrimSpace(input) if input == "" { return defaultValue, nil } return input, nil } func promptPort(label string, defaultValue int) (int, error) { if defaultValue <= 0 { defaultValue = 8090 } for { prompt := fmt.Sprintf("%s%s%s [%d]: ", headerColor, label, remoteColorReset, defaultValue) input, err := readEditableLine(prompt) if err != nil { return 0, err } input = normalizePromptInput(input) value := strings.TrimSpace(input) if value == "" { return defaultValue, nil } port, err := strconv.Atoi(value) if err != nil || port <= 0 || port > 65535 { fmt.Println(" please enter a valid port between 1 and 65535") continue } return port, nil } } func normalizePromptInput(raw string) string { var buf []rune for i := 0; i < len(raw); { r, size := utf8.DecodeRuneInString(raw[i:]) if size == 0 { break } if r == '\r' || r == '\n' { break } if r == 0x1b { nextIndex, final := skipEscapeSequence(raw, i) if nextIndex <= i { i++ continue } i = nextIndex if final == 0x7f || final == 0x08 { buf = deletePreviousWord(buf) } continue } if r == 0x17 { buf = deletePreviousWord(buf) i += size continue } if r == 0x7f || r == 0x08 { if len(buf) > 0 { buf = buf[:len(buf)-1] } i += size continue } buf = append(buf, r) i += size } return string(buf) } func readEditableLine(prompt string) (string, error) { fd := int(os.Stdin.Fd()) reader := bufio.NewReader(os.Stdin) if !term.IsTerminal(fd) { line, err := reader.ReadString('\n') if err != nil { return "", err } line = strings.TrimRight(line, "\r\n") return line, nil } oldState, err := term.MakeRaw(fd) if err != nil { return "", err } defer term.Restore(fd, oldState) var buf []rune var lastWidth int fmt.Print(prompt) for { r, _, err := reader.ReadRune() if err != nil { return "", err } switch { case r == '\r' || r == '\n': fmt.Print("\r\n") return string(buf), nil case r == 0x03: fmt.Fprint(os.Stderr, "\r\n") return "", errors.New("input interrupted") case r == 0x04: fmt.Println() return "", io.EOF case r == 0x7f || r == 0x08: if len(buf) > 0 { buf = buf[:len(buf)-1] redrawEditableLine(prompt, buf, &lastWidth) } case r == 0x17: buf = deletePreviousWord(buf) redrawEditableLine(prompt, buf, &lastWidth) case r == 0x1b: final, err := readEscapeFinal(reader) if err != nil { return "", err } if final == 0x7f || final == 0x08 { buf = deletePreviousWord(buf) redrawEditableLine(prompt, buf, &lastWidth) } default: if r < 0x20 { continue } buf = append(buf, r) redrawEditableLine(prompt, buf, &lastWidth) } } } func readEscapeFinal(reader *bufio.Reader) (rune, error) { b, err := reader.ReadByte() if err != nil { return 0, err } if b == '[' || b == 'O' { for { c, err := reader.ReadByte() if err != nil { return 0, err } if isCSIFinalByte(rune(c)) { return rune(c), nil } } } return rune(b), nil } func redrawEditableLine(prompt string, buf []rune, lastWidth *int) { fmt.Print("\r") fmt.Print(prompt) text := string(buf) fmt.Print(text) width := runewidth.StringWidth(text) if *lastWidth > width { fmt.Print(strings.Repeat(" ", *lastWidth-width)) fmt.Print("\r") fmt.Print(prompt) fmt.Print(text) } *lastWidth = width } func skipEscapeSequence(raw string, escIndex int) (int, rune) { i := escIndex + 1 if i >= len(raw) { return i, 0 } next, size := utf8.DecodeRuneInString(raw[i:]) i += size if next == '[' || next == 'O' { for i < len(raw) { code, codeSize := utf8.DecodeRuneInString(raw[i:]) if codeSize == 0 { break } i += codeSize if isCSIFinalByte(code) { return i, code } } return i, 0 } return i, next } func isCSIFinalByte(r rune) bool { return 0x40 <= r && r <= 0x7e } func deletePreviousWord(buf []rune) []rune { i := len(buf) for i > 0 && unicode.IsSpace(buf[i-1]) { i-- } for i > 0 && !unicode.IsSpace(buf[i-1]) { i-- } return buf[:i] } func pocketbaseBinaryName(goos string) string { if goos == "windows" { return "pocketbase.exe" } return "pocketbase" } func downloadPocketbase(version, goos, goarch, dest string) error { url, binaryName, err := releaseAssetURL(version, goos, goarch) if err != nil { return err } resp, err := http.Get(url) if err != nil { return fmt.Errorf("failed to download %s: %w", url, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected status %s while downloading %s", resp.Status, url) } tmpFile, err := os.CreateTemp("", "pocketbase-*.zip") if err != nil { return err } defer os.Remove(tmpFile.Name()) defer tmpFile.Close() label := fmt.Sprintf("Downloading PocketBase v%s (%s/%s)", version, goos, goarch) if err := downloadWithProgress(tmpFile, resp.Body, resp.ContentLength, label); err != nil { return err } if err := extractBinary(tmpFile.Name(), binaryName, dest); err != nil { return err } return os.Chmod(dest, 0o755) } func ensurePocketbaseBinary(version, goos, goarch, dest string) error { if _, err := os.Stat(dest); err == nil { return nil } else if !errors.Is(err, os.ErrNotExist) { return err } fmt.Println("PocketBase binary missing; downloading...") return downloadPocketbase(version, goos, goarch, dest) } func downloadWithProgress(dst io.Writer, src io.Reader, total int64, label string) error { const chunkSize = 32 * 1024 buf := make([]byte, chunkSize) var downloaded int64 lastPercent := int64(-1) updateProgress := func(force bool) { if total > 0 { percent := downloaded * 100 / total if force || percent != lastPercent { fmt.Printf("\r%s %3d%%", label, percent) lastPercent = percent } } else { fmt.Printf("\r%s %s downloaded", label, formatBytes(downloaded)) } } for { n, err := src.Read(buf) if n > 0 { if _, writeErr := dst.Write(buf[:n]); writeErr != nil { fmt.Println() return writeErr } downloaded += int64(n) updateProgress(false) } if err != nil { if err == io.EOF { break } fmt.Println() return err } } updateProgress(true) fmt.Println() return nil } func formatBytes(b int64) string { const unit = 1024 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := int64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) } func releaseAssetURL(version, goos, goarch string) (string, string, error) { osName := map[string]string{ "darwin": "darwin", "linux": "linux", "windows": "windows", } archName := map[string]string{ "amd64": "amd64", "386": "386", "arm64": "arm64", "arm": "armv7", } osPart, ok := osName[goos] if !ok { return "", "", fmt.Errorf("unsupported GOOS: %s", goos) } archPart, ok := archName[goarch] if !ok { return "", "", fmt.Errorf("unsupported GOARCH: %s", goarch) } filename := fmt.Sprintf("pocketbase_%s_%s_%s.zip", version, osPart, archPart) url := fmt.Sprintf("https://github.com/pocketbase/pocketbase/releases/download/v%s/%s", version, filename) return url, pocketbaseBinaryName(goos), nil } func extractBinary(zipPath, binaryName, dest string) error { r, err := zip.OpenReader(zipPath) if err != nil { return err } defer r.Close() for _, f := range r.File { if filepath.Base(f.Name) != binaryName { continue } if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return err } src, err := f.Open() if err != nil { return err } defer src.Close() out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) if err != nil { return err } defer out.Close() if _, err := io.Copy(out, src); err != nil { return err } return nil } return fmt.Errorf("binary %s not found in archive", binaryName) } func ensureGitignoreEntries(path string, entries []string) error { var existing = make(map[string]struct{}) var original []byte if data, err := os.ReadFile(path); err == nil { original = data for _, line := range strings.Split(string(data), "\n") { trimmed := strings.TrimSpace(line) if trimmed != "" { existing[trimmed] = struct{}{} } } } else if !errors.Is(err, os.ErrNotExist) { return err } builder := &strings.Builder{} if len(original) > 0 { builder.Write(original) if !strings.HasSuffix(string(original), "\n") { builder.WriteByte('\n') } } var added bool for _, entry := range entries { if _, seen := existing[entry]; seen { continue } builder.WriteString(entry) builder.WriteByte('\n') added = true } if !added { return nil } return os.WriteFile(path, []byte(builder.String()), 0o644) } func loadEnv(path string) (map[string]string, error) { data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, err } env := make(map[string]string) for _, line := range strings.Split(string(data), "\n") { if key, value, ok := parseEnvLine(line); ok { env[key] = value } } return env, nil } func mergeEnv(overrides map[string]string) []string { envMap := make(map[string]string) for _, kv := range os.Environ() { parts := strings.SplitN(kv, "=", 2) key := parts[0] value := "" if len(parts) > 1 { value = parts[1] } envMap[key] = value } for key, value := range overrides { envMap[key] = value } keys := make([]string, 0, len(envMap)) for key := range envMap { keys = append(keys, key) } sort.Strings(keys) env := make([]string, 0, len(keys)) for _, key := range keys { env = append(env, fmt.Sprintf("%s=%s", key, envMap[key])) } return env } const ( defaultServiceDirTemplate = "/root/pb/{service}" defaultEnvFileTemplate = "/root/pb/{service}/.env" totalSetupSteps = 5 remoteWindowSize = 10 remoteIndent = " " remoteLineColor = "\033[96m" headerColor = "\033[95m" 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"` Volume string `toml:"volume"` } type deploymentContext struct { serverIP string domain string port int serviceName string version string serviceDir string envFile string unitServiceDir string unitEnvFile string volume string unitVolume string configPath 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") } 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") volumeTemplate := cfg.PocketBase.Volume if volumeTemplate == "" { volumeTemplate = defaultPocketbaseVolume } volume := resolveVolumePath(volumeTemplate, serviceDir, serviceName) unitVolume := resolveVolumePath(volumeTemplate, unitServiceDir, "%i") return &deploymentContext{ serverIP: serverIP, domain: domain, port: port, serviceName: serviceName, version: version, serviceDir: serviceDir, envFile: envFile, unitServiceDir: unitServiceDir, unitEnvFile: unitEnvFile, volume: volume, unitVolume: unitVolume, configPath: configPath, }, nil } func runSetup() error { ctx, err := buildDeploymentContext() if err != nil { return err } ctx, _, err = ensureServerConfigConfirmed(ctx) 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%s%s\n", localTimeColor, time.Since(start).Round(time.Millisecond), remoteColorReset) 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.volume, 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 func() { closeSSHControlMaster(ctx.serverIP) }() start := time.Now() binaryPath := filepath.Join(ctx.serviceDir, "pocketbase") exists, err := remoteBinaryExists(ctx.serverIP, binaryPath) if err != nil { fmt.Fprintf(os.Stderr, "warning: Unable to contact remote server %s: %v\n", ctx.serverIP, err) } if err != nil || !exists { fmt.Println("PocketBase binary missing on remote; running setup") ctx, prompted, err := ensureServerConfigConfirmed(ctx) if err != nil { return err } if prompted { start = time.Now() } 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 := restartPocketBaseService(ctx); err != nil { return 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 runSecrets() error { if len(invocationArgs) < 2 { return fmt.Errorf("usage: pb secrets [arguments]") } ctx, err := buildDeploymentContext() if err != nil { return err } defer closeSSHControlMaster(ctx.serverIP) subcommand := invocationArgs[1] args := invocationArgs[2:] switch subcommand { case "list": if len(args) != 0 { return fmt.Errorf("usage: pb secrets list") } return runSecretsList(ctx) case "set": if len(args) == 0 { return fmt.Errorf("usage: pb secrets set KEY=VALUE...") } return runSecretsSet(ctx, args) case "delete": if len(args) == 0 { return fmt.Errorf("usage: pb secrets delete KEY...") } return runSecretsDelete(ctx, args) default: return fmt.Errorf("unknown secrets subcommand %q", subcommand) } } func runSecretsList(ctx *deploymentContext) error { lines, err := loadRemoteEnvLines(ctx) if err != nil { return err } type secretRow struct { key string digest string } var rows []secretRow for _, line := range lines { if key, value, ok := parseEnvLine(line); ok { rows = append(rows, secretRow{ key: key, digest: secretDigest(value), }) } } if len(rows) == 0 { fmt.Println("no secrets found") return nil } sort.Slice(rows, func(i, j int) bool { return rows[i].key < rows[j].key }) nameWidth := len("NAME") for _, row := range rows { if lw := len(row.key); lw > nameWidth { nameWidth = lw } } fmt.Printf("%s%-*s %s%s\n", headerColor, nameWidth, "NAME", "DIGEST", remoteColorReset) for _, row := range rows { fmt.Printf("%-*s %s\n", nameWidth, row.key, row.digest) } return nil } func secretDigest(value string) string { sum := sha256.Sum256([]byte(value)) // Return only a prefix so the table stays narrow but still reveals changes. const shortBytes = 6 return fmt.Sprintf("%x", sum[:shortBytes]) } type envAssignment struct { key string value string } func runSecretsSet(ctx *deploymentContext, pairs []string) error { var assignments []envAssignment for _, pair := range pairs { idx := strings.Index(pair, "=") if idx <= 0 { return fmt.Errorf("invalid assignment %q, expected KEY=VALUE", pair) } key := strings.TrimSpace(pair[:idx]) if key == "" { return fmt.Errorf("invalid assignment %q: empty key", pair) } if !isEnvKey(key) { return fmt.Errorf("invalid env key %q", key) } assignments = append(assignments, envAssignment{key: key, value: pair[idx+1:]}) } lines, err := loadRemoteEnvLines(ctx) if err != nil { return err } updated := applyEnvAssignments(lines, assignments) if err := writeRemoteEnvLines(ctx, updated); err != nil { return err } if err := restartPocketBaseService(ctx); err != nil { return err } fmt.Printf("updated %d secrets and restarted PocketBase\n", len(assignments)) return nil } func runSecretsDelete(ctx *deploymentContext, keys []string) error { var normalized []string for _, key := range keys { key = strings.TrimSpace(key) if key == "" { return fmt.Errorf("invalid empty key") } if !isEnvKey(key) { return fmt.Errorf("invalid env key %q", key) } normalized = append(normalized, key) } lines, err := loadRemoteEnvLines(ctx) if err != nil { return err } updated := removeEnvKeys(lines, normalized) if err := writeRemoteEnvLines(ctx, updated); err != nil { return err } if err := restartPocketBaseService(ctx); err != nil { return err } fmt.Printf("removed %d secrets and restarted PocketBase\n", len(normalized)) return nil } func loadRemoteEnvLines(ctx *deploymentContext) ([]string, error) { data, err := readRemoteEnvFile(ctx) if err != nil { return nil, err } data = strings.ReplaceAll(data, "\r\n", "\n") data = strings.TrimRightFunc(data, func(r rune) bool { return r == '\n' || r == '\r' }) if data == "" { return nil, nil } return strings.Split(data, "\n"), nil } func readRemoteEnvFile(ctx *deploymentContext) (string, error) { script := fmt.Sprintf(`set -euo pipefail if [ -f %s ]; then cat %s fi `, shellQuote(ctx.envFile), shellQuote(ctx.envFile)) return runSSHCollect(ctx.serverIP, script) } func writeRemoteEnvLines(ctx *deploymentContext, lines []string) error { content := strings.Join(lines, "\n") if content != "" && !strings.HasSuffix(content, "\n") { content += "\n" } dir := filepath.Dir(ctx.envFile) encoded := base64.StdEncoding.EncodeToString([]byte(content)) script := fmt.Sprintf(`set -euo pipefail mkdir -p %s cat <<'EOF' | base64 -d > %s %s EOF `, shellQuote(dir), shellQuote(ctx.envFile), encoded) return runSSHCommand(ctx.serverIP, script) } func applyEnvAssignments(lines []string, assignments []envAssignment) []string { if len(assignments) == 0 { return lines } assignMap := make(map[string]string, len(assignments)) order := make([]string, 0, len(assignments)) for _, assign := range assignments { if _, seen := assignMap[assign.key]; !seen { order = append(order, assign.key) } assignMap[assign.key] = assign.value } inserted := make(map[string]bool, len(assignMap)) var out []string for _, line := range lines { if key, _, ok := parseEnvLine(line); ok { if value, exists := assignMap[key]; exists { if !inserted[key] { out = append(out, fmt.Sprintf("%s=%s", key, value)) inserted[key] = true } continue } } out = append(out, line) } for _, key := range order { if inserted[key] { continue } out = append(out, fmt.Sprintf("%s=%s", key, assignMap[key])) inserted[key] = true } return out } func removeEnvKeys(lines []string, keys []string) []string { if len(keys) == 0 { return lines } targets := make(map[string]struct{}, len(keys)) for _, key := range keys { targets[key] = struct{}{} } var out []string for _, line := range lines { if key, _, ok := parseEnvLine(line); ok { if _, remove := targets[key]; remove { continue } } out = append(out, line) } return out } 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 remoteDirExists(server, path string) (bool, error) { script := fmt.Sprintf(`if [ -d %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 savePBConfig(path string, cfg *pbToml) error { data, err := toml.Marshal(cfg) if err != nil { return err } return os.WriteFile(path, data, 0o644) } 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 resolveVolumePath(volumeTemplate, baseDir, serviceName string) string { value := renderServiceTemplate(volumeTemplate, serviceName) if value == "" { return baseDir } if strings.HasPrefix(value, "/") { return value } return path.Join(baseDir, value) } 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, volume 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" data_dir="%[5]s" current_port="" current_data_dir="" if [ -f "$env_file" ]; then current_port=$(grep '^PORT=' "$env_file" | head -n 1 | cut -d= -f2 || true) current_data_dir=$(grep '^POCKETBASE_DATA_DIR=' "$env_file" | head -n 1 | cut -d= -f2 || true) fi if [ -n "$data_dir" ]; then mkdir -p "$data_dir" fi if [ "$current_port" != "%d" ] || [ "$current_data_dir" != "%[5]s" ]; then cat <<'EOF' > "$env_file" PORT=%d POCKETBASE_DATA_DIR=%[5]s EOF fi `, serviceDir, serviceDir, assetURL, envFile, volume, 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 --dir="${POCKETBASE_DATA_DIR}" --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 restartPocketBaseService(ctx *deploymentContext) error { if err := runSSHCommand(ctx.serverIP, systemdRestartScript(ctx.serviceName)); err != nil { return fmt.Errorf("systemd restart failed: %w", err) } return nil } 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 runSSHCollect(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 } return out.String(), 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 parseEnvLine(line string) (string, string, bool) { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") { return "", "", false } idx := strings.Index(trimmed, "=") if idx <= 0 { return "", "", false } key := strings.TrimSpace(trimmed[:idx]) if !isEnvKey(key) { return "", "", false } value := strings.TrimSpace(trimmed[idx+1:]) return key, value, true } func isEnvKey(key string) bool { if key == "" { return false } 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 isEnvLine(line string) bool { trimmed := strings.TrimSpace(line) if trimmed == "" { return true } idx := strings.IndexRune(trimmed, '=') if idx <= 0 { return false } return isEnvKey(trimmed[:idx]) } 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() }