TS port slice 4: portable backup, CI, and the agent-blind CLI MVP

- Portable backup (format_version 1): TS imports Python exports, re-exports
  byte-identically under pinned nonce/timestamp, verifies the canonical
  checksum, rejects tampering and unknown versions.
- .github/workflows/ts-parity.yml: typecheck + Node/jsdom parity suite on
  js/ changes, plus a fixture-drift job that regenerates fixtures from
  Python and fails if the committed ones don't match (manifest commit field
  excluded by construction).
- packages/cli — seedpass-js MVP implementing plan section 9.3:
  * reference-first output: entry list/get/search emit sp://entry/<id> refs
    and metadata; secret-bearing fields become has_* flags
  * 'entry reveal' is the only plaintext egress command
  * 'use --clipboard|--exec|--stdin-to' delivers secrets to sinks; --exec
    injects SEEDPASS_SECRET into the child env, never argv or stdout
  * vault export/import over portable backups; capabilities --format json
  * mnemonic comes from SEEDPASS_MNEMONIC env, never the command line
- CLI tests prove the blindness properties: derived password matches the v2
  fixture, TOTP at pinned time, secrets absent from all default output and
  from sink-delivery confirmations.
- 125 core parity tests (Node + jsdom) + 11 CLI tests green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-13 10:40:56 -04:00
1 parent 2345f61431
commit ab569fe900
22 files changed
+1362 -14

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
/**
* seedpass-js command surface.
*
* Design constraint (plan section 9.3): default output is reference-first —
* entry ids, labels, and metadata, never secret values. Plaintext egress
* exists only as the distinct `entry reveal` command; `use` delivers secrets
* to sinks (clipboard, child-process env, stdin) without printing them.
*/
import { Command } from "commander";
import process from "node:process";
import { entryMetadata, resolveEntry } from "./refs.js";
import { materializeSecret } from "./secrets.js";
import { clipboardSink, execSink, stdinSink } from "./sinks.js";
import { capabilities } from "./capabilities.js";
import { openVault, type OpenedVault } from "./vaultFile.js";
import { importBackup, exportBackup, generateFingerprint } from "@seedpass/core";
import { readFile, writeFile } from "node:fs/promises";
export interface ProgramIo {
out(line: string): void;
err(line: string): void;
}
const defaultIo: ProgramIo = {
out: (line) => process.stdout.write(line + "\n"),
err: (line) => process.stderr.write(line + "\n"),
};
function requireMnemonic(): string {
const m = process.env["SEEDPASS_MNEMONIC"];
if (!m) {
throw new Error(
"SEEDPASS_MNEMONIC is not set. Export the parent seed mnemonic into the " +
"environment of this process (never pass it on the command line).",
);
}
return m;
}
async function openFromOptions(opts: { vault?: string }): Promise<OpenedVault> {
if (!opts.vault) throw new Error("--vault <file> is required");
return openVault(opts.vault, requireMnemonic());
}
export function buildProgram(io: ProgramIo = defaultIo): Command {
const program = new Command();
program
.name("seedpass-js")
.description("SeedPass (TypeScript port) — reference-first CLI")
.option("--vault <file>", "encrypted vault index file")
.configureOutput({
writeOut: (s) => io.out(s.replace(/\n$/, "")),
writeErr: (s) => io.err(s.replace(/\n$/, "")),
})
.exitOverride();
program
.command("capabilities")
.description("machine-readable capability map")
.action(() => {
io.out(JSON.stringify(capabilities(), null, 2));
});
const entry = program.command("entry").description("entry operations (reference-first)");
entry
.command("list")
.description("list entries as references + metadata (no secrets)")
.action(async () => {
const vault = await openFromOptions(program.opts());
const rows = Object.entries(vault.index.entries).map(([id, e]) =>
entryMetadata(id, e),
);
io.out(JSON.stringify(rows, null, 2));
});
entry
.command("get <refOrQuery>")
.description("show one entry's reference + metadata (no secrets)")
.action(async (refOrQuery: string) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
io.out(JSON.stringify(entryMetadata(hit.id, hit.entry), null, 2));
});
entry
.command("search <text>")
.description("substring search over labels/tags/notes (no secrets)")
.action(async (text: string) => {
const vault = await openFromOptions(program.opts());
const needle = text.toLowerCase();
const rows = Object.entries(vault.index.entries)
.filter(([, e]) => {
const hay = [e.label, e.notes ?? "", ...(e.tags ?? [])].join("\n").toLowerCase();
return hay.includes(needle);
})
.map(([id, e]) => entryMetadata(id, e));
io.out(JSON.stringify(rows, null, 2));
});
entry
.command("reveal <refOrQuery>")
.description("PLAINTEXT EGRESS: print the secret to stdout")
.option("--at <timestamp>", "TOTP: unix time to compute the code at")
.action(async (refOrQuery: string, cmdOpts: { at?: string }) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
const secret = materializeSecret(vault.index, hit.id, hit.entry, vault.mnemonic, {
...(cmdOpts.at !== undefined && { timestamp: Number(cmdOpts.at) }),
});
io.out(secret.value);
});
program
.command("use <refOrQuery>")
.description("deliver a secret to a sink without printing it")
.option("--clipboard", "copy to the system clipboard")
.option("--exec <cmd...>", "run a command with SEEDPASS_SECRET in its env")
.option("--stdin-to <cmd...>", "pipe the secret to a command's stdin")
.option("--at <timestamp>", "TOTP: unix time to compute the code at")
.action(
async (
refOrQuery: string,
cmdOpts: { clipboard?: boolean; exec?: string[]; stdinTo?: string[]; at?: string },
) => {
const chosen = [cmdOpts.clipboard, cmdOpts.exec, cmdOpts.stdinTo].filter(
(v) => v !== undefined,
).length;
if (chosen !== 1) {
throw new Error("choose exactly one sink: --clipboard, --exec, or --stdin-to");
}
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
const secret = materializeSecret(vault.index, hit.id, hit.entry, vault.mnemonic, {
...(cmdOpts.at !== undefined && { timestamp: Number(cmdOpts.at) }),
});
let result;
if (cmdOpts.clipboard) {
result = await clipboardSink(secret.value);
} else if (cmdOpts.exec) {
const [cmd, ...args] = cmdOpts.exec;
result = await execSink(secret.value, cmd!, args);
} else {
const [cmd, ...args] = cmdOpts.stdinTo!;
result = await stdinSink(secret.value, cmd!, args);
}
io.out(
JSON.stringify({
delivered: secret.descriptor,
ref: hit.ref,
...result,
}),
);
},
);
const vaultCmd = program.command("vault").description("vault import/export");
vaultCmd
.command("export <destFile>")
.description("write a portable backup (encrypted by default)")
.option("--plaintext", "HIGH RISK: export without encryption")
.action(async (destFile: string, cmdOpts: { plaintext?: boolean }) => {
const vault = await openFromOptions(program.opts());
const wrapper = await exportBackup(vault.index as Record<string, unknown>, {
mnemonic: vault.mnemonic,
fingerprint: generateFingerprint(vault.mnemonic),
encrypt: !cmdOpts.plaintext,
});
await writeFile(destFile, JSON.stringify(wrapper, null, 2) + "\n", { mode: 0o600 });
io.out(
JSON.stringify({
exported: destFile,
encrypted: !cmdOpts.plaintext,
checksum: wrapper.checksum,
}),
);
});
vaultCmd
.command("import <srcFile>")
.description("read a portable backup and print its reference summary")
.action(async (srcFile: string) => {
const raw = await readFile(srcFile);
const mnemonic = process.env["SEEDPASS_MNEMONIC"];
const index = await importBackup(new Uint8Array(raw), {
...(mnemonic && { mnemonic }),
});
const entries = (index["entries"] ?? {}) as Record<string, { label?: string }>;
io.out(
JSON.stringify({
imported: srcFile,
schema_version: index["schema_version"],
entry_count: Object.keys(entries).length,
}),
);
});
return program;
}