From 0482bc3dfd76558d3a6949aeb72835e4eb025a22 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Tue, 18 Aug 2026 13:48:36 -0400 Subject: [PATCH] feat(cli): .seedpass backup naming (phase 1 -- convention only, zero format change) A portable backup is an encrypted, checksummed, cross-impl-verified file; its only sin was landing on disk as an anonymous .json. Exports now self-identify: point `vault export` (or the TUI database export) at a directory and the file is named seedpass--.seedpass -- tied to the profile it restores and sortable by date. The TUI also appends .seedpass to an extensionless destination, since interactive input has no script-compatibility concerns. The zero-risk boundary, kept deliberately: an explicit file path is used exactly as typed -- a script that exports to backup.json still gets backup.json -- and imports sniff content, never extensions, so any old export restores unchanged. No byte of the format moved, which is what makes this safe to ship ahead of the review gate while format_version 2 (magic header) and the OS icon association stay queued behind it. Tests pin all three behaviours in both surfaces: directory target gets the generated name, extensionless TUI input gets the suffix, explicit paths pass through untouched. 158 CLI tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8 --- README.md | 2 +- TODO.md | 15 ++++++++------ docs/typescript_migration_guide.md | 2 +- js/packages/cli/src/appDir.ts | 16 +++++++++++++++ js/packages/cli/src/program.ts | 19 ++++++++++++++---- js/packages/cli/src/tui/menus.ts | 18 +++++++++++++---- js/packages/cli/test/cli.test.ts | 28 +++++++++++++++++++++++++++ js/packages/cli/test/tuiMenus.test.ts | 25 ++++++++++++++++++++++++ 8 files changed, 109 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 6eead88d..623532ff 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,7 @@ You can then launch SeedPass and create a backup: seedpass # Export your index -seedpass vault export --file "~/seedpass_backup.json" +seedpass vault export --file "~/seedpass_backup.seedpass" # Later you can restore it seedpass vault import --file "~/seedpass_backup.json" diff --git a/TODO.md b/TODO.md index 8759405e..08827ce2 100644 --- a/TODO.md +++ b/TODO.md @@ -293,9 +293,12 @@ before and after each was confirmed. Ordered by severity. field tolerance check in both implementations, retention/mirror policy, and the same metadata-leakage analysis as relays (a pubkey's blob list is public). -- [ ] **`.seedpass` file extension for exports/backups** — phase 1: default - export filenames to `.seedpass`, accept any extension on import (no - byte-format change, cross-impl safe). Phase 2 (optional, versioned): - portable-backup format_version 2 with a magic header for robust file - detection. Plus OS file association + icon (XDG MIME on Linux via - install.sh, registry on Windows, UTI if a mac bundle ever exists). +- [x] **(phase 1 done 2026-08-18)** **`.seedpass` file extension** — CLI + `vault export ` and the TUI database export generate + `seedpass--.seedpass`; extensionless TUI input gets + the suffix; explicit paths are used exactly as typed (scripts + unaffected); imports never cared about extensions. Zero byte-format + change. Remaining (deliberately later): phase 2 format_version 2 with + a magic header (versioned, both implementations), and OS file + association + icon from `logo/svg/` (XDG MIME via install.sh, registry + via install.ps1) — installer work, after #34/#36. diff --git a/docs/typescript_migration_guide.md b/docs/typescript_migration_guide.md index 32eab888..d66d470b 100644 --- a/docs/typescript_migration_guide.md +++ b/docs/typescript_migration_guide.md @@ -28,7 +28,7 @@ Take a backup you can restore without either implementation: ```bash # A portable, encrypted export (readable by both implementations) -seedpass vault export --file ~/seedpass-pre-migration.json +seedpass vault export --file ~/seedpass-pre-migration.seedpass # Plus a copy of the raw profile directory cp -a ~/.seedpass ~/.seedpass.backup-$(date +%Y%m%d) diff --git a/js/packages/cli/src/appDir.ts b/js/packages/cli/src/appDir.ts index e6714757..4e888ed8 100644 --- a/js/packages/cli/src/appDir.ts +++ b/js/packages/cli/src/appDir.ts @@ -46,6 +46,22 @@ export const INDEX_FILENAME = "seedpass_entries_db.json.enc"; export const CONFIG_FILENAME = "seedpass_config.json.enc"; export const PARENT_SEED_FILENAME = "parent_seed.enc"; +/** Canonical extension for portable backups. The bytes are unchanged — this + * is a naming convention (TODO records an optional future format_version 2 + * with a magic header; that is a separate, versioned change). */ +export const BACKUP_EXTENSION = ".seedpass"; + +/** + * Default filename for a portable backup: self-identifying on a disk full of + * files, sortable by date, and tied to the profile it restores. + */ +export function defaultBackupFilename(fingerprint: string, when = new Date()): string { + const y = when.getFullYear(); + const m = String(when.getMonth() + 1).padStart(2, "0"); + const d = String(when.getDate()).padStart(2, "0"); + return `seedpass-${fingerprint}-${y}${m}${d}${BACKUP_EXTENSION}`; +} + export interface FingerprintsFile { fingerprints: string[]; last_used: string | null; diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index 4830b3c7..587084e5 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -150,7 +150,7 @@ async function readSecretInput( function resolveHome(p: string): string { return p.startsWith("~") ? join(homedir(), p.slice(1)) : p; } -import { AppDir, resolveAppDir, INDEX_FILENAME } from "./appDir.js"; +import { AppDir, resolveAppDir, INDEX_FILENAME, BACKUP_EXTENSION, defaultBackupFilename } from "./appDir.js"; import { loadConfig, saveConfig, mutateConfig } from "./configFile.js"; import { AgentClient, AgentDaemon, agentSocketPath, DEFAULT_TTL_SECONDS } from "./agent.js"; import { AuditLog } from "./audit.js"; @@ -1478,14 +1478,25 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { vaultCmd .command("export ") - .description("write a portable backup (encrypted by default)") + .description( + "write a portable backup (encrypted by default); give a directory to " + + `get a default seedpass--${BACKUP_EXTENSION} name`, + ) .option("--plaintext", "HIGH RISK: export without encryption") .option("--overwrite", "replace an existing file") - .action(async (destFile: string, o: { plaintext?: boolean; overwrite?: boolean }) => { + .action(async (rawDest: string, o: { plaintext?: boolean; overwrite?: boolean }) => { const vault = await openFromOptions(program.opts()); + const fingerprint = generateFingerprint(vault.mnemonic); + // An explicit file path is used exactly as typed — scripts depend on + // that. Only a directory target gets the generated name. + const resolved = resolveHome(rawDest); + const destFile = + existsSync(resolved) && statSync(resolved).isDirectory() + ? join(resolved, defaultBackupFilename(fingerprint)) + : resolved; const wrapper = await exportBackup(vault.index as Record, { mnemonic: vault.mnemonic, - fingerprint: generateFingerprint(vault.mnemonic), + fingerprint, encrypt: !o.plaintext, }); if (existsSync(destFile) && !o.overwrite) { diff --git a/js/packages/cli/src/tui/menus.ts b/js/packages/cli/src/tui/menus.ts index 2490386e..ed96dc53 100644 --- a/js/packages/cli/src/tui/menus.ts +++ b/js/packages/cli/src/tui/menus.ts @@ -15,7 +15,7 @@ import process from "node:process"; import { join, basename } from "node:path"; import { readFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { addPasswordEntry, addTotpDeterministic, @@ -41,7 +41,7 @@ import { type Entry, type VaultIndex, } from "@seedpass/core"; -import { AppDir, INDEX_FILENAME, DEFAULT_PBKDF2_ITERATIONS } from "../appDir.js"; +import { AppDir, INDEX_FILENAME, DEFAULT_PBKDF2_ITERATIONS, BACKUP_EXTENSION, defaultBackupFilename } from "../appDir.js"; import { atomicWrite, openVault, saveVaultHoldingLock, withVaultLock, type OpenedVault } from "../vaultFile.js"; import { entryMetadata, refFor } from "../refs.js"; import { materializeSecret } from "../secrets.js"; @@ -1405,8 +1405,18 @@ async function backupParentSeed(s: Session): Promise { async function exportDatabase(s: Session): Promise { title(s, "Main Menu > Settings > Export database"); - const dest = await s.ui.ask("Destination file: "); - if (!dest) return; + const suggested = defaultBackupFilename(s.fingerprint); + const raw = await s.ui.ask(`Destination file or directory (e.g. ${suggested}): `); + if (!raw) return; + // Interactive convenience, not applied to CLI scripts: a directory gets + // the generated name inside it, and a bare name with no extension gets + // the .seedpass suffix so backups self-identify on disk. + let dest = raw; + if (existsSync(dest) && statSync(dest).isDirectory()) { + dest = join(dest, suggested); + } else if (!basename(dest).includes(".")) { + dest = `${dest}${BACKUP_EXTENSION}`; + } const payload = await exportBackup(s.vault.index as unknown as Record, { mnemonic: s.vault.mnemonic, fingerprint: s.fingerprint, diff --git a/js/packages/cli/test/cli.test.ts b/js/packages/cli/test/cli.test.ts index 3457b182..3bf605f4 100644 --- a/js/packages/cli/test/cli.test.ts +++ b/js/packages/cli/test/cli.test.ts @@ -528,3 +528,31 @@ describe("unknown entry kinds at the CLI surface", () => { expect(rows.find((row) => row["kind"] === "bitlogin_org")).toBeDefined(); }); }); + +describe(".seedpass export naming", () => { + it("a directory target gets the generated seedpass--.seedpass name", async () => { + const dir = await mkdtemp(join(tmpdir(), "seedpass-name-")); + const r = await run("--vault", vaultPath, "vault", "export", dir); + expect(r.error).toBeUndefined(); + const dest = String(JSON.parse(r.stdout).exported); + expect(dest.startsWith(dir)).toBe(true); + expect(dest).toMatch(/seedpass-[0-9A-F]{16}-\d{8}\.seedpass$/); + // And the file restores like any other backup: same wrapper shape. + const wrapper = JSON.parse(await readFile(dest, "utf8")); + expect(wrapper.format_version).toBe(1); + }); + + it("an explicit file path is used exactly as typed (scripts depend on it)", async () => { + const dir = await mkdtemp(join(tmpdir(), "seedpass-name-")); + const exact = join(dir, "my-backup.json"); + const r = await run("--vault", vaultPath, "vault", "export", exact); + expect(String(JSON.parse(r.stdout).exported)).toBe(exact); + }); + + it("an extensionless explicit path is also used exactly as typed", async () => { + const dir = await mkdtemp(join(tmpdir(), "seedpass-name-")); + const bare = join(dir, "backup"); + const r = await run("--vault", vaultPath, "vault", "export", bare); + expect(String(JSON.parse(r.stdout).exported)).toBe(bare); + }); +}); diff --git a/js/packages/cli/test/tuiMenus.test.ts b/js/packages/cli/test/tuiMenus.test.ts index 85221012..5d2f3c3c 100644 --- a/js/packages/cli/test/tuiMenus.test.ts +++ b/js/packages/cli/test/tuiMenus.test.ts @@ -551,3 +551,28 @@ describe("secret-bearing exports get a fresh 0600 file", () => { expect(await readFile(dest, "utf8")).toBe("precious"); }); }); + +describe(".seedpass export naming in the TUI", () => { + it("appends .seedpass to an extensionless destination", async () => { + const dest = join(appDir, "my-backup"); + const ui = await run("7", "7", dest, "", "", ""); + expect(ui.text).toContain(`Exported to ${dest}.seedpass`); + const wrapper = JSON.parse(await readFile(`${dest}.seedpass`, "utf8")); + expect(wrapper.format_version).toBe(1); + }); + + it("a directory destination gets the generated self-identifying name", async () => { + const ui = await run("7", "7", appDir, "", "", ""); + const match = ui.text.match(/Exported to (.*seedpass-[0-9A-F]{16}-\d{8}\.seedpass)/); + expect(match).not.toBeNull(); + const wrapper = JSON.parse(await readFile(match![1]!, "utf8")); + expect(wrapper.format_version).toBe(1); + }); + + it("an explicit extension is respected as typed", async () => { + const dest = join(appDir, "explicit.json"); + const ui = await run("7", "7", dest, "", "", ""); + expect(ui.text).toContain(`Exported to ${dest}`); + expect(ui.text).not.toContain(`${dest}.seedpass`); + }); +});