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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ Linux and Windows users can pipe text on stdin. `--clipboard` uses `pbpaste`, `x
- `clipcase search <query> [--json]` searches entry text, tags, and source labels offline.
- `clipcase export <case> [--out <file>]` produces a single Markdown bundle. Without `--out` it writes the bundle to stdout; with `--out` it creates missing parent directories and writes the file.

Case identifiers are trimmed and lowercased; runs of characters outside
`a-z`, `0-9`, `.`, `_`, and `-` become `-`, and leading or trailing `-`
characters are removed. The result must contain at least one allowed character:
identifiers such as `Bug Login` become `bug-login`, while punctuation-only
identifiers such as `!!!` are rejected instead of being mapped to another case.

## Storage format

By default ClipCase writes to `.clipcase/`. `clipcase init --storage notes/cases` writes `.clipcase.json`. `CLIPCASE_HOME=/tmp/cases` overrides config.
Expand Down
5 changes: 4 additions & 1 deletion docs/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
ClipCase stores plain files so users can inspect, diff, back up, or delete casefiles without the CLI.

- `.clipcase.json` optionally points commands at a storage directory.
- Each case directory is named with the case slug.
- Each case directory is named with the case slug. Slugs are lowercase and may
contain ASCII letters, digits, `.`, `_`, and `-`; other character runs are
normalized to `-`. An identifier must produce a non-empty slug, so blank or
punctuation-only identifiers are rejected before case data is read or written.
- `index.json` contains case metadata and entry metadata.
- `entries/*.md` contains front matter, hash metadata, and fenced plaintext.

Expand Down
8 changes: 4 additions & 4 deletions src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ const LOCK_DIR = '.index.lock';
const LOCK_RETRY_MS = 25;
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 dir = path.resolve(root, slugify(caseName)); if (dir === root || path.dirname(dir) !== root) throw new ClipcaseError(`Invalid case name: ${caseName}`); return dir; }
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> { try { return JSON.parse(await fs.readFile(await indexPath(storageDir, caseName), '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); try { return JSON.parse(await fs.readFile(target, 'utf8')) as CaseMetadata; } catch { throw new ClipcaseError(`Case not found: ${caseName}`, 2); } }
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> { 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 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 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); 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; } }); }
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 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); }
Expand Down
2 changes: 1 addition & 1 deletion src/util.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto';
import path from 'node:path';
export function slugify(input: string): string { return input.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'case'; }
export function slugify(input: string): string { return input.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); }
export function sha256(text: string): string { return createHash('sha256').update(text).digest('hex'); }
export function shortHash(text: string): string { return sha256(text).slice(0, 12); }
export function toPosix(p: string): string { return p.split(path.sep).join('/'); }
Expand Down
22 changes: 22 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,28 @@ describe('clipcase CLI', () => {
await fs.access(path.join(cwd, 'store', 'normal-case', 'index.json'));
});

it('rejects punctuation-only case identifiers without changing the case slug', async () => {
const cwd = await tmp();

run(['init'], cwd);
run(['new', 'case', '--title', 'Existing Case'], cwd);
const index = path.join(cwd, '.clipcase', 'case', 'index.json');
const before = await fs.readFile(index, 'utf8');

for (const [args, input] of [
[['new', '!!!'], undefined],
[['add', '@@@'], 'must not be saved\n'],
[['show', '###'], undefined],
] as Array<[string[], string | undefined]>) {
const result = runResult(args, cwd, input);
assert.notEqual(result.status, 0, args.join(' '));
assert.match(result.stderr, /Invalid case name/);
}

assert.equal(await fs.readFile(index, 'utf8'), before);
assert.deepEqual(await fs.readdir(path.join(cwd, '.clipcase', 'case', 'entries')), []);
});

it('rejects unknown commands with usage', async () => {
const cwd = await tmp();

Expand Down
14 changes: 14 additions & 0 deletions test/clipcase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ 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('rejects case identifiers that do not produce a meaningful slug', async () => {
const dir = await tmp();
const existing = await createCase(dir, 'case', 'Existing Case', new Date('2026-01-01T00:00:00.000Z'));
const index = path.join(dir, 'case', 'index.json');
const before = await fs.readFile(index, 'utf8');

await assert.rejects(() => createCase(dir, '!!!'), /Invalid case name: !!!/);
await assert.rejects(() => loadCase(dir, '@@@'), /Invalid case name: @@@/);
await assert.rejects(() => addEntry(dir, { caseName: '###', text: 'must not be saved' }), /Invalid case name: ###/);

assert.equal(await fs.readFile(index, 'utf8'), before);
assert.deepEqual(await fs.readdir(path.join(dir, 'case', 'entries')), []);
assert.deepEqual(await loadCase(dir, 'case'), existing);
});
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/); });
Expand Down
Loading