Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ ClipCase stores plain files so users can inspect, diff, back up, or delete casef
- `entries/*.md` contains front matter, hash metadata, and fenced plaintext.

Entry IDs are timestamp plus content hash prefix: `YYYYMMDDTHHMMSSZ-<12 hex>`.
If that ID already exists, ClipCase appends a zero-padded collision counter, starting
at `-000001`. This preserves both identical captures made within the same second
while keeping filenames and index ordering deterministic.
3 changes: 2 additions & 1 deletion src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export async function loadCase(storageDir: string, caseName: string): Promise<Ca
async function saveCase(storageDir: string, meta: CaseMetadata): Promise<void> { meta.entries.sort((a, b) => a.id.localeCompare(b.id)); await fs.writeFile(await indexPath(storageDir, meta.name), JSON.stringify(meta, null, 2) + '\n'); }
export async function createCase(storageDir: string, name: string, title?: string, now = new Date()): Promise<CaseMetadata> { await ensureStore(storageDir); const slug = slugify(name); const dir = caseDir(storageDir, slug); await fs.mkdir(path.join(dir, 'entries'), { recursive: true }); const createdAt = now.toISOString(); const meta: CaseMetadata = { name: slug, title: title ?? slug, createdAt, updatedAt: createdAt, entries: [] }; await fs.writeFile(path.join(dir, INDEX_FILE), JSON.stringify(meta, null, 2) + '\n', { flag: 'wx' }); return meta; }
export async function listCases(storageDir: string): Promise<CaseMetadata[]> { await ensureStore(storageDir); const names = await fs.readdir(storageDir).catch(() => [] as string[]); const cases: CaseMetadata[] = []; for (const name of names.sort()) { try { cases.push(await loadCase(storageDir, name)); } catch {} } return cases.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.name.localeCompare(b.name)); }
export async function addEntry(storageDir: string, input: AddEntryInput): Promise<EntryMetadata> { const findings = findSecrets(input.text); if (findings.length && !input.allowSecret) throw new ClipcaseError(`Refusing to save likely secret(s): ${findings.map((f) => f.label).join(', ')}. Re-run with --allow-secret if this is intentional.`, 3); const meta = await loadCase(storageDir, input.caseName); const now = input.now ?? new Date(); const createdAt = now.toISOString(); const hash = sha256(input.text); const stamp = createdAt.replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); const id = `${stamp}-${shortHash(input.text)}`; const relPath = toPosix(path.join('entries', `${id}.md`)); const entry: EntryMetadata = { id, caseName: meta.name, createdAt, source: input.source ?? 'stdin', tags: [...new Set(input.tags ?? [])].sort(), hash, bytes: Buffer.byteLength(input.text), path: relPath }; await fs.writeFile(path.join(caseDir(storageDir, meta.name), relPath), renderEntry(entry, input.text), { flag: 'wx' }); meta.entries.push(entry); meta.updatedAt = createdAt; await saveCase(storageDir, meta); return entry; }
export async function addEntry(storageDir: string, input: AddEntryInput): Promise<EntryMetadata> { const findings = findSecrets(input.text); if (findings.length && !input.allowSecret) throw new ClipcaseError(`Refusing to save likely secret(s): ${findings.map((f) => f.label).join(', ')}. Re-run with --allow-secret if this is intentional.`, 3); const meta = await loadCase(storageDir, input.caseName); const now = input.now ?? new Date(); const createdAt = now.toISOString(); const hash = sha256(input.text); const stamp = createdAt.replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); const baseId = `${stamp}-${shortHash(input.text)}`; let entry: EntryMetadata | undefined; for (let collision = 0; !entry; collision += 1) { const id = collision === 0 ? baseId : `${baseId}-${String(collision).padStart(6, '0')}`; const relPath = toPosix(path.join('entries', `${id}.md`)); const candidate: EntryMetadata = { id, caseName: meta.name, createdAt, source: input.source ?? 'stdin', tags: [...new Set(input.tags ?? [])].sort(), hash, bytes: Buffer.byteLength(input.text), path: relPath }; try { await fs.writeFile(path.join(caseDir(storageDir, meta.name), relPath), renderEntry(candidate, input.text), { flag: 'wx' }); entry = candidate; } catch (error) { if (!isAlreadyExists(error)) throw error; } } meta.entries.push(entry); meta.updatedAt = createdAt; await saveCase(storageDir, meta); return entry; }
function isAlreadyExists(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'EEXIST'; }
export function renderEntry(entry: EntryMetadata, text: string): string { return `---\nid: ${entry.id}\ncreatedAt: ${entry.createdAt}\nsource: ${entry.source}\ntags: [${entry.tags.join(', ')}]\nhash: ${entry.hash}\nbytes: ${entry.bytes}\n---\n\n# Entry ${entry.id}\n\n- Source: ${escapeMarkdown(entry.source)}\n- Tags: ${formatTags(entry.tags)}\n- SHA-256: \`${entry.hash}\`\n\n\`\`\`text\n${text.replace(/\n?$/, '\n')}\`\`\`\n`; }
export async function readEntryText(storageDir: string, meta: CaseMetadata, entry: EntryMetadata): Promise<string> { const md = await fs.readFile(path.join(caseDir(storageDir, meta.name), entry.path), 'utf8'); const match = md.match(/```text\n([\s\S]*?)```\n?$/); return match ? match[1].replace(/\n$/, '') : md; }
export async function exportCase(storageDir: string, caseName: string): Promise<string> { const meta = await loadCase(storageDir, caseName); const chunks = [`# ${meta.title}\n`, `- Case: ${meta.name}`, `- Created: ${meta.createdAt}`, `- Updated: ${meta.updatedAt}`, `- Entries: ${meta.entries.length}`, '']; for (const entry of meta.entries) { const text = await readEntryText(storageDir, meta, entry); chunks.push(`## ${entry.id}`, '', `- Source: ${entry.source}`, `- Tags: ${formatTags(entry.tags)}`, `- SHA-256: \`${entry.hash}\``, '', '```text', text, '```', ''); } return chunks.join('\n'); }
Expand Down
17 changes: 17 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ describe('clipcase CLI', () => {
assert.match(run(['export', 'bug-login'], cwd), /expired cookie causes redirect failure/);
});

