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`);
+ });
+});