package main import ( "archive/zip" "bufio" "errors" "fmt" "io" "log" "math/rand" "net/http" "os" "os/exec" "path/filepath" "runtime" "sort" "strings" "time" tea "github.com/charmbracelet/bubbletea" ) var initServiceNameArg string func main() { commands := defaultCommands() args := os.Args[1:] if len(args) > 0 && args[0] == "--" { args = args[1:] } 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: placeholderAction("secrets")}, } } 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 placeholderAction(name string) commandAction { return func() error { fmt.Printf("TODO: implement %s command\n", name) return nil } } 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.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" func init() { rand.Seed(time.Now().UnixNano()) } func writePBConfig(path, serviceName string) error { const tmpl = `[pocketbase] version = "%s" service_name = "%s" [server] ip = "127.0.0.1" port = 8090 domain = "example.com" ` return os.WriteFile(path, []byte(fmt.Sprintf(tmpl, defaultPocketbaseVersion, serviceName)), 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) { reader := bufio.NewReader(os.Stdin) fmt.Printf("Service name [%s]: ", defaultName) input, err := reader.ReadString('\n') if err != nil { return "", err } 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 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") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } idx := strings.Index(line, "=") if idx == -1 { continue } key := strings.TrimSpace(line[:idx]) value := strings.TrimSpace(line[idx+1:]) 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 }