From 4e091e82ea62f54d36d429aa095abb501a579b2b Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Mon, 7 Sep 2026 03:29:17 +1000 Subject: [PATCH 1/3] test: cover invalid configuration diagnostics --- test/cli.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/cli.test.ts b/test/cli.test.ts index 552c4af..f88b0ba 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -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(); From d9223175ab12f8b4d30f7c1bd6fc98dbd0253f8b Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Mon, 7 Sep 2026 03:29:56 +1000 Subject: [PATCH 2/3] fix: validate local configuration --- src/config.ts | 3 ++- test/clipcase.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 09f1b0e..a2ece81 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 { 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 { 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; 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 { 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).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 { const target = path.join(cwd, CONFIG_FILE); await fs.writeFile(target, JSON.stringify({ storageDir }, null, 2) + '\n', { flag: 'wx' }); return target; } diff --git a/test/clipcase.test.ts b/test/clipcase.test.ts index 99b6536..875dd38 100644 --- a/test/clipcase.test.ts +++ b/test/clipcase.test.ts @@ -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); }); From 51a632480dbefe265bb3ec3fb5b11ca214c99c76 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Mon, 7 Sep 2026 03:30:16 +1000 Subject: [PATCH 3/3] docs: describe configuration recovery --- README.md | 2 +- docs/STORAGE.md | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0be7869..a686fb1 100644 --- a/README.md +++ b/README.md @@ -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/ diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 158e1a4..e3a4cee 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -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