From 7e1baaa2f180a62a4c1eda63f6586c743ee3d04a Mon Sep 17 00:00:00 2001 From: Nick Goodall Date: Tue, 13 Jan 2026 14:06:25 +0000 Subject: [PATCH] secrets command --- README.md | 6 ++ main.go | 296 +++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 278 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 1b3a577..2986859 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/main.go b/main.go index 9d3b0a9..2b33e79 100644 --- a/main.go +++ b/main.go @@ -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 [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 "''"