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
7 changes: 7 additions & 0 deletions docs/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ than the longest run found in the captured text. Readers use the indexed byte
length to recover the exact capture, including whether it ended with a newline.
Older entries that use the original fixed triple-backtick fence remain readable.

If a case directory has no `index.json`, commands treat it as a missing case and
`list` ignores that directory. If an index exists but is malformed or cannot be
read (including when `index.json` is a directory), `list` and case-specific
commands stop with a diagnostic that names the case and index path. Repair or
restore that plain JSON file before retrying; ClipCase does not silently discard
or replace corrupt metadata.

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
Expand Down
6 changes: 4 additions & 2 deletions src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ const LOCK_TIMEOUT_MS = 10_000;
export async function ensureStore(storageDir: string): Promise<void> { await fs.mkdir(storageDir, { recursive: true }); }
export function caseDir(storageDir: string, caseName: string): string { const root = path.resolve(storageDir); const slug = slugify(caseName); if (!slug) throw new ClipcaseError(`Invalid case name: ${caseName}`); const dir = path.resolve(root, slug); if (dir === root || path.dirname(dir) !== root) throw new ClipcaseError(`Invalid case name: ${caseName}`); return dir; }
async function indexPath(storageDir: string, caseName: string): Promise<string> { return path.join(caseDir(storageDir, caseName), INDEX_FILE); }
export async function loadCase(storageDir: string, caseName: string): Promise<CaseMetadata> { const target = await indexPath(storageDir, caseName); try { return JSON.parse(await fs.readFile(target, 'utf8')) as CaseMetadata; } catch { throw new ClipcaseError(`Case not found: ${caseName}`, 2); } }
export async function loadCase(storageDir: string, caseName: string): Promise<CaseMetadata> { const target = await indexPath(storageDir, caseName); let contents: string; try { contents = await fs.readFile(target, 'utf8'); } catch (error) { if (hasCode(error, 'ENOENT')) throw new ClipcaseError(`Case not found: ${caseName}`, 2); throw new ClipcaseError(`Cannot read case metadata for ${slugify(caseName)} at ${target}: ${errorMessage(error)}`, 4); } try { return JSON.parse(contents) as CaseMetadata; } catch (error) { throw new ClipcaseError(`Invalid case metadata for ${slugify(caseName)} at ${target}: ${errorMessage(error)}`, 4); } }
async function saveCase(storageDir: string, meta: CaseMetadata): Promise<void> { meta.entries.sort((a, b) => a.id.localeCompare(b.id)); const target = await indexPath(storageDir, meta.name); const temporary = `${target}.${process.pid}.${Date.now()}.tmp`; try { await fs.writeFile(temporary, JSON.stringify(meta, null, 2) + '\n', { flag: 'wx' }); await fs.rename(temporary, target); } finally { await fs.rm(temporary, { force: true }); } }
async function withCaseLock<T>(storageDir: string, caseName: string, operation: () => Promise<T>): Promise<T> { const lock = path.join(caseDir(storageDir, caseName), LOCK_DIR); const deadline = Date.now() + LOCK_TIMEOUT_MS; while (true) { try { await fs.mkdir(lock); break; } catch (error) { if (!isAlreadyExists(error)) throw error; if (Date.now() >= deadline) throw new ClipcaseError(`Timed out waiting to update case: ${slugify(caseName)}`, 4); await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS)); } } try { return await operation(); } finally { await fs.rmdir(lock).catch(() => undefined); } }
export async function createCase(storageDir: string, name: string, title?: string, now = new Date()): Promise<CaseMetadata> { const slug = slugify(name); const dir = caseDir(storageDir, name); await ensureStore(storageDir); 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 listCases(storageDir: string): Promise<CaseMetadata[]> { await ensureStore(storageDir); const names = await fs.readdir(storageDir); const cases: CaseMetadata[] = []; for (const name of names.sort()) { try { cases.push(await loadCase(storageDir, name)); } catch (error) { if (error instanceof ClipcaseError && error.exitCode === 2) continue; throw error; } } 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> { caseDir(storageDir, input.caseName); 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); return withCaseLock(storageDir, input.caseName, async () => { 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; } } try { meta.entries.push(entry); meta.updatedAt = createdAt; await saveCase(storageDir, meta); return entry; } catch (error) { await fs.rm(path.join(caseDir(storageDir, meta.name), entry.path), { force: true }); throw error; } }); }
function isAlreadyExists(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'EEXIST'; }
function hasCode(error: unknown, code: string): boolean { return error instanceof Error && 'code' in error && error.code === code; }
function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); }
function fenceFor(text: string): string { const longest = Math.max(0, ...Array.from(text.matchAll(/`+/g), (match) => match[0].length)); return '`'.repeat(Math.max(3, longest + 1)); }
function serializedLabel(value: string): string { return JSON.stringify(value).slice(1, -1); }
function codeSpan(value: string): string { const serialized = serializedLabel(value); const longest = Math.max(0, ...Array.from(serialized.matchAll(/`+/g), (match) => match[0].length)); const delimiter = '`'.repeat(longest + 1); return `${delimiter}${serialized}${delimiter}`; }
Expand Down
28 changes: 28 additions & 0 deletions test/clipcase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { test } from 'node:test';
import { addEntry, createCase, exportCase, listCases, loadCase, readEntryText, 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-')); }
function cli(cwd: string, ...args: string[]) { return spawnSync(process.execPath, [path.resolve('dist/src/cli.js'), ...args], { cwd, encoding: 'utf8' }); }
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('rejects case identifiers that do not produce a meaningful slug', async () => {
const dir = await tmp();
Expand All @@ -25,6 +27,32 @@ test('rejects case identifiers that do not produce a meaningful slug', async ()
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('distinguishes missing, malformed, and unreadable case indexes', async () => {
const dir = await tmp();
await assert.rejects(() => loadCase(dir, 'missing'), (error: unknown) => error instanceof Error && error.message === 'Case not found: missing');
await createCase(dir, 'malformed');
await fs.writeFile(path.join(dir, 'malformed', 'index.json'), '{broken');
await assert.rejects(() => loadCase(dir, 'malformed'), /Invalid case metadata for malformed .*index\.json/);
await fs.mkdir(path.join(dir, 'unreadable', 'index.json'), { recursive: true });
await assert.rejects(() => loadCase(dir, 'unreadable'), /Cannot read case metadata for unreadable .*index\.json/);
await assert.rejects(() => listCases(dir), /Invalid case metadata for malformed/);
});
test('CLI list and show report corrupt metadata while missing show keeps exit 2', async () => {
const cwd = await tmp();
await writeConfig('.clipcase', cwd);
const storage = path.join(cwd, '.clipcase');
await createCase(storage, 'broken');
await fs.writeFile(path.join(storage, 'broken', 'index.json'), '{broken');
const list = cli(cwd, 'list');
assert.equal(list.status, 4);
assert.match(list.stderr, /Invalid case metadata for broken .*index\.json/);
const show = cli(cwd, 'show', 'broken');
assert.equal(show.status, 4);
assert.match(show.stderr, /Invalid case metadata for broken .*index\.json/);
const missing = cli(cwd, 'show', 'missing');
assert.equal(missing.status, 2);
assert.equal(missing.stderr, 'Case not found: missing\n');
});
test('round-trips arbitrary entry text and safely serializes metadata', async () => {
const dir = await tmp();
await createCase(dir, 'hostile-markdown');
Expand Down
Loading