(cfg: PersistedConfig)
| 592 | * to `<path>.bak` so a crash mid-rename can't strand the user without a |
| 593 | * recoverable config. */ |
| 594 | export async function saveConfig(cfg: PersistedConfig): Promise<void> { |
| 595 | const sanitized = sanitizeForPersist(cfg) |
| 596 | const target = configPath() |
| 597 | const tmp = `${target}.${process.pid}.${Date.now()}.tmp` |
| 598 | const backup = configBackupPath() |
| 599 | const payload = JSON.stringify(sanitized, null, 2) |
| 600 | await fs.mkdir(path.dirname(target), { recursive: true }) |
| 601 | await cleanupStaleConfigTmpFiles() |
| 602 | |
| 603 | // Write + fsync the temp file so the bytes are on disk before we rename. |
| 604 | const handle = await fs.open(tmp, 'w') |
| 605 | try { |
| 606 | await handle.writeFile(payload, 'utf8') |
| 607 | try { |
| 608 | await handle.sync() |
| 609 | } catch (syncErr) { |
| 610 | // fsync isn't supported on every filesystem. Don't abort the save. |
| 611 | console.warn('fsync failed for config temp file', syncErr) |
| 612 | } |
| 613 | } finally { |
| 614 | await handle.close() |
| 615 | } |
| 616 | |
| 617 | // Keep the previous good file as a backup before overwriting. Best-effort: |
| 618 | // missing primary just means there's nothing to back up yet. |
| 619 | try { |
| 620 | await fs.copyFile(target, backup) |
| 621 | } catch (err) { |
| 622 | if (!isMissingFileError(err)) { |
| 623 | console.warn('Failed to refresh config backup', err) |
| 624 | } |
| 625 | } |
| 626 | |
| 627 | try { |
| 628 | await fs.rename(tmp, target) |
| 629 | } catch (err) { |
| 630 | // Rename failed — clean up the temp file so it doesn't accumulate. |
| 631 | try { |
| 632 | await fs.unlink(tmp) |
| 633 | } catch { |
| 634 | /* ignore */ |
| 635 | } |
| 636 | throw err |
| 637 | } |
| 638 | } |
| 639 | |
| 640 | /** |
| 641 | * Atomically write a file: temp file + fsync + rename. The rename is atomic, so |
no test coverage detected