595 lines
13 KiB
Go
595 lines
13 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"
|
|
)
|
|
|
|
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: "deploy", description: "deploy the PocketBase project", action: runDeploy},
|
|
{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 := 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
|
|
}
|