Compare commits

...

10 Commits

Author SHA1 Message Date
78c0d0184e license 2026-01-23 15:41:38 +00:00
03ecba3892 warning 2026-01-23 15:38:22 +00:00
6d4a3f6cce update workflow 2026-01-15 16:53:56 +00:00
e0bf545881 release 2026-01-15 16:48:59 +00:00
67fce39d1f some tests? 2026-01-15 16:44:29 +00:00
f86cfec0a7 .env init 2026-01-14 18:31:57 +00:00
d211493e3c improve init 2026-01-14 17:59:17 +00:00
095a92dfc2 readme example update 2026-01-14 14:17:56 +00:00
4e2b2046ae fix status color 2026-01-14 13:35:45 +00:00
b942757ade fix setup remote stoof 2026-01-14 13:33:34 +00:00
7 changed files with 445 additions and 38 deletions

36
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: release
on:
pull_request:
push:
tags:
- "*"
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run tests
run: go test ./...
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

30
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,30 @@
version: 2
project_name: pb
builds:
- id: pb
main: .
binary: pb
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
archives:
- id: pb
builds:
- pb
format: tar.gz
format_overrides:
- goos: windows
format: zip
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
checksum:
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Nick Goodall
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -2,6 +2,12 @@
A simple and _fast_ PocketBase deployment tool. A simple and _fast_ PocketBase deployment tool.
## ⚠️ Fair Warning
**This is a personal project.** I built it to manage and deploy some side-quests using PocketBase: [Milkstonks](https://milkstonks.com), [ringing.guide](https://ringing.guide), [belfry.world](https://belfry.world) and [waterways.watch](https://waterways.watch).
Use at your own peril.
## Commands ## Commands
### `init` ### `init`
@@ -10,9 +16,9 @@ Start a new PocketBase project (optionally provide a service name via `pb init <
```toml ```toml
[server] [server]
ip = '127.0.0.1'
port = 8090 port = 8090
domain = 'example.com' # ip = '127.0.0.1'
# domain = 'example.com'
[pocketbase] [pocketbase]
version = '0.35.1' version = '0.35.1'

4
go.mod
View File

@@ -4,7 +4,9 @@ go 1.23.1
require ( require (
github.com/charmbracelet/bubbletea v0.26.1 github.com/charmbracelet/bubbletea v0.26.1
github.com/mattn/go-runewidth v0.0.15
github.com/pelletier/go-toml/v2 v2.2.4 github.com/pelletier/go-toml/v2 v2.2.4
golang.org/x/term v0.19.0
) )
require ( require (
@@ -13,7 +15,6 @@ require (
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/reflow v0.3.0 // indirect
@@ -21,6 +22,5 @@ require (
github.com/rivo/uniseg v0.4.6 // indirect github.com/rivo/uniseg v0.4.6 // indirect
golang.org/x/sync v0.7.0 // indirect golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.19.0 // indirect golang.org/x/sys v0.19.0 // indirect
golang.org/x/term v0.19.0 // indirect
golang.org/x/text v0.3.8 // indirect golang.org/x/text v0.3.8 // indirect
) )

86
main.go
View File

@@ -250,10 +250,26 @@ func runInit() error {
return err return err
} }
if err := ensureGitignoreEntries(filepath.Join(cwd, ".gitignore"), []string{"pocketbase", "pb_data", ".env", ".DS_store"}); err != nil { if err := ensureGitignoreEntries(filepath.Join(cwd, ".gitignore"), []string{"pocketbase", "pb_data", ".env", ".DS_Store"}); err != nil {
return err return err
} }
for _, dir := range []string{"pb_public", "pb_migrations", "pb_hooks"} {
if err := os.MkdirAll(filepath.Join(cwd, dir), 0o755); err != nil {
return err
}
}
envPath := filepath.Join(cwd, ".env")
if _, err := os.Stat(envPath); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.WriteFile(envPath, []byte("# PocketBase env overrides\n"), 0o644); err != nil {
return err
}
}
fmt.Printf("Initialized PocketBase project %q\n", serviceName) fmt.Printf("Initialized PocketBase project %q\n", serviceName)
return nil return nil
} }
@@ -326,9 +342,9 @@ func init() {
func writePBConfig(path, serviceName string) error { func writePBConfig(path, serviceName string) error {
const tmpl = `[server] const tmpl = `[server]
ip = "127.0.0.1"
port = 8090 port = 8090
domain = "example.com" # ip = "127.0.0.1"
# domain = "example.com"
[pocketbase] [pocketbase]
version = "%s" version = "%s"
@@ -369,12 +385,16 @@ func resolveServiceName(defaultName string) (string, error) {
} }
func shouldConfirmServerConfig(ctx *deploymentContext) (bool, error) { func shouldConfirmServerConfig(ctx *deploymentContext) (bool, error) {
exists, err := remoteDirExists(ctx.serverIP, ctx.serviceDir) if strings.TrimSpace(ctx.serverIP) == "" {
if err != nil {
fmt.Fprintf(os.Stderr, "warning: unable to verify remote service at %s: %v\n", ctx.serviceDir, err)
return true, nil return true, nil
} }
return !exists, nil if strings.TrimSpace(ctx.domain) == "" {
return true, nil
}
if ctx.port <= 0 {
return true, nil
}
return false, nil
} }
func ensureServerConfigConfirmed(ctx *deploymentContext) (*deploymentContext, bool, error) { func ensureServerConfigConfirmed(ctx *deploymentContext) (*deploymentContext, bool, error) {
@@ -389,7 +409,7 @@ func ensureServerConfigConfirmed(ctx *deploymentContext) (*deploymentContext, bo
if err := confirmServerConfig(ctx.configPath); err != nil { if err := confirmServerConfig(ctx.configPath); err != nil {
return nil, false, err return nil, false, err
} }
newCtx, err := buildDeploymentContext() newCtx, err := buildDeploymentContext(true)
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
@@ -969,7 +989,7 @@ type deploymentContext struct {
configPath string configPath string
} }
func buildDeploymentContext() (*deploymentContext, error) { func buildDeploymentContext(requireServer bool) (*deploymentContext, error) {
cwd, err := os.Getwd() cwd, err := os.Getwd()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -987,17 +1007,17 @@ func buildDeploymentContext() (*deploymentContext, error) {
} }
serverIP := cfg.Server.IP serverIP := cfg.Server.IP
if serverIP == "" { if requireServer && serverIP == "" {
return nil, fmt.Errorf("pb.toml missing [server].ip") return nil, fmt.Errorf("pb.toml missing [server].ip")
} }
domain := cfg.Server.Domain domain := cfg.Server.Domain
if domain == "" { if requireServer && domain == "" {
return nil, fmt.Errorf("pb.toml missing [server].domain") return nil, fmt.Errorf("pb.toml missing [server].domain")
} }
port := cfg.Server.Port port := cfg.Server.Port
if port <= 0 { if requireServer && port <= 0 {
return nil, fmt.Errorf("pb.toml server.port must be greater than zero") return nil, fmt.Errorf("pb.toml server.port must be greater than zero")
} }
@@ -1034,7 +1054,7 @@ func buildDeploymentContext() (*deploymentContext, error) {
} }
func runSetup() error { func runSetup() error {
ctx, err := buildDeploymentContext() ctx, err := buildDeploymentContext(false)
if err != nil { if err != nil {
return err return err
} }
@@ -1113,14 +1133,22 @@ func performSetup(ctx *deploymentContext, restart bool) error {
} }
func runDeploy() error { func runDeploy() error {
ctx, err := buildDeploymentContext() ctx, err := buildDeploymentContext(false)
if err != nil { if err != nil {
return err return err
} }
start := time.Now()
ctx, prompted, err := ensureServerConfigConfirmed(ctx)
if err != nil {
return err
}
if prompted {
start = time.Now()
}
defer func() { defer func() {
closeSSHControlMaster(ctx.serverIP) closeSSHControlMaster(ctx.serverIP)
}() }()
start := time.Now()
binaryPath := filepath.Join(ctx.serviceDir, "pocketbase") binaryPath := filepath.Join(ctx.serviceDir, "pocketbase")
exists, err := remoteBinaryExists(ctx.serverIP, binaryPath) exists, err := remoteBinaryExists(ctx.serverIP, binaryPath)
@@ -1130,13 +1158,6 @@ func runDeploy() error {
if err != nil || !exists { if err != nil || !exists {
fmt.Println("PocketBase binary missing on remote; running setup") fmt.Println("PocketBase binary missing on remote; running setup")
ctx, prompted, err := ensureServerConfigConfirmed(ctx)
if err != nil {
return err
}
if prompted {
start = time.Now()
}
if err := performSetup(ctx, false); err != nil { if err := performSetup(ctx, false); err != nil {
return err return err
} }
@@ -1161,16 +1182,16 @@ func runDeploy() error {
} }
func runStatus() error { func runStatus() error {
ctx, err := buildDeploymentContext() ctx, err := buildDeploymentContext(true)
if err != nil { if err != nil {
return err return err
} }
defer closeSSHControlMaster(ctx.serverIP) defer closeSSHControlMaster(ctx.serverIP)
printInfo("Service", fmt.Sprintf("pb@%s", ctx.serviceName), remoteLineColor) printInfo("Service", fmt.Sprintf("pb@%s", ctx.serviceName), "")
printInfo("Server", ctx.serverIP, remoteLineColor) printInfo("Server", ctx.serverIP, "")
printInfo("Domain", ctx.domain, remoteLineColor) printInfo("Domain", ctx.domain, "")
printInfo("Port", fmt.Sprintf("%d", ctx.port), remoteLineColor) printInfo("Port", fmt.Sprintf("%d", ctx.port), "")
props, err := querySystemdProperties(ctx) props, err := querySystemdProperties(ctx)
if err != nil { if err != nil {
@@ -1188,7 +1209,7 @@ func runStatus() error {
printInfo("Status", statusLine, statusColorFor(state)) printInfo("Status", statusLine, statusColorFor(state))
if pid := strings.TrimSpace(props["ExecMainPID"]); pid != "" && pid != "0" { if pid := strings.TrimSpace(props["ExecMainPID"]); pid != "" && pid != "0" {
printInfo("PID", pid, remoteLineColor) printInfo("PID", pid, "")
} }
if memCurrent, ok := parseSystemdBytes(props["MemoryCurrent"]); ok { if memCurrent, ok := parseSystemdBytes(props["MemoryCurrent"]); ok {
@@ -1196,7 +1217,7 @@ func runStatus() error {
if memPeak, ok := parseSystemdBytes(props["MemoryPeak"]); ok { if memPeak, ok := parseSystemdBytes(props["MemoryPeak"]); ok {
memLine = fmt.Sprintf("%s (peak: %s)", memLine, formatBytes(memPeak)) memLine = fmt.Sprintf("%s (peak: %s)", memLine, formatBytes(memPeak))
} }
printInfo("Memory", memLine, remoteLineColor) printInfo("Memory", memLine, "")
} }
if started := strings.TrimSpace(props["ActiveEnterTimestamp"]); started != "" { if started := strings.TrimSpace(props["ActiveEnterTimestamp"]); started != "" {
@@ -1211,9 +1232,6 @@ func runStatus() error {
} }
func printInfo(label, value, valueColor string) { func printInfo(label, value, valueColor string) {
if valueColor == "" {
valueColor = remoteLineColor
}
fmt.Printf("%s%s:%s %s%s%s\n", headerColor, label, remoteColorReset, valueColor, value, remoteColorReset) fmt.Printf("%s%s:%s %s%s%s\n", headerColor, label, remoteColorReset, valueColor, value, remoteColorReset)
} }
@@ -1326,7 +1344,7 @@ func formatDuration(d time.Duration) string {
} }
func runLogs() error { func runLogs() error {
ctx, err := buildDeploymentContext() ctx, err := buildDeploymentContext(true)
if err != nil { if err != nil {
return err return err
} }
@@ -1349,7 +1367,7 @@ func runSecrets() error {
return fmt.Errorf("usage: pb secrets <list|set|delete> [arguments]") return fmt.Errorf("usage: pb secrets <list|set|delete> [arguments]")
} }
ctx, err := buildDeploymentContext() ctx, err := buildDeploymentContext(true)
if err != nil { if err != nil {
return err return err
} }

296
main_test.go Normal file
View File

@@ -0,0 +1,296 @@
package main
import (
"reflect"
"strings"
"testing"
)
func TestParseEnvLine(t *testing.T) {
t.Parallel()
tests := []struct {
name string
line string
wantKey string
wantValue string
wantOK bool
}{
{name: "simple", line: "FOO=bar", wantKey: "FOO", wantValue: "bar", wantOK: true},
{name: "trims", line: " FOO = bar ", wantKey: "FOO", wantValue: "bar", wantOK: true},
{name: "comment", line: "# comment", wantOK: false},
{name: "empty", line: "", wantOK: false},
{name: "no equals", line: "FOO", wantOK: false},
{name: "invalid key digit", line: "1FOO=bar", wantOK: false},
{name: "invalid key dash", line: "FOO-BAR=1", wantOK: false},
{name: "underscore ok", line: "_FOO=bar", wantKey: "_FOO", wantValue: "bar", wantOK: true},
{name: "digit ok after", line: "FOO1=2", wantKey: "FOO1", wantValue: "2", wantOK: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
key, value, ok := parseEnvLine(tt.line)
if ok != tt.wantOK {
t.Fatalf("ok=%v want %v", ok, tt.wantOK)
}
if key != tt.wantKey || value != tt.wantValue {
t.Fatalf("got key=%q value=%q want key=%q value=%q", key, value, tt.wantKey, tt.wantValue)
}
})
}
}
func TestIsEnvKey(t *testing.T) {
t.Parallel()
tests := []struct {
key string
want bool
}{
{key: "A", want: true},
{key: "_A", want: true},
{key: "A1", want: true},
{key: "A_B2", want: true},
{key: "", want: false},
{key: "1A", want: false},
{key: "A-B", want: false},
{key: "A B", want: false},
{key: "A.B", want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.key, func(t *testing.T) {
if got := isEnvKey(tt.key); got != tt.want {
t.Fatalf("got %v want %v", got, tt.want)
}
})
}
}
func TestIsEnvLine(t *testing.T) {
t.Parallel()
tests := []struct {
line string
want bool
}{
{line: "", want: true},
{line: " ", want: true},
{line: "FOO=bar", want: true},
{line: "FOO = bar", want: false},
{line: "1FOO=bar", want: false},
{line: "NO_EQUALS", want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.line, func(t *testing.T) {
if got := isEnvLine(tt.line); got != tt.want {
t.Fatalf("got %v want %v", got, tt.want)
}
})
}
}
func TestApplyEnvAssignments(t *testing.T) {
t.Parallel()
lines := []string{
"# comment",
"FOO=old",
"BAR=keep",
"INVALID LINE",
"BAZ=gone",
"FOO=duplicate",
}
assignments := []envAssignment{
{key: "FOO", value: "new"},
{key: "BAZ", value: "zzz"},
{key: "NEWKEY", value: "123"},
}
want := []string{
"# comment",
"FOO=new",
"BAR=keep",
"INVALID LINE",
"BAZ=zzz",
"NEWKEY=123",
}
got := applyEnvAssignments(lines, assignments)
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v want %#v", got, want)
}
}
func TestRemoveEnvKeys(t *testing.T) {
t.Parallel()
lines := []string{
"KEEP=1",
"DROP=2",
"# comment",
"INVALID LINE",
"DROP=3",
"ALSO=4",
}
keys := []string{"DROP", "MISSING"}
want := []string{
"KEEP=1",
"# comment",
"INVALID LINE",
"ALSO=4",
}
got := removeEnvKeys(lines, keys)
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v want %#v", got, want)
}
}
func TestShellQuote(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
}{
{name: "empty", input: "", want: "''"},
{name: "simple", input: "abc", want: "'abc'"},
{name: "spaces", input: "a b", want: "'a b'"},
{name: "single quote", input: "a'b", want: "'a'\"'\"'b'"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
if got := shellQuote(tt.input); got != tt.want {
t.Fatalf("got %q want %q", got, tt.want)
}
})
}
}
func TestResolveVolumePath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
template string
baseDir string
service string
wantResult string
}{
{name: "empty uses base", template: "", baseDir: "/srv/pb", service: "svc", wantResult: "/srv/pb"},
{name: "absolute path", template: "/data/{service}", baseDir: "/srv/pb", service: "svc", wantResult: "/data/svc"},
{name: "relative path", template: "data/{service}", baseDir: "/srv/pb", service: "svc", wantResult: "/srv/pb/data/svc"},
{name: "simple relative", template: "custom", baseDir: "/srv/pb", service: "svc", wantResult: "/srv/pb/custom"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
if got := resolveVolumePath(tt.template, tt.baseDir, tt.service); got != tt.wantResult {
t.Fatalf("got %q want %q", got, tt.wantResult)
}
})
}
}
func TestTranslateMachineArch(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
wantErr bool
}{
{name: "x86_64", input: " x86_64 ", want: "amd64"},
{name: "amd64", input: "amd64", want: "amd64"},
{name: "i686", input: "i686", want: "386"},
{name: "armv7l", input: "armv7l", want: "armv7"},
{name: "armv6l", input: "armv6l", want: "arm"},
{name: "aarch64", input: "aarch64", want: "arm64"},
{name: "arm64", input: "arm64", want: "arm64"},
{name: "unknown", input: "mips", wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
got, err := translateMachineArch(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("got %q want %q", got, tt.want)
}
})
}
}
func TestSystemdScript(t *testing.T) {
t.Parallel()
serviceDir := "/srv/pb"
envFile := "/etc/pb/env"
serviceName := "demo"
script := systemdScript(serviceDir, envFile, serviceName)
required := []string{
"/etc/systemd/system/pb@.service",
"StandardOutput = append:" + serviceDir + "/%i.log",
"StandardError = append:" + serviceDir + "/%i.log",
"WorkingDirectory = " + serviceDir,
"EnvironmentFile = " + envFile,
"ExecStart = " + serviceDir + "/pocketbase serve --http=127.0.0.1:${PORT} --hooksWatch=false --dir=${DATA_DIR} --hooksDir=" + serviceDir + "/pb_hooks --migrationsDir=" + serviceDir + "/pb_migrations --publicDir=" + serviceDir + "/pb_public",
"systemctl --no-block enable pb@" + serviceName,
}
for _, snippet := range required {
if !strings.Contains(script, snippet) {
t.Fatalf("script missing %q", snippet)
}
}
}
func TestSystemdOverrideScript(t *testing.T) {
t.Parallel()
serviceName := "demo"
port := 8090
volume := "/srv/pb/data"
script := systemdOverrideScript(serviceName, port, volume)
required := []string{
"/etc/systemd/system/pb@" + serviceName + ".service.d",
"Environment=PORT=8090",
"Environment=DATA_DIR=" + volume,
}
for _, snippet := range required {
if !strings.Contains(script, snippet) {
t.Fatalf("script missing %q", snippet)
}
}
}
func TestSystemdRestartScript(t *testing.T) {
t.Parallel()
serviceName := "demo"
script := systemdRestartScript(serviceName)
expected := "systemctl --no-block restart pb@" + serviceName
if !strings.Contains(script, expected) {
t.Fatalf("script missing %q", expected)
}
}