Files
pb/main.go
2026-01-13 17:23:46 +00:00

1888 lines
43 KiB
Go

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 <command> [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 = `[pocketbase]
version = "%s"
service = "%s"
volume = "%s"
[server]
ip = "127.0.0.1"
port = 8090
domain = "example.com"
`
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, error) {
needsConfirm, err := shouldConfirmServerConfig(ctx)
if err != nil {
return nil, err
}
if !needsConfirm {
return ctx, nil
}
if err := confirmServerConfig(ctx.configPath); err != nil {
return nil, err
}
return buildDeploymentContext()
}
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.unitVolume, 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")
ctx, err = ensureServerConfigConfirmed(ctx)
if err != nil {
return err
}
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 <list|set|delete> [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="%s"
current_port=""
if [ -f "$env_file" ]; then
current_port=$(grep '^PORT=' "$env_file" | head -n 1 | cut -d= -f2)
fi
if [ -n "$data_dir" ]; then
mkdir -p "$data_dir"
fi
if [ "$current_port" != "%d" ]; then
cat <<'EOF' > "$env_file"
PORT=%d
EOF
fi
`, serviceDir, serviceDir, assetURL, envFile, volume, port, port)
}
func systemdScript(serviceDir, envFile, volume, 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=%s --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, volume, 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()
}