diff --git a/.memory/project.md b/.memory/project.md index 030aaef..52dbf9a 100644 --- a/.memory/project.md +++ b/.memory/project.md @@ -1,7 +1,7 @@ # Project ## Goal -Provide a small read-only TypeScript wrapper around KeePass `.kdbx` databases using a Python bridge powered by `pykeepass`. +Provide a TypeScript wrapper around KeePass `.kdbx` databases using a Python bridge powered by `pykeepass`. ## Architecture - Public API is TypeScript. @@ -15,8 +15,10 @@ Provide a small read-only TypeScript wrapper around KeePass `.kdbx` databases us - `KeePassDatabase.listEntries()` - `KeePassDatabase.findEntries(query)` - `KeePassDatabase.listGroups()` +- `KeePassDatabase.createEntry(entry)` +- `KeePassDatabase.createGroup(group)` +- `KeePassDatabase.save()` - `KeePassDatabase.close()` is a no-op. - ## Types - Entries expose: `title`, `username`, `password`, `url`, `notes`, optional `groupPath`, optional `otp`. - Groups expose: `name`, `path`. @@ -33,7 +35,7 @@ Provide a small read-only TypeScript wrapper around KeePass `.kdbx` databases us - Bundled fixtures: `tests/fixtures/data.kdbx` and `tests/fixtures/empty.kdbx`. - Companion JSON fixture: `tests/fixtures/data.kdbx.json` stores the password and expected content. - Unit tests in `tests/unit/` mock the child process and validate bridge parsing/error handling. -- Integration tests in `tests/integration/` use `data.kdbx` to verify entries, groups, partial search, and OTP/TOTP output when `pykeepass` is installed. +- Integration tests in `tests/integration/` use `data.kdbx` to verify entries, groups, partial search, OTP/TOTP output, and basic write persistence on a temporary copy when `pykeepass` is installed. - The integration test runner checks for `pykeepass` and skips cleanly when it is unavailable. ## Main scripts @@ -43,5 +45,4 @@ Provide a small read-only TypeScript wrapper around KeePass `.kdbx` databases us - `bun run setup:python` ## Current direction -Keep improving failure-path coverage and keep the API minimal unless a concrete need appears. - +Keep improving failure-path coverage, keep write support minimal and predictable, and continue validating persistence on temporary copies. diff --git a/README.md b/README.md index 7001819..a1504b6 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # kdbx-lib -TypeScript wrapper around `pykeepass` for reading KeePass `.kdbx` files. +TypeScript wrapper around `pykeepass` for reading and modifying KeePass `.kdbx` files. ## Overview This project uses: - TypeScript as the public API - Python as the runtime backend -- `pykeepass` to read KeePass databases +- `pykeepass` to read and update KeePass databases The TypeScript layer launches a Python bridge per request and exchanges JSON through stdin/stdout. @@ -74,6 +74,27 @@ Finds entries by partial match and returns the full entry objects. ### `listGroups()` Returns all groups, including their names and paths. +### `createEntry(entry)` +Creates a new entry in the target database and persists it immediately. + +#### Entry input +- `title`: entry title +- `username`: optional username +- `password`: optional password +- `url`: optional URL +- `notes`: optional notes +- `groupPath`: optional target group path + +### `createGroup(group)` +Creates a new group and persists it immediately. + +#### Group input +- `name`: group name +- `path`: optional parent group path + +### `save()` +Persists the current database state. + ### `close()` No-op for now. @@ -83,5 +104,6 @@ No-op for now. - This is simple and robust for a first version. - Errors from the Python bridge are propagated to the TypeScript API, including invalid or empty output. - A persistent Python process can be added later if needed. +- Write operations currently open, modify, and save the database per command. - Bundled fixtures include `tests/fixtures/data.kdbx` and `tests/fixtures/empty.kdbx`; the companion JSON file stores the password and expected content for tests/examples. - Integration tests validate the bundled `data.kdbx` entry-by-entry and group-by-group against `tests/fixtures/data.kdbx.json`, and may skip cleanly when `pykeepass` is unavailable. diff --git a/src/keepass.ts b/src/keepass.ts index 39756fd..9fcc397 100644 --- a/src/keepass.ts +++ b/src/keepass.ts @@ -3,18 +3,20 @@ import { fileURLToPath } from "node:url"; import type { KeePassCommand, KeePassEntry, + KeePassEntryInput, KeePassFindQuery, KeePassGroup, + KeePassGroupInput, KeePassOpenOptions, KeePassResponse, } from "./types"; export class KeePassDatabase { constructor( - private readonly path: string, - private readonly options: KeePassOpenOptions, - private readonly pythonPath = process.env.PYTHON_PATH ?? ".venv/bin/python3", - private readonly bridgePath = new URL("./python/bridge.py", import.meta.url) + private path: string, + private options: KeePassOpenOptions, + private pythonPath = process.env.PYTHON_PATH ?? ".venv/bin/python3", + private bridgePath = new URL("./python/bridge.py", import.meta.url) ) {} async listEntries(): Promise { @@ -32,8 +34,19 @@ export class KeePassDatabase { return response; } + async createEntry(entry: KeePassEntryInput): Promise { + return this.run({ command: "create-entry", entry }); + } + + async createGroup(group: KeePassGroupInput): Promise { + return this.run({ command: "create-group", group }); + } + + async save(): Promise { + await this.run({ command: "save" }); + } + async close(): Promise { - // No persistent process is kept alive yet. return; } diff --git a/src/python/bridge.py b/src/python/bridge.py index e47d457..c669ec5 100644 --- a/src/python/bridge.py +++ b/src/python/bridge.py @@ -60,6 +60,40 @@ def load_db(db_path, password, key_file=None): return PyKeePass(db_path, password=password) +def save_db(db): + db.save() + + +def normalize_path(value): + if value is None: + return "" + return str(value).strip() + + +def resolve_group_by_path(db, group_path): + normalized = normalize_path(group_path) + if not normalized: + return db.root_group + + candidate_paths = [normalized] + if normalized.startswith("/"): + candidate_paths.append(normalized.lstrip("/")) + else: + candidate_paths.append(f"/{normalized}") + + for candidate in candidate_paths: + matching_groups = db.find_groups(path=candidate) + if matching_groups: + return matching_groups[0] + + matching_groups = db.find_groups(name=normalized.split("/")[-1]) + for group in matching_groups: + if path_to_string(group.path).endswith(normalized): + return group + + raise ValueError(f"Group not found: {normalized}") + + def read_payload(): if len(sys.argv) > 1 and sys.argv[1].strip(): return json.loads(sys.argv[1]) @@ -123,6 +157,42 @@ def main(): emit({"ok": True, "data": groups}) return 0 + if command == "create-entry": + entry_input = payload.get("entry", {}) + title = normalize_path(entry_input.get("title")) + if not title: + emit({"ok": False, "error": "Missing entry title"}) + return 1 + group = resolve_group_by_path(db, entry_input.get("groupPath")) + created = db.add_entry( + group, + title, + normalize_path(entry_input.get("username")), + normalize_path(entry_input.get("password")), + normalize_path(entry_input.get("url")), + normalize_path(entry_input.get("notes")), + ) + save_db(db) + emit({"ok": True, "data": entry_to_dict(created)}) + return 0 + + if command == "create-group": + group_input = payload.get("group", {}) + name = normalize_path(group_input.get("name")) + if not name: + emit({"ok": False, "error": "Missing group name"}) + return 1 + parent = resolve_group_by_path(db, group_input.get("path")) + created_group = db.add_group(parent, name) + save_db(db) + emit({"ok": True, "data": group_to_dict(created_group)}) + return 0 + + if command == "save": + save_db(db) + emit({"ok": True, "data": None}) + return 0 + emit({"ok": False, "error": f"Unknown command: {command}"}) return 1 diff --git a/src/types.ts b/src/types.ts index 745de84..c5ad046 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,10 +25,27 @@ export type KeePassFindQuery = { groupPath?: string; }; +export type KeePassEntryInput = { + title: string; + username?: string; + password?: string; + url?: string; + notes?: string; + groupPath?: string; +}; + +export type KeePassGroupInput = { + name: string; + path?: string; +}; + export type KeePassCommand = | { command: "list-entries" } | { command: "find-entries"; query: KeePassFindQuery } - | { command: "list-groups" }; + | { command: "list-groups" } + | { command: "create-entry"; entry: KeePassEntryInput } + | { command: "create-group"; group: KeePassGroupInput } + | { command: "save" }; export type KeePassResponse = | { diff --git a/tests/integration/pykeepass.test.ts b/tests/integration/pykeepass.test.ts index a7c2d49..0cb74c8 100644 --- a/tests/integration/pykeepass.test.ts +++ b/tests/integration/pykeepass.test.ts @@ -1,5 +1,8 @@ import { expect, test } from "bun:test"; -import { readFile } from "node:fs/promises"; +import { copyFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; import { openKeePassDatabase } from "../../src/keepass"; type FixtureEntry = { @@ -25,6 +28,16 @@ type FixtureData = { const FIXTURE_PATH = "tests/fixtures/data.kdbx"; const FIXTURE_DATA_PATH = "tests/fixtures/data.kdbx.json"; +async function withTempCopy(filePath: string, fn: (tempPath: string) => Promise): Promise { + const tempPath = join(tmpdir(), `kdbx-lib-${randomUUID()}.kdbx`); + await copyFile(filePath, tempPath); + try { + return await fn(tempPath); + } finally { + await rm(tempPath, { force: true }); + } +} + async function ensurePyKeePass(): Promise { const python = process.env.PYTHON_PATH ?? ".venv/bin/python3"; const child = Bun.spawn([python, "-c", "import pykeepass; print('ok')"], { @@ -125,6 +138,34 @@ test("finds entries in the bundled data fixture", async () => { expect(entries[0]?.groupPath).toBe("Folder1"); }); +test("creates entries in a temporary copy of the bundled fixture and persists them", async () => { + const [{ password }, pykeepassReady] = await Promise.all([ + readFile(FIXTURE_DATA_PATH, "utf8").then((raw) => JSON.parse(raw) as FixtureData), + ensurePyKeePass(), + ]); + + if (!pykeepassReady) { + console.log("Skipping integration test: pykeepass is not installed"); + return; + } + + await withTempCopy(FIXTURE_PATH, async (tempPath) => { + const db = openKeePassDatabase(tempPath, { password }); + const createdEntry = await db.createEntry({ + title: "TempEntry", + username: "temp-user", + password: "temp-pass", + }); + expect(createdEntry.title).toBe("TempEntry"); + expect(createdEntry.username).toBe("temp-user"); + + const persisted = await db.findEntries({ title: "TempEntry" }); + expect(persisted).toHaveLength(1); + expect(persisted[0]?.username).toBe("temp-user"); + }); +}); + + test("uses the JSON fixture content as the source of truth for expectations", async () => { const { content } = JSON.parse(await readFile(FIXTURE_DATA_PATH, "utf8")) as FixtureData; diff --git a/tests/unit/keepass.test.ts b/tests/unit/keepass.test.ts index 7646534..c88c60d 100644 --- a/tests/unit/keepass.test.ts +++ b/tests/unit/keepass.test.ts @@ -9,33 +9,23 @@ mock.module("node:child_process", () => ({ spawn: spawnMock, })); +function mockSuccessfulBridgeResponse(data: unknown) { + spawnMock.mockImplementation(() => { + const child = { + stdin: { write: () => undefined, end: () => undefined }, + stdout: { on: (_event: string, cb: (chunk: Buffer | string) => void) => cb(JSON.stringify({ ok: true, data })) }, + stderr: { on: () => undefined }, + on: (event: string, cb: (code?: number | null) => void) => { + if (event === "close") queueMicrotask(() => cb(0)); + }, + }; + return child as never; + }); +} + describe("KeePassDatabase", () => { test("listEntries parses successful bridge response", async () => { - spawnMock.mockImplementation(() => { - const listeners: Record void)[]> = {}; - const child = { - stdin: { - write: () => undefined, - end: () => { - listeners.close?.forEach((cb) => cb(Buffer.from(""))); - }, - }, - stdout: { - on: (event: string, cb: (chunk: Buffer | string) => void) => { - listeners[event] ??= []; - listeners[event].push(cb); - if (event === "data") { - cb(JSON.stringify({ ok: true, data: [{ title: "Entry" }] })); - } - }, - }, - stderr: { on: () => undefined }, - on: (event: string, cb: (code?: number | null) => void) => { - if (event === "close") queueMicrotask(() => cb(0)); - }, - }; - return child as never; - }); + mockSuccessfulBridgeResponse([{ title: "Entry" }]); const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py")); const entries = await db.listEntries(); @@ -60,4 +50,32 @@ describe("KeePassDatabase", () => { const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py")); await expect(db.listEntries()).rejects.toThrow("boom"); }); + + test("createEntry forwards the create-entry command", async () => { + mockSuccessfulBridgeResponse({ title: "New" }); + + const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py")); + const created = await db.createEntry({ title: "New" }); + + expect(created).toEqual({ title: "New" }); + expect(spawnMock).toHaveBeenCalled(); + }); + + test("createGroup forwards the create-group command", async () => { + mockSuccessfulBridgeResponse({ name: "Folder", path: "" }); + + const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py")); + const created = await db.createGroup({ name: "Folder" }); + + expect(created).toEqual({ name: "Folder", path: "" }); + expect(spawnMock).toHaveBeenCalled(); + }); + + test("save forwards the save command", async () => { + mockSuccessfulBridgeResponse(null); + + const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py")); + await expect(db.save()).resolves.toBeUndefined(); + expect(spawnMock).toHaveBeenCalled(); + }); });