it('captures repeated identical input without overwriting an entry', async () => {
const cwd = await tmp();

run(['init'], cwd);
run(['new', 'duplicates'], cwd);
for (let attempt = 0; attempt < 4; attempt += 1) run(['add', 'duplicates'], cwd, 'identical capture\n');

const shown = JSON.parse(run(['show', 'duplicates'], cwd)) as { entries: Array<{ id: string; path: string; hash: string; bytes: number }> };
assert.equal(shown.entries.length, 4);
assert.equal(new Set(shown.entries.map((entry) => entry.id)).size, 4);
assert.equal(new Set(shown.entries.map((entry) => entry.path)).size, 4);
assert.equal(new Set(shown.entries.map((entry) => entry.hash)).size, 1);
assert.deepEqual(new Set(shown.entries.map((entry) => entry.bytes)), new Set([18]));
assert.equal(run(['search', 'identical'], cwd).trim().split('\n').length, 4);
assert.match(run(['export', 'duplicates'], cwd), /- Entries: 4/);
});

it('rejects case names that resolve outside the configured store', async () => {
const cwd = await tmp();

Expand Down
3 changes: 2 additions & 1 deletion test/clipcase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { addEntry, createCase, exportCase, listCases, searchCases } from '../src/index.js';
import { addEntry, createCase, exportCase, listCases, loadCase, searchCases } from '../src/index.js';
import { findSecrets } from '../src/secrets.js';
import { loadConfig, writeConfig } from '../src/config.js';
async function tmp(): Promise<string> { return fs.mkdtemp(path.join(os.tmpdir(), 'clipcase-test-')); }
test('creates cases and captures deterministic entry metadata', async () => { const dir = await tmp(); await createCase(dir, 'Bug Login', 'Bug Login', new Date('2026-01-01T00:00:00.000Z')); const entry = await addEntry(dir, { caseName: 'bug-login', text: 'hello repro\n', source: 'terminal', tags: ['repro'], now: new Date('2026-01-01T00:01:00.000Z') }); assert.equal(entry.id, '20260101T000100Z-4e17aeaa9041'); assert.equal(entry.source, 'terminal'); assert.deepEqual(entry.tags, ['repro']); });
test('keeps identical same-second captures as distinct, ordered entries', async () => { const dir = await tmp(); await createCase(dir, 'collision'); const now = new Date('2026-01-01T00:00:01.100Z'); const first = await addEntry(dir, { caseName: 'collision', text: 'same content', now }); const second = await addEntry(dir, { caseName: 'collision', text: 'same content', now: new Date('2026-01-01T00:00:01.900Z') }); assert.equal(first.id, '20260101T000001Z-a636bd7cd420'); assert.equal(second.id, `${first.id}-000001`); assert.notEqual(first.path, second.path); const meta = await loadCase(dir, 'collision'); assert.deepEqual(meta.entries.map((entry) => entry.id), [first.id, second.id]); assert.deepEqual(meta.entries.map((entry) => entry.hash), [first.hash, first.hash]); assert.deepEqual(meta.entries.map((entry) => entry.bytes), [12, 12]); assert.equal((await searchCases(dir, 'same content')).length, 2); const exported = await exportCase(dir, 'collision'); assert.ok(exported.indexOf(`## ${first.id}\n`) < exported.indexOf(`## ${second.id}\n`)); });
test('blocks likely secrets unless explicitly allowed', async () => { const dir = await tmp(); await createCase(dir, 'secret-case'); await assert.rejects(() => addEntry(dir, { caseName: 'secret-case', text: 'token=abcdefghijklmnopqrstuvwxyz123456' }), /Refusing to save/); const entry = await addEntry(dir, { caseName: 'secret-case', text: 'token=abcdefghijklmnopqrstuvwxyz123456', allowSecret: true }); assert.ok(entry.id); assert.equal(findSecrets('AKIAABCDEFGHIJKLMNOP').length, 1); assert.equal(findSecrets('npm_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL').length, 1); });
test('lists, searches, and exports case content', async () => { const dir = await tmp(); await createCase(dir, 'bug-login', 'Login Bug'); await addEntry(dir, { caseName: 'bug-login', text: 'expired cookie causes failure', source: 'terminal', tags: ['auth'] }); assert.equal((await listCases(dir)).length, 1); const results = await searchCases(dir, 'cookie'); assert.equal(results.length, 1); const exported = await exportCase(dir, 'bug-login'); assert.match(exported, /# Login Bug/); assert.match(exported, /expired cookie/); });
test('writes and loads local config', async () => { const dir = await tmp(); await writeConfig('notes', dir); const config = await loadConfig(dir); assert.equal(config.storageDir, path.join(dir, 'notes')); });
Loading