feat(cli): close the remaining command-surface gaps with the Python CLI

Six commands existed in Python (and several in the TS TUI or API) but had no
TypeScript CLI equivalent:

  vault stats                — counts by kind, archived, relays; asserts no
                               secret reaches stdout
  vault change-password      — re-wraps the parent seed, honouring the
                               profile's configured kdf_iterations the way the
                               TUI does. Passwords come from the environment,
                               never argv: a password on the command line is
                               in the process list and the shell history.
  vault reveal-parent-seed   — same egress rule as `fingerprint create`: this
                               is the one secret that unlocks everything, so
                               it refuses to print into a pipe (a log, a
                               transcript, an agent's context) and writes a
                               0600 file it will not clobber.
  entry export-totp          — every 2FA secret, written through atomicWrite
                               for a genuine 0600 inode. Secrets and otpauth
                               URIs, not codes: a code expires in seconds and
                               cannot seed an authenticator, so an export of
                               codes looks correct and is useless. Same bug
                               that the API tests caught.
  config toggle-secret-mode  — `config set` could already do this, but only if
  config toggle-offline        the caller knew the key and that the value is a
                               JSON boolean. These read and flip.
  api stop                   — POSTs /shutdown; token from SEEDPASS_API_TOKEN
                               rather than argv, like every other credential.

`api` becomes a group with `start` as the default subcommand, so `api` alone
keeps working.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-19 09:27:39 -04:00
1 parent 348245e14a
commit 46ff51f3d6
2 files changed
+311 -5

No files matched your search

+214 -3
View File
@@ -154,7 +154,7 @@ async function readSecretInput(
function resolveHome(p: string): string {
return p.startsWith("~") ? join(homedir(), p.slice(1)) : p;
}
import { AppDir, resolveAppDir, INDEX_FILENAME, BACKUP_EXTENSION, defaultBackupFilename } from "./appDir.js";
import { AppDir, resolveAppDir, INDEX_FILENAME, BACKUP_EXTENSION, defaultBackupFilename, DEFAULT_PBKDF2_ITERATIONS } from "./appDir.js";
import {
loadConfig,
saveConfig,
@@ -915,6 +915,50 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
io.out(JSON.stringify(rows, null, 2));
});
entry
.command("export-totp <destFile>")
.description(
"PLAINTEXT EGRESS: write every TOTP secret to a 0600 file (secrets, " +
"not codes — the form an authenticator can be seeded from)",
)
.option("--overwrite", "replace an existing file")
.action(async (destFile: string, cmdOpts: { overwrite?: boolean }) => {
const vault = await openFromOptions(program.opts());
const rows = Object.entries(vault.index.entries)
.filter(([, e]) => e.kind === "totp" && !e.archived)
.map(([, e]) => {
const totp = e as {
secret?: string; index?: number; period: number; digits: number; label: string;
};
// The SECRET, not a code: a code expires in seconds and cannot seed
// an authenticator. Imported entries carry theirs; deterministic
// ones re-derive it.
const secret = totp.secret ?? deriveTotpSecret(vault.mnemonic, totp.index ?? 0);
return {
label: totp.label,
secret,
period: totp.period,
digits: totp.digits,
uri:
`otpauth://totp/${encodeURIComponent(totp.label)}?secret=${secret}` +
`&issuer=SeedPass&period=${totp.period}&digits=${totp.digits}`,
};
});
const resolved = resolveHome(destFile);
if (existsSync(resolved) && !cmdOpts.overwrite) {
throw new Error(`${resolved} already exists; pass --overwrite to replace it`);
}
// Fresh 0600 inode via atomicWrite rather than writeFile's mode, which
// applies only at creation and would leave an existing 0644 file as it
// found it. This file is every 2FA secret in the vault.
await atomicWrite(resolved, utf8(JSON.stringify({ entries: rows }, null, 2)));
io.out(JSON.stringify({ exported: resolved, count: rows.length }));
io.err(
"That file contains the 2FA secrets themselves, not codes. Move it " +
"somewhere safe or delete it.",
);
});
entry
.command("reveal <refOrQuery>")
.description("PLAINTEXT EGRESS: print the secret to stdout")
@@ -1145,6 +1189,34 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
io.out(JSON.stringify({ [key]: parsed }));
});
/**
* Convenience toggles, matching Python's `config toggle-*`.
*
* `config set` can do this, but only if the caller knows the key name and
* that the value is a JSON boolean. These read the current value and flip
* it, which is what a person at a prompt actually wants.
*/
for (const [name, key, label] of [
["toggle-secret-mode", "secret_mode_enabled", "Secret Mode"],
["toggle-offline", "offline_mode", "Offline Mode"],
] as const) {
config
.command(name)
.description(`flip ${label} for the active profile`)
.action(async () => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
const mnemonic = await resolveMnemonic(app, opts);
const next = await mutateConfig(app.profileDir(fp), mnemonic, (cfg) => {
const value = !cfg[key];
cfg[key] = value;
return value;
});
io.out(JSON.stringify({ [key]: next }));
});
}
const nostr = program.command("nostr").description("Nostr relay sync");
async function nostrContext(opts: GlobalOpts) {
@@ -1523,8 +1595,10 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
io.out(JSON.stringify({ agent: "stopped" }));
});
program
.command("api")
const apiCmd = program.command("api").description("HTTP API server");
apiCmd
.command("start", { isDefault: true })
.description(
"serve the HTTP API on loopback (holds an unlocked seed; prints a " +
"bearer token once)",
@@ -1569,6 +1643,32 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
},
);
apiCmd
.command("stop")
.description("ask a running API server to shut down")
.option("--host <host>", "server address", "127.0.0.1")
.option("--port <port>", "server port", "8765")
.action(async (o: { host: string; port: string }) => {
// The token is only ever printed once, at startup, so the caller has to
// supply it. Read from the environment rather than argv, like every
// other credential here.
const token = process.env["SEEDPASS_API_TOKEN"];
if (!token) {
throw new Error(
"set SEEDPASS_API_TOKEN to the token the server printed when it started",
);
}
const port = parseIntOption(o.port, "--port", { min: 1, max: 65535 });
const res = await fetch(`http://${o.host}:${port}/api/v1/shutdown`, {
method: "POST",
headers: { authorization: `Bearer ${token}` },
});
if (!res.ok) {
throw new Error(`server refused the shutdown request: HTTP ${res.status}`);
}
io.out(JSON.stringify({ status: "shutting down", host: o.host, port }));
});
const util = program.command("util").description("utility commands");
util
@@ -1730,6 +1830,117 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
io.out(JSON.stringify({ locked }));
});
vaultCmd
.command("stats")
.description("summarize the active profile (counts only, no secrets)")
.action(async () => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const vault = await openFromOptions(opts);
const byKind: Record<string, number> = {};
let archived = 0;
for (const entry of Object.values(vault.index.entries)) {
const e = entry as unknown as Record<string, unknown>;
const kind = String(e["kind"] ?? e["type"] ?? "unknown");
byKind[kind] = (byKind[kind] ?? 0) + 1;
if (e["archived"] === true) archived++;
}
// Config is best-effort: --vault can name an index with no profile
// beside it, and a stats command should not fail for want of settings.
let relays = 0;
let fingerprint: string | null = null;
try {
fingerprint = await currentFingerprint(app, opts);
const cfg = await loadConfig(app.profileDir(fingerprint), vault.mnemonic);
relays = Array.isArray(cfg["relays"]) ? (cfg["relays"] as unknown[]).length : 0;
} catch {
// no profile context; counts below are still accurate
}
io.out(
JSON.stringify(
{
fingerprint,
schema_version: vault.index.schema_version,
total_entries: Object.keys(vault.index.entries).length,
archived_entries: archived,
by_kind: byKind,
relays_configured: relays,
},
null,
2,
),
);
});
vaultCmd
.command("change-password")
.description(
"re-wrap the parent seed under a new master password (reads " +
"SEEDPASS_PASSWORD and SEEDPASS_NEW_PASSWORD)",
)
.action(async () => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
// Never from argv: a password on the command line is in the process
// list and the shell history.
const current = process.env["SEEDPASS_PASSWORD"];
const next = process.env["SEEDPASS_NEW_PASSWORD"];
if (!current || !next) {
throw new Error(
"set SEEDPASS_PASSWORD and SEEDPASS_NEW_PASSWORD in the environment " +
"(never pass a password on the command line)",
);
}
// Re-wrap at the profile's configured strength, so the kdf_iterations
// setting is honoured here the way the TUI honours it.
let iterations = DEFAULT_PBKDF2_ITERATIONS;
try {
const mnemonic = await app.decryptParentSeed(fp, current);
const cfg = await loadConfig(app.profileDir(fp), mnemonic);
iterations = Number(cfg["kdf_iterations"] ?? DEFAULT_PBKDF2_ITERATIONS);
} catch {
throw new Error("current password is incorrect");
}
await app.changePassword(fp, current, next, iterations);
io.out(JSON.stringify({ status: "ok", fingerprint: fp, kdf_iterations: iterations }));
});
vaultCmd
.command("reveal-parent-seed")
.description("PLAINTEXT EGRESS: the master seed phrase for this profile")
.option("--out <file>", "write to a 0600 file instead of printing")
.option("--show", "print to stdout even when it is not a terminal")
.action(async (o: { out?: string; show?: boolean }) => {
if (o.show && o.out) throw new Error("--show and --out are mutually exclusive");
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const mnemonic = await resolveMnemonic(app, opts);
// Same egress rule as `fingerprint create`: this is the one secret that
// unlocks everything, so it never lands in a pipe by default.
const toTty = !o.out && !o.show && Boolean(process.stdout.isTTY);
if (!o.out && !o.show && !toTty) {
throw new Error(
"refusing to print the parent seed into a pipe (a log, a transcript, " +
"an AI agent's context). Pass --out <file> for a 0600 file, or " +
"--show if you really want it on stdout.",
);
}
if (o.out) {
try {
await writeFile(o.out, mnemonic + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
} catch (e) {
if ((e as NodeJS.ErrnoException).code === "EEXIST") {
throw new Error(`${o.out} already exists; choose another path.`);
}
throw e;
}
io.err(`Parent seed written to ${o.out} (0600). Move it offline.`);
} else {
io.out(mnemonic);
}
});
vaultCmd
.command("import <srcFile>")
.description("restore a portable backup into the active vault")
+97 -2
View File
@@ -5,7 +5,7 @@
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { mkdtemp, readFile, writeFile, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -19,7 +19,10 @@ import {
encryptV3,
deriveIndexKeyBytes,
} from "@seedpass/core";
import { buildProgram, AgentDaemon, agentSocketPath, type ProgramIo } from "../src/index.js";
import { buildProgram, AgentDaemon, agentSocketPath, AppDir, type ProgramIo } from "../src/index.js";
/** Owner env: seed in the environment, as the other suites use it. */
const asOwnerEnv = () => ({ SEEDPASS_MNEMONIC: MNEMONIC });
const MNEMONIC = mnemonics["abandon12"]!;
const FINGERPRINT = generateFingerprint(MNEMONIC);
@@ -206,3 +209,95 @@ describe("config", () => {
expect(redacted.pin_hash).toBe("<redacted>");
});
});
describe("commands that close the gap with the Python CLI", () => {
it("vault stats counts entries without disclosing any", async () => {
await run(asOwnerEnv(), "entry", "add", "password", "stats-a", "--length", "16");
await run(asOwnerEnv(), "entry", "add", "key-value", "stats-b", "K", "stats-secret-value");
const r = await run(asOwnerEnv(), "vault", "stats");
const stats = JSON.parse(r.stdout);
expect(stats.fingerprint).toBe(FINGERPRINT);
expect(stats.total_entries).toBeGreaterThanOrEqual(2);
expect(stats.by_kind.password).toBeGreaterThanOrEqual(1);
// A stats command has no business emitting a secret.
expect(r.stdout).not.toContain("stats-secret-value");
});
it("vault change-password re-wraps the seed and the old password stops working", async () => {
const NEW = "changed-master-password";
const r = await run(
{ SEEDPASS_MNEMONIC: undefined, SEEDPASS_PASSWORD: PASSWORD, SEEDPASS_NEW_PASSWORD: NEW },
"vault", "change-password",
);
expect(JSON.parse(r.stdout).status).toBe("ok");
const app = new AppDir(appDir);
// The new password opens it...
await expect(app.decryptParentSeed(FINGERPRINT, NEW)).resolves.toBe(MNEMONIC);
// ...and the old one does not.
await expect(app.decryptParentSeed(FINGERPRINT, PASSWORD)).rejects.toThrow();
// Put it back so the rest of the file's expectations still hold.
await app.changePassword(FINGERPRINT, NEW, PASSWORD);
});
it("vault change-password refuses a wrong current password", async () => {
const r = await run(
{ SEEDPASS_MNEMONIC: undefined, SEEDPASS_PASSWORD: "wrong", SEEDPASS_NEW_PASSWORD: "x" },
"vault", "change-password",
);
expect(String((r.error as Error).message)).toContain("incorrect");
});
it("vault reveal-parent-seed refuses to drop the seed into a pipe", async () => {
// stdout is not a TTY under the test runner, which is exactly the case
// the guard exists for: a log, a transcript, an agent's context.
const r = await run(asOwnerEnv(), "vault", "reveal-parent-seed");
expect(String((r.error as Error).message)).toContain("refusing to print the parent seed");
expect(r.stdout).not.toContain("abandon");
});
it("vault reveal-parent-seed writes a 0600 file and will not clobber one", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-seedout-"));
const out = join(dir, "seed.txt");
const r = await run(asOwnerEnv(), "vault", "reveal-parent-seed", "--out", out);
expect(r.error).toBeUndefined();
expect((await readFile(out, "utf8")).trim()).toBe(MNEMONIC);
expect((await stat(out)).mode & 0o777).toBe(0o600);
const second = await run(asOwnerEnv(), "vault", "reveal-parent-seed", "--out", out);
expect(String((second.error as Error).message)).toContain("already exists");
});
it("entry export-totp writes secrets, not codes, at 0600", async () => {
await run(asOwnerEnv(), "entry", "add", "totp", "export-me");
const dir = await mkdtemp(join(tmpdir(), "seedpass-totpout-"));
const out = join(dir, "totp.json");
const r = await run(asOwnerEnv(), "entry", "export-totp", out);
expect(JSON.parse(r.stdout).count).toBeGreaterThanOrEqual(1);
expect((await stat(out)).mode & 0o777).toBe(0o600);
const exported = JSON.parse(await readFile(out, "utf8"));
const row = exported.entries.find((e: { label: string }) => e.label === "export-me");
// base32 secret, not a six-digit code — an authenticator cannot be seeded
// from a code, so an export of codes would look right and be useless.
expect(row.secret).toMatch(/^[A-Z2-7]+$/);
expect(row.secret).not.toMatch(/^\d{6}$/);
expect(row.uri).toContain("otpauth://totp/");
// And it refuses to overwrite without being told to.
expect(String(((await run(asOwnerEnv(), "entry", "export-totp", out)).error as Error).message))
.toContain("--overwrite");
});
it("config toggles flip the stored value both ways", async () => {
const first = JSON.parse((await run(asOwnerEnv(), "config", "toggle-secret-mode")).stdout);
expect(first.secret_mode_enabled).toBe(true);
const second = JSON.parse((await run(asOwnerEnv(), "config", "toggle-secret-mode")).stdout);
expect(second.secret_mode_enabled).toBe(false);
const offline = JSON.parse((await run(asOwnerEnv(), "config", "toggle-offline")).stdout);
expect(offline.offline_mode).toBe(true);
await run(asOwnerEnv(), "config", "toggle-offline");
});
});