secrets command

This commit is contained in:
2026-01-13 14:06:25 +00:00
parent 5b1a65849b
commit 7e1baaa2f1
2 changed files with 278 additions and 24 deletions

View File

@@ -30,3 +30,9 @@ Syncs `pb_public`, `pb_migrations`, and `pb_hooks`, then restarts the remote Poc
Connects to the configured server and streams `/root/pb/{service}/{service}.log` via `tail -n 100 -F`.
### secrets
Manage the remote `/root/pb/{service}/.env` file.
- `pb secrets list` prints every variable name defined in the remote `.env` (comments and empty lines are ignored).
- `pb secrets set KEY=VALUE [...]` adds or updates one or more key/value pairs while leaving the other file entries untouched.
- `pb secrets delete KEY [...]` removes the named entries from the remote `.env`.

296
main.go
View File

@@ -5,6 +5,7 @@ import (
"bufio"
"bytes"
"crypto/sha1"
"encoding/base64"
"errors"
"fmt"
"io"
@@ -24,7 +25,10 @@ import (
"github.com/pelletier/go-toml/v2"
)
var initServiceNameArg string
var (
initServiceNameArg string
invocationArgs []string
)
func main() {
commands := defaultCommands()
@@ -33,6 +37,8 @@ func main() {
args = args[1:]
}
invocationArgs = args
if len(args) == 0 {
printUsage(commands)
return
@@ -67,7 +73,7 @@ func defaultCommands() []command {
{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")},
{name: "secrets", description: "manage deployment secrets", action: runSecrets},
}
}
@@ -202,13 +208,6 @@ func runCommand(cmd command) tea.Cmd {
}
}
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 {
@@ -285,6 +284,8 @@ const defaultPocketbaseVersion = "0.35.1"
func init() {
rand.Seed(time.Now().UnixNano())
log.SetFlags(0)
log.SetPrefix("")
}
func writePBConfig(path, serviceName string) error {
@@ -555,17 +556,9 @@ func loadEnv(path string) (map[string]string, error) {
env := make(map[string]string)
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
if key, value, ok := parseEnvLine(line); ok {
env[key] = value
}
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
}
@@ -808,6 +801,226 @@ tail -n 25 -F "$log"
return runSSHRawCommand(ctx.serverIP, script)
}
func runSecrets() error {
if len(invocationArgs) < 2 {
return fmt.Errorf("usage: pb secrets <list|set|delete> [arguments]")
}
ctx, err := buildDeploymentContext()
if err != nil {
return err
}
defer closeSSHControlMaster(ctx.serverIP)
subcommand := invocationArgs[1]
args := invocationArgs[2:]
switch subcommand {
case "list":
if len(args) != 0 {
return fmt.Errorf("usage: pb secrets list")
}
return runSecretsList(ctx)
case "set":
if len(args) == 0 {
return fmt.Errorf("usage: pb secrets set KEY=VALUE...")
}
return runSecretsSet(ctx, args)
case "delete":
if len(args) == 0 {
return fmt.Errorf("usage: pb secrets delete KEY...")
}
return runSecretsDelete(ctx, args)
default:
return fmt.Errorf("unknown secrets subcommand %q", subcommand)
}
}
func runSecretsList(ctx *deploymentContext) error {
lines, err := loadRemoteEnvLines(ctx)
if err != nil {
return err
}
var keys []string
for _, line := range lines {
if key, _, ok := parseEnvLine(line); ok {
keys = append(keys, key)
}
}
if len(keys) == 0 {
fmt.Println("no secrets found")
return nil
}
sort.Strings(keys)
for _, key := range keys {
fmt.Println(key)
}
return nil
}
type envAssignment struct {
key string
value string
}
func runSecretsSet(ctx *deploymentContext, pairs []string) error {
var assignments []envAssignment
for _, pair := range pairs {
idx := strings.Index(pair, "=")
if idx <= 0 {
return fmt.Errorf("invalid assignment %q, expected KEY=VALUE", pair)
}
key := strings.TrimSpace(pair[:idx])
if key == "" {
return fmt.Errorf("invalid assignment %q: empty key", pair)
}
if !isEnvKey(key) {
return fmt.Errorf("invalid env key %q", key)
}
assignments = append(assignments, envAssignment{key: key, value: pair[idx+1:]})
}
lines, err := loadRemoteEnvLines(ctx)
if err != nil {
return err
}
updated := applyEnvAssignments(lines, assignments)
if err := writeRemoteEnvLines(ctx, updated); err != nil {
return err
}
fmt.Printf("updated %d secrets\n", len(assignments))
return nil
}
func runSecretsDelete(ctx *deploymentContext, keys []string) error {
var normalized []string
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
return fmt.Errorf("invalid empty key")
}
if !isEnvKey(key) {
return fmt.Errorf("invalid env key %q", key)
}
normalized = append(normalized, key)
}
lines, err := loadRemoteEnvLines(ctx)
if err != nil {
return err
}
updated := removeEnvKeys(lines, normalized)
if err := writeRemoteEnvLines(ctx, updated); err != nil {
return err
}
fmt.Printf("removed %d secrets\n", len(normalized))
return nil
}
func loadRemoteEnvLines(ctx *deploymentContext) ([]string, error) {
data, err := readRemoteEnvFile(ctx)
if err != nil {
return nil, err
}
data = strings.ReplaceAll(data, "\r\n", "\n")
data = strings.TrimRightFunc(data, func(r rune) bool {
return r == '\n' || r == '\r'
})
if data == "" {
return nil, nil
}
return strings.Split(data, "\n"), nil
}
func readRemoteEnvFile(ctx *deploymentContext) (string, error) {
script := fmt.Sprintf(`set -euo pipefail
if [ -f %s ]; then
cat %s
fi
`, shellQuote(ctx.envFile), shellQuote(ctx.envFile))
return runSSHCollect(ctx.serverIP, script)
}
func writeRemoteEnvLines(ctx *deploymentContext, lines []string) error {
content := strings.Join(lines, "\n")
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
dir := filepath.Dir(ctx.envFile)
encoded := base64.StdEncoding.EncodeToString([]byte(content))
script := fmt.Sprintf(`set -euo pipefail
mkdir -p %s
cat <<'EOF' | base64 -d > %s
%s
EOF
`, shellQuote(dir), shellQuote(ctx.envFile), encoded)
return runSSHCommand(ctx.serverIP, script)
}
func applyEnvAssignments(lines []string, assignments []envAssignment) []string {
if len(assignments) == 0 {
return lines
}
assignMap := make(map[string]string, len(assignments))
order := make([]string, 0, len(assignments))
for _, assign := range assignments {
if _, seen := assignMap[assign.key]; !seen {
order = append(order, assign.key)
}
assignMap[assign.key] = assign.value
}
inserted := make(map[string]bool, len(assignMap))
var out []string
for _, line := range lines {
if key, _, ok := parseEnvLine(line); ok {
if value, exists := assignMap[key]; exists {
if !inserted[key] {
out = append(out, fmt.Sprintf("%s=%s", key, value))
inserted[key] = true
}
continue
}
}
out = append(out, line)
}
for _, key := range order {
if inserted[key] {
continue
}
out = append(out, fmt.Sprintf("%s=%s", key, assignMap[key]))
inserted[key] = true
}
return out
}
func removeEnvKeys(lines []string, keys []string) []string {
if len(keys) == 0 {
return lines
}
targets := make(map[string]struct{}, len(keys))
for _, key := range keys {
targets[key] = struct{}{}
}
var out []string
for _, line := range lines {
if key, _, ok := parseEnvLine(line); ok {
if _, remove := targets[key]; remove {
continue
}
}
out = append(out, line)
}
return out
}
func remoteBinaryExists(server, path string) (bool, error) {
script := fmt.Sprintf(`if [ -f %q ]; then printf yes; else printf no; fi`, path)
output, err := runSSHOutput(server, script)
@@ -1040,6 +1253,18 @@ func runSSHOutput(server, script string) (string, error) {
return strings.TrimSpace(filtered), nil
}
func runSSHCollect(server, script string) (string, error) {
remoteCmd := fmt.Sprintf("bash --noprofile --norc -c %s", shellQuote(script))
cmd := exec.Command("ssh", append(sshArgs(server), remoteCmd)...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", err
}
return out.String(), nil
}
func syncLocalDirectories(server, remoteBase string, dirs []string) error {
var toSync []string
for _, dir := range dirs {
@@ -1197,16 +1422,27 @@ func stripLeadingEnvLines(input string) string {
return builder.String()
}
func isEnvLine(line string) bool {
func parseEnvLine(line string) (string, string, bool) {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return true
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
return "", "", false
}
idx := strings.IndexRune(trimmed, '=')
idx := strings.Index(trimmed, "=")
if idx <= 0 {
return "", "", false
}
key := strings.TrimSpace(trimmed[:idx])
if !isEnvKey(key) {
return "", "", false
}
value := strings.TrimSpace(trimmed[idx+1:])
return key, value, true
}
func isEnvKey(key string) bool {
if key == "" {
return false
}
key := trimmed[:idx]
for i, r := range key {
if i == 0 {
if r != '_' && !unicode.IsLetter(r) {
@@ -1221,6 +1457,18 @@ func isEnvLine(line string) bool {
return true
}
func isEnvLine(line string) bool {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return true
}
idx := strings.IndexRune(trimmed, '=')
if idx <= 0 {
return false
}
return isEnvKey(trimmed[:idx])
}
func shellQuote(value string) string {
if value == "" {
return "''"