From aed36f5b832350ed8fa416eab0db1b0fcd0a40b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Apr 2026 11:04:48 +0000 Subject: [PATCH] fix: prevent duplicate dotenv key mass-deletion --- src/app/vault/[slug]/file/file-workspace.tsx | 8 ++-- src/lib/dotenv-parse.test.ts | 40 ++++++++++++++++++++ src/lib/dotenv-parse.ts | 24 ++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 src/lib/dotenv-parse.test.ts diff --git a/src/app/vault/[slug]/file/file-workspace.tsx b/src/app/vault/[slug]/file/file-workspace.tsx index 12996ee..cda545a 100644 --- a/src/app/vault/[slug]/file/file-workspace.tsx +++ b/src/app/vault/[slug]/file/file-workspace.tsx @@ -5,7 +5,7 @@ import { useMemo, useState } from "react"; import { appendDotenvKey, parseDotenv, - removeDotenvKey, + removeDotenvEntryAt, } from "@/lib/dotenv-parse"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; @@ -106,14 +106,14 @@ function FileWorkspaceInner({ collectionSlug, objectKey }: Props) { } }; - const removeKey = (secretKey: string) => { + const removeKey = (secretKey: string, entryIndex: number) => { if ( !confirm( `Remove "${secretKey}" from this file? Save to persist the change.`, ) ) return; - setDraft(removeDotenvKey(text, secretKey)); + setDraft(removeDotenvEntryAt(text, entryIndex)); }; const addKey = () => { @@ -346,7 +346,7 @@ function FileWorkspaceInner({ collectionSlug, objectKey }: Props) { size="sm" variant="outline" className="text-destructive hover:bg-destructive/10 hover:text-destructive" - onClick={() => removeKey(row.key)} + onClick={() => removeKey(row.key, index)} disabled={save.isPending || remove.isPending} > Remove diff --git a/src/lib/dotenv-parse.test.ts b/src/lib/dotenv-parse.test.ts new file mode 100644 index 0000000..52ae473 --- /dev/null +++ b/src/lib/dotenv-parse.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + appendDotenvKey, + parseDotenv, + removeDotenvEntryAt, + removeDotenvKey, +} from "./dotenv-parse"; + +describe("removeDotenvEntryAt", () => { + it("removes only the targeted duplicate key entry", () => { + const content = "A=1\nA=2\nB=3\n"; + + expect(removeDotenvEntryAt(content, 0)).toBe("A=2\nB=3\n"); + expect(removeDotenvEntryAt(content, 1)).toBe("A=1\nB=3\n"); + }); + + it("preserves comments and blank lines while removing one parsed entry", () => { + const content = "# top\nA=1\n\n# middle\nA=2\nB=3"; + + expect(removeDotenvEntryAt(content, 1)).toBe("# top\nA=1\n\n# middle\nB=3"); + }); +}); + +describe("parseDotenv and appendDotenvKey", () => { + it("parses simple entries and appends a new one", () => { + const content = "A=1\nB=2"; + expect(parseDotenv(content)).toEqual([ + { key: "A", value: "1" }, + { key: "B", value: "2" }, + ]); + + const result = appendDotenvKey(content, "C", "3"); + expect(result).toEqual({ ok: true, content: "A=1\nB=2\nC=3" }); + }); + + it("retains legacy removeDotenvKey behavior for all matching keys", () => { + const content = "A=1\nA=2\nB=3"; + expect(removeDotenvKey(content, "A")).toBe("B=3"); + }); +}); diff --git a/src/lib/dotenv-parse.ts b/src/lib/dotenv-parse.ts index 1b573ec..be2686a 100644 --- a/src/lib/dotenv-parse.ts +++ b/src/lib/dotenv-parse.ts @@ -41,6 +41,30 @@ export function removeDotenvKey(content: string, key: string): string { return filtered.join("\n"); } +/** + * Removes exactly one parsed dotenv entry by its parse order index. + * Preserves all non-entry lines and other duplicate keys. + */ +export function removeDotenvEntryAt( + content: string, + entryIndex: number, +): string { + if (!Number.isInteger(entryIndex) || entryIndex < 0) return content; + const lines = content.split(/\r?\n/); + let seen = 0; + const filtered = lines.filter((line) => { + const entry = tryParseDotenvLine(line); + if (!entry) return true; + if (seen === entryIndex) { + seen += 1; + return false; + } + seen += 1; + return true; + }); + return filtered.join("\n"); +} + function formatDotenvLine(key: string, value: string): string { const needsQuotes = /[\s#"']/.test(value) || (value.length > 0 && value !== value.trim());