Files
pb/main.go
2026-01-13 11:33:29 +00:00

508 lines
11 KiB
Go

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"
)
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
}
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: "deploy", description: "deploy the PocketBase project", action: placeholderAction("deploy")},
{name: "logs", description: "show PocketBase logs", action: placeholderAction("logs")},
{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 <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 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 := promptServiceName(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
}
binaryPath := filepath.Join(targetDir, pocketbaseBinaryName(runtime.GOOS))
if err := downloadPocketbase(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 := os.Stat(binaryPath); err != nil {
return fmt.Errorf("cannot find PocketBase binary at %s: %w", binaryPath, 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 = `[server]
ip = "127.0.0.1"
port = 8090
domain = "example.com"
[pocketbase]
version = "%s"
service_name = "%s"
`
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 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()
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
return err
}
if err := extractBinary(tmpFile.Name(), binaryName, dest); err != nil {
return err
}
return os.Chmod(dest, 0o755)
}
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
}