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-<fingerprint>-<date>.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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-18 13:48:36 -04:00
1 parent 7748d6b5e9
commit 0482bc3dfd
8 files changed
+109 -16

No files matched your search

+15 -4
View File
@@ -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 <destFile>")
.description("write a portable backup (encrypted by default)")
.description(
"write a portable backup (encrypted by default); give a directory to " +
`get a default seedpass-<fingerprint>-<date>${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<string, unknown>, {
mnemonic: vault.mnemonic,
fingerprint: generateFingerprint(vault.mnemonic),
fingerprint,
encrypt: !o.plaintext,
});
if (existsSync(destFile) && !o.overwrite) {