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
8 changes: 8 additions & 0 deletions docs/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@ 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.

## Concurrent writers

Adds to the same case are serialized with a case-local `.index.lock` directory.
A writer retries every 25 ms for up to 10 seconds, then exits with an error rather
than overwriting another writer's metadata. `index.json` is written to a temporary
file and atomically renamed, so readers see either the previous complete index or
the new complete index, never a partially written JSON document.
8 changes: 6 additions & 2 deletions src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ import { findSecrets } from './secrets.js';
import type { AddEntryInput, CaseMetadata, EntryMetadata } from './types.js';
import { escapeMarkdown, formatTags, sha256, shortHash, slugify, toPosix } from './util.js';
const INDEX_FILE = 'index.json';
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; }
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); } }
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'); }
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 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 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; }
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; } }); }
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; }
Expand Down
36 changes: 35 additions & 1 deletion test/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { execFileSync, spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
Expand All @@ -24,6 +24,18 @@ function runResult(args: string[], cwd: string, input?: string) {
return spawnSync(process.execPath, [cliPath, ...args], { cwd, encoding: 'utf8', input });
}

function runAsync(args: string[], cwd: string, input: string): Promise<{ code: number | null; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [cliPath, ...args], { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk; });
child.stderr.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk; });
child.on('close', (code) => resolve({ code, stdout, stderr }));
child.stdin.end(input);
});
}

describe('clipcase CLI', () => {
it('prints current casefile commands in help output', async () => {
const cwd = await tmp();
Expand Down Expand Up @@ -66,6 +78,28 @@ describe('clipcase CLI', () => {
assert.match(run(['export', 'duplicates'], cwd), /- Entries: 4/);
});

it('preserves every entry added by concurrent CLI processes', async () => {
const cwd = await tmp();
const inputs = Array.from({ length: 12 }, (_, index) => `parallel capture ${String(index).padStart(2, '0')}\n`);

run(['init'], cwd);
run(['new', 'concurrent'], cwd);
const results = await Promise.all(inputs.map((input) => runAsync(['add', 'concurrent', '--source', 'parallel'], cwd, input)));
for (const result of results) {
assert.equal(result.code, 0, result.stderr);
assert.match(result.stdout, /Added .* to concurrent/);
}

const shown = JSON.parse(run(['show', 'concurrent'], cwd)) as { entries: Array<{ id: string; path: string }> };
assert.equal(shown.entries.length, inputs.length);
assert.equal(new Set(shown.entries.map((entry) => entry.id)).size, inputs.length);
assert.equal(run(['search', 'parallel capture'], cwd).trim().split('\n').length, inputs.length);
const exported = run(['export', 'concurrent'], cwd);
for (const input of inputs) assert.match(exported, new RegExp(input.trim()));
const entryFiles = (await fs.readdir(path.join(cwd, '.clipcase', 'concurrent', 'entries'))).sort();
assert.deepEqual(entryFiles, shown.entries.map((entry) => path.basename(entry.path)).sort());
});

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

Expand Down
Loading