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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ 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.
By default ClipCase writes to `.clipcase/`. `clipcase init --storage notes/cases` writes `.clipcase.json`. Its optional `storageDir` must be a string; relative paths resolve beside the config file. Malformed, unreadable, or wrongly shaped configuration produces a concise repair diagnostic. `CLIPCASE_HOME=/tmp/cases` overrides config.

```text
.clipcase/bug-login/
Expand Down
7 changes: 6 additions & 1 deletion docs/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

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.
- `.clipcase.json` optionally points commands at a storage directory. It must be
a readable JSON object whose optional `storageDir` value is a string. Relative
paths resolve from the directory containing the configuration file; absolute
paths and `~/` home-relative paths are also accepted. If the file is unreadable,
malformed, or has the wrong shape, ClipCase stops with a concise diagnostic.
Repair or replace the file before retrying.
- 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
Expand Down
3 changes: 2 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import type { ClipcaseConfig } from './types.js';
import { ClipcaseError } from './errors.js';
export const CONFIG_FILE = '.clipcase.json';
export async function findConfig(start = process.cwd()): Promise<string | undefined> { let dir = path.resolve(start); while (true) { const candidate = path.join(dir, CONFIG_FILE); try { await fs.access(candidate); return candidate; } catch {} const parent = path.dirname(dir); if (parent === dir) return undefined; dir = parent; } }
function expandHome(p: string): string { return p === '~' ? os.homedir() : p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p; }
export async function loadConfig(cwd = process.cwd()): Promise<ClipcaseConfig> { const explicit = process.env.CLIPCASE_HOME; if (explicit) return { storageDir: path.resolve(expandHome(explicit)) }; const configPath = await findConfig(cwd); if (!configPath) return { storageDir: path.join(cwd, '.clipcase') }; const raw = JSON.parse(await fs.readFile(configPath, 'utf8')) as Partial<ClipcaseConfig>; const base = path.dirname(configPath); const configured = expandHome(raw.storageDir ?? '.clipcase'); return { storageDir: path.isAbsolute(configured) ? configured : path.resolve(base, configured) }; }
export async function loadConfig(cwd = process.cwd()): Promise<ClipcaseConfig> { const explicit = process.env.CLIPCASE_HOME; if (explicit) return { storageDir: path.resolve(expandHome(explicit)) }; const configPath = await findConfig(cwd); if (!configPath) return { storageDir: path.join(cwd, '.clipcase') }; let contents: string; try { contents = await fs.readFile(configPath, 'utf8'); } catch { throw new ClipcaseError(`Cannot read configuration at ${configPath}. Check that it is a readable JSON file.`); } let raw: unknown; try { raw = JSON.parse(contents); } catch { throw new ClipcaseError(`Invalid JSON in configuration at ${configPath}. Repair or replace the file and retry.`); } if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new ClipcaseError(`Configuration at ${configPath} must be a JSON object.`); const storageDir = (raw as Record<string, unknown>).storageDir; if (storageDir !== undefined && typeof storageDir !== 'string') throw new ClipcaseError(`Configuration storageDir must be a string in ${configPath}.`); const base = path.dirname(configPath); const configured = expandHome(storageDir ?? '.clipcase'); return { storageDir: path.isAbsolute(configured) ? configured : path.resolve(base, configured) }; }
export async function writeConfig(storageDir = '.clipcase', cwd = process.cwd()): Promise<string> { const target = path.join(cwd, CONFIG_FILE); await fs.writeFile(target, JSON.stringify({ storageDir }, null, 2) + '\n', { flag: 'wx' }); return target; }
22 changes: 22 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,28 @@ describe('clipcase CLI', () => {
assert.match(run(['export', 'bug-login'], cwd), /expired cookie causes redirect failure/);
});

it('reports invalid and unreadable configuration without a stack trace', async () => {
for (const [contents, expected] of [
['{broken\n', /Invalid JSON in .*\.clipcase\.json/],
['[]\n', /must be a JSON object/],
['{"storageDir":42}\n', /storageDir must be a string/],
] as Array<[string, RegExp]>) {
const cwd = await tmp();
await fs.writeFile(path.join(cwd, '.clipcase.json'), contents);
const result = runResult(['list'], cwd);
assert.equal(result.status, 1);
assert.match(result.stderr, expected);
assert.doesNotMatch(result.stderr, /\n\s+at /);
}

const cwd = await tmp();
await fs.mkdir(path.join(cwd, '.clipcase.json'));
const result = runResult(['list'], cwd);
assert.equal(result.status, 1);
assert.match(result.stderr, /Cannot read configuration/);
assert.doesNotMatch(result.stderr, /\n\s+at /);
});

it('creates missing parent directories for an export destination', async () => {
const cwd = await tmp();

Expand Down
2 changes: 1 addition & 1 deletion test/clipcase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,4 @@ test('round-trips arbitrary entry text and safely serializes metadata', async ()
assert.match(exported, /- Tags: `brackets \[x\]` `comma, tag` `line\\nbreak` ``quote \\" and ` tick``/);
assert.match(exported, /\n``````text\nbefore\n```\nmiddle\n`````text\nafter\n``````\n/);
});
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')); });
test('writes and loads relative and absolute local config paths', async () => { const dir = await tmp(); await writeConfig('notes', dir); const relative = await loadConfig(dir); assert.equal(relative.storageDir, path.join(dir, 'notes')); const absoluteDir = path.join(await tmp(), 'cases'); await fs.writeFile(path.join(dir, '.clipcase.json'), JSON.stringify({ storageDir: absoluteDir })); const absolute = await loadConfig(dir); assert.equal(absolute.storageDir, absoluteDir); });
Loading