TS port slice 7: entry modify/archive/links + util generate-password

- core entryMod: modify_entry port with the per-kind allowed-field matrix,
  policy merging, blacklisted-field cleanup and timestamp touching;
  archive/restore; typed links (add/remove/get with Python's normalization,
  dedupe on target/relation/note, self-link and missing-target refusal).
- Parity fixture entry_mods.json: the real Python EntryManager applies a
  10-op modification sequence at a later pinned clock; TS replays the same
  ops from the creation state and matches byte-for-byte.
- CLI: entry modify/archive/unarchive/links/link-add/link-remove,
  entry totp-codes (explicit egress, honors --at and archived state),
  util generate-password (Python parity: index 0 + gen v1 defaults, full
  policy flags — v1/v2 outputs verified against password fixtures).
- capabilities: egress command list updated.
- 148 core parity tests (Node + jsdom) + 22 CLI tests green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-14 13:14:25 -04:00
1 parent 774c992153
commit 4f2b8a79bf
11 files changed
+868 -9

No files matched your search

+6 -2
View File
@@ -18,14 +18,18 @@ export function capabilities(): Record<string, unknown> {
agent_surface: {
reference_scheme: "sp://entry/<id>",
reference_first_output: true,
plaintext_egress_commands: ["entry reveal"],
plaintext_egress_commands: [
"entry reveal",
"entry totp-codes",
"util generate-password",
],
sinks: ["clipboard", "exec-env", "stdin-to"],
exec_env_var: "SEEDPASS_SECRET",
},
not_yet_ported: [
"ssh/pgp key material",
"relay sync commands",
"profile management",
"profile and config management",
"index0/atlas",
"leases and scoped tokens (design: plan section 9.3)",
],
+187
View File
@@ -18,6 +18,18 @@ import {
importBackup,
exportBackup,
generateFingerprint,
modifyEntry,
archiveEntry,
restoreEntry,
addLink,
removeLink,
getLinks,
generatePassword,
Bip85,
totpCodeAt,
deriveTotpSecret,
type ModifyChanges,
type PasswordPolicy,
addPasswordEntry,
addTotpDeterministic,
addTotpImported,
@@ -256,6 +268,137 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
await finishAdd(vault, id);
});
entry
.command("modify <refOrQuery>")
.description("update entry fields (kind-checked); prints the new metadata")
.option("--label <text>")
.option("--username <text>")
.option("--url <text>")
.option("--notes <text>")
.option("--tags <tag...>")
.option("--period <n>")
.option("--digits <n>")
.option("--key <text>")
.option("--value <text>", "new secret value (key_value/managed_account)")
.option("--content <text>", "new document content")
.option("--file-type <ext>")
.action(
async (
refOrQuery: string,
o: {
label?: string; username?: string; url?: string; notes?: string;
tags?: string[]; period?: string; digits?: string; key?: string;
value?: string; content?: string; fileType?: string;
},
) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
const changes: ModifyChanges = {
...(o.label !== undefined && { label: o.label }),
...(o.username !== undefined && { username: o.username }),
...(o.url !== undefined && { url: o.url }),
...(o.notes !== undefined && { notes: o.notes }),
...(o.tags !== undefined && { tags: o.tags }),
...(o.period !== undefined && { period: Number(o.period) }),
...(o.digits !== undefined && { digits: Number(o.digits) }),
...(o.key !== undefined && { key: o.key }),
...(o.value !== undefined && { value: o.value }),
...(o.content !== undefined && { content: o.content }),
...(o.fileType !== undefined && { file_type: o.fileType }),
};
if (Object.keys(changes).length === 0) throw new Error("no changes given");
modifyEntry(vault.index, hit.id, changes);
await saveVault(vault);
io.out(JSON.stringify(entryMetadata(hit.id, vault.index.entries[hit.id]!), null, 2));
},
);
entry
.command("archive <refOrQuery>")
.description("archive an entry")
.action(async (refOrQuery: string) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
archiveEntry(vault.index, hit.id);
await saveVault(vault);
io.out(JSON.stringify({ ref: hit.ref, archived: true }));
});
entry
.command("unarchive <refOrQuery>")
.description("restore an archived entry")
.action(async (refOrQuery: string) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
restoreEntry(vault.index, hit.id);
await saveVault(vault);
io.out(JSON.stringify({ ref: hit.ref, archived: false }));
});
entry
.command("links <refOrQuery>")
.description("list an entry's links with resolved targets")
.action(async (refOrQuery: string) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
io.out(JSON.stringify(getLinks(vault.index, hit.id), null, 2));
});
entry
.command("link-add <refOrQuery> <target>")
.description("link an entry to another")
.option("--relation <name>", "relation type", "related_to")
.option("--note <text>", "link note", "")
.action(async (refOrQuery: string, target: string, o: { relation: string; note: string }) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
const targetHit = resolveEntry(vault.index, target);
const links = addLink(vault.index, hit.id, Number(targetHit.id), {
relation: o.relation,
note: o.note,
});
await saveVault(vault);
io.out(JSON.stringify({ ref: hit.ref, links }, null, 2));
});
entry
.command("link-remove <refOrQuery> <target>")
.description("remove links to a target entry")
.option("--relation <name>", "only remove this relation")
.action(async (refOrQuery: string, target: string, o: { relation?: string }) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
const targetHit = resolveEntry(vault.index, target);
const links = removeLink(vault.index, hit.id, Number(targetHit.id), {
...(o.relation !== undefined && { relation: o.relation }),
});
await saveVault(vault);
io.out(JSON.stringify({ ref: hit.ref, links }, null, 2));
});
entry
.command("totp-codes")
.description("PLAINTEXT EGRESS: current codes for all active TOTP entries")
.option("--at <timestamp>", "unix time to compute codes at")
.action(async (cmdOpts: { at?: string }) => {
const vault = await openFromOptions(program.opts());
const ts = cmdOpts.at !== undefined ? Number(cmdOpts.at) : Math.floor(Date.now() / 1000);
const rows = Object.entries(vault.index.entries)
.filter(([, e]) => e.kind === "totp" && !e.archived)
.map(([id, e]) => {
const totp = e as { secret?: string; index?: number; period: number; digits: number; label: string };
const secret = totp.secret ?? deriveTotpSecret(vault.mnemonic, totp.index ?? 0);
return {
ref: `sp://entry/${id}`,
label: totp.label,
code: totpCodeAt(secret, ts, totp.period, totp.digits),
period: totp.period,
seconds_remaining: totp.period - (ts % totp.period),
};
});
io.out(JSON.stringify(rows, null, 2));
});
entry
.command("reveal <refOrQuery>")
.description("PLAINTEXT EGRESS: print the secret to stdout")
@@ -313,6 +456,50 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
},
);
const util = program.command("util").description("utility commands");
util
.command("generate-password")
.description("derive a password (Python parity: index 0, gen v1 by default)")
.option("--length <n>", "password length", "24")
.option("--index <n>", "derivation index", "0")
.option("--gen-version <v>", "generation version (1 or 2)", "1")
.option("--no-special", "exclude special characters")
.option("--allowed-special-chars <set>")
.option("--special-mode <mode>")
.option("--exclude-ambiguous")
.option("--min-uppercase <n>")
.option("--min-lowercase <n>")
.option("--min-digits <n>")
.option("--min-special <n>")
.action(
(o: {
length: string; index: string; genVersion: string; special: boolean;
allowedSpecialChars?: string; specialMode?: string; excludeAmbiguous?: boolean;
minUppercase?: string; minLowercase?: string; minDigits?: string; minSpecial?: string;
}) => {
const policy: PasswordPolicy = {
...(o.special === false && { includeSpecialChars: false }),
...(o.allowedSpecialChars !== undefined && { allowedSpecialChars: o.allowedSpecialChars }),
...(o.specialMode !== undefined && { specialMode: o.specialMode }),
...(o.excludeAmbiguous !== undefined && { excludeAmbiguous: o.excludeAmbiguous }),
...(o.minUppercase !== undefined && { minUppercase: Number(o.minUppercase) }),
...(o.minLowercase !== undefined && { minLowercase: Number(o.minLowercase) }),
...(o.minDigits !== undefined && { minDigits: Number(o.minDigits) }),
...(o.minSpecial !== undefined && { minSpecial: Number(o.minSpecial) }),
};
const bip85 = Bip85.fromMnemonic(requireMnemonic());
io.out(
generatePassword(bip85, {
length: Number(o.length),
index: Number(o.index),
genVersion: Number(o.genVersion),
policy,
}),
);
},
);
const vaultCmd = program.command("vault").description("vault import/export");
vaultCmd
+85
View File
@@ -14,6 +14,7 @@ import process from "node:process";
import {
entriesIndex,
entrySecrets,
passwordV1Cases,
passwordV2Cases,
totpCases,
mnemonics,
@@ -247,6 +248,90 @@ describe("sink delivery keeps secrets out of CLI output", () => {
});
});
describe("modification commands", () => {
let modPath: string;
beforeAll(async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-mod-"));
modPath = join(dir, "vault.enc");
await writeFile(modPath, await readFile(vaultPath));
});
it("entry modify updates fields and bumps timestamps", async () => {
const before = JSON.parse(
(await run("--vault", modPath, "entry", "get", "example.com")).stdout,
);
const r = await run(
"--vault", modPath, "entry", "modify", "example.com",
"--username", "carol", "--tags", "web", "staging",
);
const row = JSON.parse(r.stdout);
expect(row.username).toBe("carol");
expect(row.tags).toEqual(["web", "staging"]);
expect(row.modified_ts).toBeGreaterThan(before.modified_ts);
});
it("entry modify rejects fields the kind does not allow", async () => {
const r = await run(
"--vault", modPath, "entry", "modify", "example-totp", "--username", "x",
);
expect(String((r.error as Error).message)).toContain("does not support fields");
});
it("archive hides from totp-codes; unarchive restores", async () => {
await run("--vault", modPath, "entry", "archive", "example-totp");
const archived = JSON.parse(
(await run("--vault", modPath, "entry", "totp-codes", "--at", "1700000000")).stdout,
) as { label: string }[];
expect(archived.map((x) => x.label)).not.toContain("example-totp");
await run("--vault", modPath, "entry", "unarchive", "example-totp");
const restored = JSON.parse(
(await run("--vault", modPath, "entry", "totp-codes", "--at", "1700000000")).stdout,
) as { label: string; code: string }[];
const det = restored.find((x) => x.label === "example-totp")!;
expect(det.code).toBe(entrySecrets.totp_entry_1_code_at["1700000000"]);
});
it("link-add/links/link-remove round-trip with resolved targets", async () => {
await run(
"--vault", modPath, "entry", "link-add", "example.com", "example-totp",
"--relation", "totp", "--note", "2fa",
);
const links = JSON.parse(
(await run("--vault", modPath, "entry", "links", "example.com")).stdout,
);
expect(links).toEqual([
{ target_id: 1, relation: "totp", note: "2fa", target_label: "example-totp", target_kind: "totp" },
]);
const after = await run(
"--vault", modPath, "entry", "link-remove", "example.com", "example-totp",
);
expect(JSON.parse(after.stdout).links).toEqual([]);
});
});
describe("util generate-password", () => {
it("matches the v1 fixture at default index/version", async () => {
const expected = passwordV1Cases.find(
(c) => c.policy === "default" && c.length === 16 && c.index === 0,
)!.password;
const r = await run("util", "generate-password", "--length", "16");
expect(r.stdout).toBe(expected);
});
it("matches v2 and policy fixtures", async () => {
const v2 = passwordV2Cases.find(
(c) => c.policy === "safe_special" && c.length === 40 && c.index === 2,
)!.password;
const r = await run(
"util", "generate-password", "--length", "40", "--index", "2",
"--gen-version", "2", "--special-mode", "safe",
);
expect(r.stdout).toBe(v2);
});
});
describe("vault export/import", () => {
it("exports an encrypted portable backup the importer accepts", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-export-"));