package main import ( "archive/zip" "errors" "fmt" "io" "log" "math/rand" "net/http" "os" "path/filepath" "runtime" "strings" "time" "github.com/urfave/cli/v2" ) func main() { app := &cli.App{ Name: "pb", Usage: "PocketBase deployment helper", Commands: []*cli.Command{ { Name: "init", Usage: "start a new PocketBase project", Action: initAction(), }, { Name: "deploy", Usage: "deploy the PocketBase project", Action: placeholderAction("deploy"), }, { Name: "logs", Usage: "show PocketBase logs", Action: placeholderAction("logs"), }, { Name: "secrets", Usage: "manage deployment secrets", Action: placeholderAction("secrets"), }, }, } if err := app.Run(os.Args); err != nil { log.Fatal(err) } } func placeholderAction(name string) func(*cli.Context) error { return func(c *cli.Context) error { fmt.Printf("TODO: implement %s command\n", name) return nil } } func initAction() func(*cli.Context) error { return func(c *cli.Context) 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 } serviceName := generateServiceName() 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.toml"}); err != nil { return err } fmt.Printf("Initialized PocketBase project %q\n", serviceName) return nil } } 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 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) }