From 61a31ca6fac073aa28aefd7c33be6d4846452bb5 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Tue, 3 Feb 2026 00:14:12 +0100 Subject: [PATCH 1/2] Add --upgrade-config-json and auto-config upgrade Introduce a machine-readable config-upgrade mode (--upgrade-config-json) and wire automatic configuration upgrades into the binary upgrade flow. The upgrade command now attempts to run the newly installed binary to merge template changes into backup.env (adding missing keys while preserving existing values). Implemented robust parsing and rendering for multi-line (block) env values, refactored value parsing into helpers (parseEnvValues, splitKeyValueRaw, renderEnvValue, findClosingQuoteLine) and updated computeConfigUpgrade to preserve block entries and multiple values per key. Added tests covering block preservation and missing-block detection. printUpgradeFooter now reports detailed config-upgrade results or errors, and installer/help text was updated to reflect the behavior change. Error handling for the JSON mode and subprocess invocation was added to ensure clear diagnostics. --- cmd/proxsave/install.go | 2 +- cmd/proxsave/main.go | 34 ++++++- cmd/proxsave/upgrade.go | 90 +++++++++++++++-- internal/cli/args.go | 6 +- internal/config/upgrade.go | 149 +++++++++++++++++++++++++---- internal/config/upgrade_test.go | 79 +++++++++++++++ internal/orchestrator/.backup.lock | 4 +- 7 files changed, 328 insertions(+), 36 deletions(-) diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go index 675fecd..d52e6dd 100644 --- a/cmd/proxsave/install.go +++ b/cmd/proxsave/install.go @@ -223,7 +223,7 @@ func printInstallFooter(installErr error, configPath, baseDir, telegramCode, per fmt.Println(" --dry-run - Test without changes") fmt.Println(" --install - Re-run interactive installation/setup") fmt.Println(" --new-install - Wipe installation directory (keep env/identity) then run installer") - fmt.Println(" --upgrade - Update proxsave binary to latest release (no config changes)") + fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)") fmt.Println(" --newkey - Generate a new encryption key for backups") fmt.Println(" --decrypt - Decrypt an existing backup archive") fmt.Println(" --restore - Run interactive restore workflow (select bundle, decrypt if needed, apply to system)") diff --git a/cmd/proxsave/main.go b/cmd/proxsave/main.go index d4184a0..d7e9c34 100644 --- a/cmd/proxsave/main.go +++ b/cmd/proxsave/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -147,8 +148,8 @@ func run() int { if args.EnvMigration || args.EnvMigrationDry { incompatible = append(incompatible, "--env-migration/--env-migration-dry-run") } - if args.UpgradeConfig || args.UpgradeConfigDry { - incompatible = append(incompatible, "--upgrade-config/--upgrade-config-dry-run") + if args.UpgradeConfig || args.UpgradeConfigDry || args.UpgradeConfigJSON { + incompatible = append(incompatible, "--upgrade-config/--upgrade-config-dry-run/--upgrade-config-json") } if len(incompatible) > 0 { @@ -188,7 +189,7 @@ func run() int { if args.EnvMigration || args.EnvMigrationDry { incompatible = append(incompatible, "--env-migration") } - if args.UpgradeConfig || args.UpgradeConfigDry { + if args.UpgradeConfig || args.UpgradeConfigDry || args.UpgradeConfigJSON { incompatible = append(incompatible, "--upgrade-config") } if args.ForceNewKey { @@ -223,7 +224,30 @@ func run() int { } args.ConfigPath = resolvedConfigPath - // Dedicated upgrade mode (download latest binary, no config changes) + if args.UpgradeConfigJSON { + if _, err := os.Stat(args.ConfigPath); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: configuration file not found: %v\n", err) + return types.ExitConfigError.Int() + } + + result, err := config.UpgradeConfigFile(args.ConfigPath) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: Failed to upgrade configuration: %v\n", err) + return types.ExitConfigError.Int() + } + if result == nil { + result = &config.UpgradeResult{} + } + + enc := json.NewEncoder(os.Stdout) + if err := enc.Encode(result); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: Failed to encode JSON: %v\n", err) + return types.ExitGenericError.Int() + } + return types.ExitSuccess.Int() + } + + // Dedicated upgrade mode (download latest binary and upgrade config keys) if args.Upgrade { logging.DebugStepBootstrap(bootstrap, "main run", "mode=upgrade") return runUpgrade(ctx, args, bootstrap) @@ -1582,7 +1606,7 @@ func printFinalSummary(finalExitCode int) { fmt.Println(" --new-install - Wipe installation directory (keep env/identity) then run installer") fmt.Println(" --env-migration - Run installer and migrate legacy Bash backup.env to Go template") fmt.Println(" --env-migration-dry-run - Preview installer/migration without writing files") - fmt.Println(" --upgrade - Update proxsave binary to latest release (no config changes)") + fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)") fmt.Println(" --newkey - Generate a new encryption key for backups") fmt.Println(" --decrypt - Decrypt an existing backup archive") fmt.Println(" --restore - Run interactive restore workflow (select bundle, decrypt if needed, apply to system)") diff --git a/cmd/proxsave/upgrade.go b/cmd/proxsave/upgrade.go index bc24874..757b650 100644 --- a/cmd/proxsave/upgrade.go +++ b/cmd/proxsave/upgrade.go @@ -14,6 +14,7 @@ import ( "io" "net/http" "os" + "os/exec" "path/filepath" "runtime" "strconv" @@ -38,7 +39,7 @@ type releaseInfo struct { // runUpgrade orchestrates the upgrade flow: // - downloads and installs the latest binary release -// - keeps the existing backup.env untouched +// - upgrades backup.env by adding missing keys from the new template (preserving existing values) // - refreshes symlinks/cron/docs and normalizes permissions/ownership func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger) int { baseDir := filepath.Dir(filepath.Dir(args.ConfigPath)) @@ -147,7 +148,17 @@ func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.Bootstra workflowErr = upgradeErr } - // Refresh docs/symlinks/cron/identity without touching backup.env + var cfgUpgradeResult *config.UpgradeResult + var cfgUpgradeErr error + if upgradeErr == nil { + logging.DebugStepBootstrap(bootstrap, "upgrade workflow", "upgrading configuration with newly installed binary") + cfgUpgradeResult, cfgUpgradeErr = upgradeConfigWithBinary(ctx, execPath, args.ConfigPath) + if cfgUpgradeErr != nil { + bootstrap.Warning("Upgrade: configuration upgrade failed: %v", cfgUpgradeErr) + } + } + + // Refresh docs/symlinks/cron/identity (configuration upgrade is handled separately) logging.DebugStepBootstrap(bootstrap, "upgrade workflow", "refreshing docs and symlinks") if err := installSupportDocs(baseDir, bootstrap); err != nil { bootstrap.Warning("Upgrade: failed to refresh documentation: %v", err) @@ -174,7 +185,7 @@ func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.Bootstra logging.DebugStepBootstrap(bootstrap, "upgrade workflow", "normalizing permissions") permStatus, permMessage := fixPermissionsAfterInstall(ctx, args.ConfigPath, baseDir, bootstrap) - printUpgradeFooter(upgradeErr, versionInstalled, args.ConfigPath, baseDir, telegramCode, permStatus, permMessage) + printUpgradeFooter(upgradeErr, versionInstalled, args.ConfigPath, baseDir, telegramCode, permStatus, permMessage, cfgUpgradeResult, cfgUpgradeErr) if upgradeErr != nil { return types.ExitGenericError.Int() @@ -512,7 +523,7 @@ func installBinary(srcPath, destPath string, bootstrap *logging.BootstrapLogger) return nil } -func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegramCode, permStatus, permMessage string) { +func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegramCode, permStatus, permMessage string, cfgUpgradeResult *config.UpgradeResult, cfgUpgradeErr error) { colorReset := "\033[0m" title := "Go-based upgrade completed" @@ -549,11 +560,38 @@ func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegram fmt.Println() } + if cfgUpgradeErr != nil { + fmt.Printf("Configuration: ERROR - failed to upgrade %s\n", configPath) + fmt.Printf(" Details: %v\n", cfgUpgradeErr) + fmt.Println(" Action: Review the configuration file and run: proxsave --upgrade-config") + fmt.Println() + } else if cfgUpgradeResult != nil { + if cfgUpgradeResult.Changed { + if len(cfgUpgradeResult.MissingKeys) > 0 { + fmt.Printf("Configuration: updated (added %d missing key(s))\n", len(cfgUpgradeResult.MissingKeys)) + fmt.Printf(" Added keys: %s\n", strings.Join(cfgUpgradeResult.MissingKeys, ", ")) + fmt.Println(" Action: Review these keys in backup.env and adjust values as needed.") + } else { + fmt.Println("Configuration: updated (no new keys were required)") + if len(cfgUpgradeResult.ExtraKeys) > 0 { + fmt.Printf(" Preserved %d custom key(s) not present in the template.\n", len(cfgUpgradeResult.ExtraKeys)) + } + } + if cfgUpgradeResult.BackupPath != "" { + fmt.Printf(" Backup saved to: %s\n", cfgUpgradeResult.BackupPath) + } + fmt.Println() + } else { + fmt.Println("Configuration: already up to date with the latest template (no changes).") + fmt.Println() + } + } + fmt.Println("Next steps:") if strings.TrimSpace(configPath) != "" { - fmt.Printf("1. Verify configuration (unchanged): %s\n", configPath) + fmt.Printf("1. Verify configuration: %s\n", configPath) } else { - fmt.Println("1. Verify configuration (unchanged)") + fmt.Println("1. Verify configuration") } if strings.TrimSpace(baseDir) != "" { fmt.Println("2. Run backup: proxsave") @@ -569,7 +607,7 @@ func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegram fmt.Println("Commands:") fmt.Println(" proxsave (alias: proxmox-backup) - Start backup") - fmt.Println(" --upgrade - Update proxsave binary to latest release (no config changes)") + fmt.Println(" --upgrade - Update proxsave binary to latest release (also adds missing keys to backup.env)") fmt.Println(" --install - Re-run interactive installation/setup") fmt.Println(" --new-install - Wipe installation directory (keep env/identity) then run installer") fmt.Println(" --upgrade-config - Upgrade configuration file using the embedded template (run after installing a new binary)") @@ -579,3 +617,41 @@ func printUpgradeFooter(upgradeErr error, version, configPath, baseDir, telegram fmt.Println("Upgrade reported an error; please review the log above.") } } + +func upgradeConfigWithBinary(ctx context.Context, execPath, configPath string) (*config.UpgradeResult, error) { + execPath = strings.TrimSpace(execPath) + configPath = strings.TrimSpace(configPath) + if execPath == "" { + return nil, fmt.Errorf("exec path is empty") + } + if configPath == "" { + return nil, fmt.Errorf("configuration path is empty") + } + + cmd := exec.CommandContext(ctx, execPath, "--config", configPath, "--upgrade-config-json") + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + details := strings.TrimSpace(stderr.String()) + if details == "" { + details = strings.TrimSpace(stdout.String()) + } + if details != "" { + return nil, fmt.Errorf("upgrade-config-json failed: %w: %s", err, details) + } + return nil, fmt.Errorf("upgrade-config-json failed: %w", err) + } + + var result config.UpgradeResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + preview := strings.TrimSpace(stdout.String()) + if len(preview) > 4000 { + preview = preview[:4000] + "…" + } + return nil, fmt.Errorf("invalid JSON from upgrade-config-json: %w (stdout=%q)", err, preview) + } + return &result, nil +} diff --git a/internal/cli/args.go b/internal/cli/args.go index 33222bb..8c182ed 100644 --- a/internal/cli/args.go +++ b/internal/cli/args.go @@ -37,6 +37,7 @@ type Args struct { NewInstall bool UpgradeConfig bool UpgradeConfigDry bool + UpgradeConfigJSON bool EnvMigration bool EnvMigrationDry bool CleanupGuards bool @@ -95,7 +96,7 @@ func Parse() *Args { flag.BoolVar(&args.NewInstall, "new-install", false, "Reset the installation directory (preserving env/identity) and launch the interactive installer") flag.BoolVar(&args.Upgrade, "upgrade", false, - "Download and install the latest ProxSave binary (without modifying backup.env)") + "Download and install the latest ProxSave binary (also upgrades backup.env by adding missing keys from the new template)") flag.BoolVar(&args.EnvMigration, "env-migration", false, "Run the installer and migrate a legacy Bash backup.env to the Go template") flag.BoolVar(&args.EnvMigrationDry, "env-migration-dry-run", false, @@ -111,6 +112,9 @@ func Parse() *Args { flag.BoolVar(&args.UpgradeConfigDry, "upgrade-config-dry-run", false, "Plan configuration upgrade using the embedded template without modifying the file (reports missing and custom keys)") + flag.BoolVar(&args.UpgradeConfigJSON, "upgrade-config-json", false, + "Upgrade configuration file using the embedded template and print JSON summary to stdout (for internal use by --upgrade)") + // Custom usage message flag.Usage = func() { printHelp(os.Stderr, os.Args[0]) diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go index 6284499..3fb314d 100644 --- a/internal/config/upgrade.go +++ b/internal/config/upgrade.go @@ -9,6 +9,19 @@ import ( "github.com/tis24dev/proxsave/pkg/utils" ) +type envValueKind int + +const ( + envValueKindLine envValueKind = iota + envValueKindBlock +) + +type envValue struct { + kind envValueKind + rawValue string + blockLines []string +} + // UpgradeResult describes the outcome of a configuration upgrade. type UpgradeResult struct { // BackupPath is the path of the backup created from the previous config. @@ -142,21 +155,9 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er originalLines := strings.Split(normalizedOriginal, "\n") // 1. Collect user values: for each KEY we store all VALUE entries in order. - userValues := make(map[string][]string) - userKeyOrder := make([]string, 0) - - for _, line := range originalLines { - if utils.IsComment(line) { - continue - } - key, value, ok := utils.SplitKeyValue(line) - if !ok || key == "" { - continue - } - if _, seen := userValues[key]; !seen { - userKeyOrder = append(userKeyOrder, key) - } - userValues[key] = append(userValues[key], value) + userValues, userKeyOrder, err := parseEnvValues(originalLines) + if err != nil { + return result, "", originalContent, err } // 2. Walk the template line-by-line, merging values. @@ -168,14 +169,15 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er missingKeys := make([]string, 0) newLines := make([]string, 0, len(templateLines)+len(userValues)) - for _, line := range templateLines { + for i := 0; i < len(templateLines); i++ { + line := templateLines[i] trimmed := strings.TrimSpace(line) if utils.IsComment(trimmed) { newLines = append(newLines, line) continue } - key, _, ok := utils.SplitKeyValue(line) + key, _, ok := splitKeyValueRaw(line) if !ok || key == "" { newLines = append(newLines, line) continue @@ -183,10 +185,28 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er templateKeys[key] = true + if blockValueKeys[key] && trimmed == fmt.Sprintf("%s=\"", key) { + blockEnd, err := findClosingQuoteLine(templateLines, i+1) + if err != nil { + return result, "", originalContent, fmt.Errorf("template %s block invalid: %w", key, err) + } + + if values, ok := userValues[key]; ok && len(values) > 0 { + for _, v := range values { + newLines = append(newLines, renderEnvValue(key, v)...) + } + } else { + missingKeys = append(missingKeys, key) + newLines = append(newLines, templateLines[i:blockEnd+1]...) + } + + i = blockEnd + continue + } + if values, ok := userValues[key]; ok && len(values) > 0 { - // Preserve all user-defined values for this key. for _, v := range values { - newLines = append(newLines, fmt.Sprintf("%s=%s", key, v)) + newLines = append(newLines, renderEnvValue(key, v)...) } } else { // Key missing in user config: keep template default and record it. @@ -209,7 +229,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er } extraKeys = append(extraKeys, key) for _, v := range values { - extraLines = append(extraLines, fmt.Sprintf("%s=%s", key, v)) + extraLines = append(extraLines, renderEnvValue(key, v)...) } } @@ -250,3 +270,92 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er result.Changed = true return result, newContent, originalContent, nil } + +func parseEnvValues(lines []string) (map[string][]envValue, []string, error) { + userValues := make(map[string][]envValue) + userKeyOrder := make([]string, 0) + + for i := 0; i < len(lines); i++ { + line := lines[i] + trimmed := strings.TrimSpace(line) + if utils.IsComment(trimmed) { + continue + } + + key, rawValue, ok := splitKeyValueRaw(line) + if !ok || key == "" { + continue + } + + if blockValueKeys[key] && trimmed == fmt.Sprintf("%s=\"", key) { + blockLines := make([]string, 0) + blockEnd, err := findClosingQuoteLine(lines, i+1) + if err != nil { + return nil, nil, fmt.Errorf("unterminated multi-line value for %s", key) + } + blockLines = append(blockLines, lines[i+1:blockEnd]...) + + if _, seen := userValues[key]; !seen { + userKeyOrder = append(userKeyOrder, key) + } + userValues[key] = append(userValues[key], envValue{kind: envValueKindBlock, blockLines: blockLines}) + + i = blockEnd + continue + } + + if _, seen := userValues[key]; !seen { + userKeyOrder = append(userKeyOrder, key) + } + userValues[key] = append(userValues[key], envValue{kind: envValueKindLine, rawValue: rawValue}) + } + + return userValues, userKeyOrder, nil +} + +func splitKeyValueRaw(line string) (string, string, bool) { + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + return "", "", false + } + + key := strings.TrimSpace(parts[0]) + valuePart := strings.TrimSpace(parts[1]) + + // Remove inline comments (but respect quotes) + value := valuePart + if strings.HasPrefix(valuePart, "\"") || strings.HasPrefix(valuePart, "'") { + quote := valuePart[0] + endIdx := strings.IndexByte(valuePart[1:], quote) + if endIdx >= 0 { + value = valuePart[:endIdx+2] + } + return key, value, true + } + + // Not quoted, remove everything after # + if idx := strings.Index(valuePart, "#"); idx >= 0 { + value = strings.TrimSpace(valuePart[:idx]) + } + + return key, value, true +} + +func findClosingQuoteLine(lines []string, start int) (int, error) { + for i := start; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "\"" { + return i, nil + } + } + return 0, fmt.Errorf("closing quote not found") +} + +func renderEnvValue(key string, value envValue) []string { + if value.kind == envValueKindBlock { + lines := []string{fmt.Sprintf("%s=\"", key)} + lines = append(lines, value.blockLines...) + lines = append(lines, "\"") + return lines + } + return []string{fmt.Sprintf("%s=%s", key, value.rawValue)} +} diff --git a/internal/config/upgrade_test.go b/internal/config/upgrade_test.go index 1f53b2d..5580bb9 100644 --- a/internal/config/upgrade_test.go +++ b/internal/config/upgrade_test.go @@ -154,3 +154,82 @@ KEY3=default3 } }) } + +func TestUpgradeConfigPreservesBlockValues(t *testing.T) { + template := `BACKUP_PATH=/default/backup +LOG_PATH=/default/log +CUSTOM_BACKUP_PATHS=" +# /template/example +" +` + withTemplate(t, template, func() { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "backup.env") + userConfig := `BACKUP_PATH=/legacy/backup +CUSTOM_BACKUP_PATHS=" +/etc/custom.conf +" +` + if err := os.WriteFile(configPath, []byte(userConfig), 0600); err != nil { + t.Fatalf("failed to seed config: %v", err) + } + + result, err := UpgradeConfigFile(configPath) + if err != nil { + t.Fatalf("UpgradeConfigFile returned error: %v", err) + } + if !result.Changed { + t.Fatal("expected result.Changed=true when template has missing keys") + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read upgraded config: %v", err) + } + content := string(data) + if !strings.Contains(content, "BACKUP_PATH=/legacy/backup") { + t.Fatalf("upgraded config missing preserved BACKUP_PATH:\n%s", content) + } + if !strings.Contains(content, "CUSTOM_BACKUP_PATHS=\"\n/etc/custom.conf\n\"\n") && + !strings.Contains(content, "CUSTOM_BACKUP_PATHS=\"\r\n/etc/custom.conf\r\n\"\r\n") { + t.Fatalf("upgraded config missing preserved CUSTOM_BACKUP_PATHS block:\n%s", content) + } + if strings.Contains(content, "# /template/example") { + t.Fatalf("template example unexpectedly present in preserved block:\n%s", content) + } + }) +} + +func TestPlanUpgradeConfigTracksMissingBlockKey(t *testing.T) { + template := `BACKUP_PATH=/default/backup +CUSTOM_BACKUP_PATHS=" +# /template/example +" +` + withTemplate(t, template, func() { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "backup.env") + userConfig := "BACKUP_PATH=/legacy/backup\n" + if err := os.WriteFile(configPath, []byte(userConfig), 0600); err != nil { + t.Fatalf("failed to seed config: %v", err) + } + + result, err := PlanUpgradeConfigFile(configPath) + if err != nil { + t.Fatalf("PlanUpgradeConfigFile returned error: %v", err) + } + if !result.Changed { + t.Fatal("expected result.Changed=true when keys are missing") + } + found := false + for _, key := range result.MissingKeys { + if key == "CUSTOM_BACKUP_PATHS" { + found = true + break + } + } + if !found { + t.Fatalf("MissingKeys=%v; expected CUSTOM_BACKUP_PATHS", result.MissingKeys) + } + }) +} diff --git a/internal/orchestrator/.backup.lock b/internal/orchestrator/.backup.lock index 4c24bc1..2893e45 100644 --- a/internal/orchestrator/.backup.lock +++ b/internal/orchestrator/.backup.lock @@ -1,3 +1,3 @@ -pid=568974 +pid=17956 host=pve -time=2026-01-31T07:18:45+01:00 +time=2026-02-03T00:07:31+01:00 From 8311517c4cb27f427785ab1c90d3ad0a59996952 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Tue, 3 Feb 2026 00:22:00 +0100 Subject: [PATCH 2/2] Refine upgrade prompts, previews, and errors Make the upgrade UX and diagnostics clearer: update the interactive prompt to inform users a backup will be created and backup.env may be updated; extract maxUpgradeConfigJSONPreviewLength constant and use it when truncating invalid JSON preview. Improve error messages to include the config path when parsing fails and the starting line number for unterminated multi-line values. Update tests to normalize CRLF vs LF so assertions are deterministic across platforms. --- cmd/proxsave/upgrade.go | 8 +++++--- internal/config/upgrade.go | 4 ++-- internal/config/upgrade_test.go | 5 ++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/cmd/proxsave/upgrade.go b/cmd/proxsave/upgrade.go index 757b650..9a09107 100644 --- a/cmd/proxsave/upgrade.go +++ b/cmd/proxsave/upgrade.go @@ -31,6 +31,8 @@ import ( const ( githubRepo = "tis24dev/proxsave" + + maxUpgradeConfigJSONPreviewLength = 4000 ) type releaseInfo struct { @@ -125,7 +127,7 @@ func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.Bootstra bootstrap.Printf("Latest available version: %s (current: %s)", latestVersion, currentVersion) reader := bufio.NewReader(os.Stdin) - confirm, err := promptYesNo(ctx, reader, "Do you want to download and install this version now? [y/N]: ", false) + confirm, err := promptYesNo(ctx, reader, "Do you want to download and install this version now? (backup.env will be updated with any missing keys; a backup will be created) [y/N]: ", false) if err != nil { bootstrap.Error("ERROR: %v", err) workflowErr = err @@ -648,8 +650,8 @@ func upgradeConfigWithBinary(ctx context.Context, execPath, configPath string) ( var result config.UpgradeResult if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { preview := strings.TrimSpace(stdout.String()) - if len(preview) > 4000 { - preview = preview[:4000] + "…" + if len(preview) > maxUpgradeConfigJSONPreviewLength { + preview = preview[:maxUpgradeConfigJSONPreviewLength] + "…" } return nil, fmt.Errorf("invalid JSON from upgrade-config-json: %w (stdout=%q)", err, preview) } diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go index 3fb314d..2b59107 100644 --- a/internal/config/upgrade.go +++ b/internal/config/upgrade.go @@ -157,7 +157,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er // 1. Collect user values: for each KEY we store all VALUE entries in order. userValues, userKeyOrder, err := parseEnvValues(originalLines) if err != nil { - return result, "", originalContent, err + return result, "", originalContent, fmt.Errorf("failed to parse config %s: %w", configPath, err) } // 2. Walk the template line-by-line, merging values. @@ -291,7 +291,7 @@ func parseEnvValues(lines []string) (map[string][]envValue, []string, error) { blockLines := make([]string, 0) blockEnd, err := findClosingQuoteLine(lines, i+1) if err != nil { - return nil, nil, fmt.Errorf("unterminated multi-line value for %s", key) + return nil, nil, fmt.Errorf("unterminated multi-line value for %s starting at line %d", key, i+1) } blockLines = append(blockLines, lines[i+1:blockEnd]...) diff --git a/internal/config/upgrade_test.go b/internal/config/upgrade_test.go index 5580bb9..822e4da 100644 --- a/internal/config/upgrade_test.go +++ b/internal/config/upgrade_test.go @@ -186,12 +186,11 @@ CUSTOM_BACKUP_PATHS=" if err != nil { t.Fatalf("failed to read upgraded config: %v", err) } - content := string(data) + content := strings.ReplaceAll(string(data), "\r\n", "\n") if !strings.Contains(content, "BACKUP_PATH=/legacy/backup") { t.Fatalf("upgraded config missing preserved BACKUP_PATH:\n%s", content) } - if !strings.Contains(content, "CUSTOM_BACKUP_PATHS=\"\n/etc/custom.conf\n\"\n") && - !strings.Contains(content, "CUSTOM_BACKUP_PATHS=\"\r\n/etc/custom.conf\r\n\"\r\n") { + if !strings.Contains(content, "CUSTOM_BACKUP_PATHS=\"\n/etc/custom.conf\n\"\n") { t.Fatalf("upgraded config missing preserved CUSTOM_BACKUP_PATHS block:\n%s", content) } if strings.Contains(content, "# /template/example") {