From eff556989239ac4a2241b43cd9bdf14383e48b8c Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 22 Feb 2026 10:49:49 -0800 Subject: [PATCH 01/11] fix(cli): restore via canonical manifest reader Route CLI restore through cas.readManifest() to preserve v2 Merkle sub-manifest reconstitution. Add integration regression coverage for --oid restore on forced Merkle manifests. --- bin/git-cas.js | 18 +--------- test/integration/vault-cli.test.js | 53 ++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/bin/git-cas.js b/bin/git-cas.js index 28b3931..845213c 100755 --- a/bin/git-cas.js +++ b/bin/git-cas.js @@ -4,7 +4,6 @@ import { readFileSync } from 'node:fs'; import { program } from 'commander'; import GitPlumbing, { ShellRunnerFactory } from '@git-stunts/plumbing'; import ContentAddressableStore from '../index.js'; -import Manifest from '../src/domain/value-objects/Manifest.js'; program .name('git-cas') @@ -70,20 +69,6 @@ async function resolveEncryptionKey(cas, opts) { return undefined; } -/** - * Read the manifest from a tree OID. - */ -async function readManifestFromTree(service, treeOid) { - const entries = await service.persistence.readTree(treeOid); - const entry = entries.find((e) => e.name.startsWith('manifest.')); - if (!entry) { - process.stderr.write('error: No manifest found in tree\n'); - process.exit(1); - } - const blob = await service.persistence.readBlob(entry.oid); - return new Manifest(service.codec.decode(blob)); -} - /** * Validate --slug / --oid flags (exactly one required). */ @@ -172,8 +157,7 @@ program validateRestoreFlags(opts); const cas = createCas(opts.cwd); const treeOid = opts.oid || await cas.resolveVaultEntry({ slug: opts.slug }); - const service = await cas.getService(); - const manifest = await readManifestFromTree(service, treeOid); + const manifest = await cas.readManifest({ treeOid }); const restoreOpts = { manifest }; const encryptionKey = await resolveEncryptionKey(cas, opts); diff --git a/test/integration/vault-cli.test.js b/test/integration/vault-cli.test.js index 2c197d3..d9aff9c 100644 --- a/test/integration/vault-cli.test.js +++ b/test/integration/vault-cli.test.js @@ -16,6 +16,8 @@ import { execSync } from 'node:child_process'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import GitPlumbing from '@git-stunts/plumbing'; +import ContentAddressableStore from '../../index.js'; // Hard gate: refuse to run outside Docker if (process.env.GIT_STUNTS_DOCKER !== '1') { @@ -181,3 +183,54 @@ describe.skipIf(IS_BUN)('vault CLI — encrypted workflow', () => { rmSync(outDir, { recursive: true, force: true }); }); }); + +// --------------------------------------------------------------------------- +// CLI restore --oid with Merkle manifests +// --------------------------------------------------------------------------- +describe.skipIf(IS_BUN)('vault CLI — restore --oid with Merkle manifest', () => { + let merkleRepoDir; + let merkleInputFile; + let merkleInputDir; + let merkleTreeOid; + const merkleOriginal = Buffer.alloc(6 * 1024); + + beforeAll(async () => { + for (let i = 0; i < merkleOriginal.length; i++) { + merkleOriginal[i] = i % 251; + } + + merkleRepoDir = mkdtempSync(path.join(os.tmpdir(), 'cas-cli-merkle-integ-')); + execSync('git init --bare', { cwd: merkleRepoDir, stdio: 'ignore' }); + ({ filePath: merkleInputFile, dir: merkleInputDir } = tempFile(merkleOriginal)); + + const plumbing = GitPlumbing.createDefault({ cwd: merkleRepoDir }); + const cas = new ContentAddressableStore({ + plumbing, + chunkSize: 1024, + merkleThreshold: 2, // Force v2 Merkle manifests for this fixture. + }); + + const manifest = await cas.storeFile({ + filePath: merkleInputFile, + slug: 'merkle/asset', + }); + merkleTreeOid = await cas.createTree({ manifest }); + }); + + afterAll(() => { + rmSync(merkleRepoDir, { recursive: true, force: true }); + rmSync(merkleInputDir, { recursive: true, force: true }); + }); + + it('restores full content via --oid for v2 manifests', () => { + const outDir = mkdtempSync(path.join(os.tmpdir(), 'cas-cli-merkle-out-')); + const outPath = path.join(outDir, 'restored.bin'); + const bytesWritten = cli(`restore --oid ${merkleTreeOid} --out ${outPath}`, merkleRepoDir); + + expect(bytesWritten).toBe(String(merkleOriginal.length)); + + const restored = readFileSync(outPath); + expect(restored.equals(merkleOriginal)).toBe(true); + rmSync(outDir, { recursive: true, force: true }); + }); +}); From c7c8abc0f0e26a382d25d7c883162f4c03d417d3 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 26 Feb 2026 22:27:58 -0800 Subject: [PATCH 02/11] feat(cli): add interactive vault dashboard TUI (M13.2) TEA-based full-screen dashboard for browsing vault entries and inspecting manifests, powered by @flyingrobots/bijou-tui. Progressively loads all manifests, supports j/k navigation, / filtering, J/K detail scrolling, and non-TTY static fallback. --- bin/git-cas.js | 88 +++++++++++++- bin/ui/dashboard-cmds.js | 34 ++++++ bin/ui/dashboard-view.js | 123 ++++++++++++++++++++ bin/ui/dashboard.js | 196 ++++++++++++++++++++++++++++++++ bin/ui/manifest-view.js | 135 ++++++++++++++++++++++ test/unit/cli/dashboard.test.js | 150 ++++++++++++++++++++++++ 6 files changed, 724 insertions(+), 2 deletions(-) create mode 100644 bin/ui/dashboard-cmds.js create mode 100644 bin/ui/dashboard-view.js create mode 100644 bin/ui/dashboard.js create mode 100644 bin/ui/manifest-view.js create mode 100644 test/unit/cli/dashboard.test.js diff --git a/bin/git-cas.js b/bin/git-cas.js index 845213c..fc74db4 100755 --- a/bin/git-cas.js +++ b/bin/git-cas.js @@ -4,11 +4,18 @@ import { readFileSync } from 'node:fs'; import { program } from 'commander'; import GitPlumbing, { ShellRunnerFactory } from '@git-stunts/plumbing'; import ContentAddressableStore from '../index.js'; +import Manifest from '../src/domain/value-objects/Manifest.js'; +import { createStoreProgress, createRestoreProgress } from './ui/progress.js'; +import { renderEncryptionCard } from './ui/encryption-card.js'; +import { renderHistoryTimeline } from './ui/history-timeline.js'; +import { renderManifestView } from './ui/manifest-view.js'; +import { renderHeatmap } from './ui/heatmap.js'; program .name('git-cas') .description('Content Addressable Storage backed by Git') - .version('3.0.0'); + .version('3.0.0') + .option('-q, --quiet', 'Suppress progress output'); /** * Read a 32-byte raw encryption key from a file. @@ -104,7 +111,13 @@ program storeOpts.encryptionKey = encryptionKey; } + const service = await cas.getService(); + const progress = createStoreProgress({ + filePath: file, chunkSize: cas.chunkSize, quiet: program.opts().quiet, + }); + progress.attach(service); const manifest = await cas.storeFile(storeOpts); + progress.detach(); if (opts.tree) { const treeOid = await cas.createTree({ manifest }); @@ -140,6 +153,43 @@ program } }); +// --------------------------------------------------------------------------- +// inspect +// --------------------------------------------------------------------------- +program + .command('inspect') + .description('Inspect a stored manifest') + .option('--slug ', 'Resolve tree OID from vault slug') + .option('--oid ', 'Direct tree OID') + .option('--heatmap', 'Show chunk heatmap visualization') + .option('--cwd ', 'Git working directory', '.') + .action(async (opts) => { + try { + if (opts.slug && opts.oid) { + process.stderr.write('error: Provide --slug or --oid, not both\n'); + process.exit(1); + } + if (!opts.slug && !opts.oid) { + process.stderr.write('error: Provide --slug or --oid \n'); + process.exit(1); + } + const cas = createCas(opts.cwd); + const treeOid = opts.oid || await cas.resolveVaultEntry({ slug: opts.slug }); + const manifest = await cas.readManifest({ treeOid }); + + if (opts.heatmap) { + process.stdout.write(renderHeatmap({ manifest })); + } else if (process.stdout.isTTY) { + process.stdout.write(renderManifestView({ manifest })); + } else { + process.stdout.write(`${JSON.stringify(manifest.toJSON(), null, 2)}\n`); + } + } catch (err) { + process.stderr.write(`error: ${err.message}\n`); + process.exit(1); + } + }); + // --------------------------------------------------------------------------- // restore // --------------------------------------------------------------------------- @@ -165,10 +215,16 @@ program restoreOpts.encryptionKey = encryptionKey; } + const service = await cas.getService(); + const progress = createRestoreProgress({ + totalChunks: manifest.chunks.length, quiet: program.opts().quiet, + }); + progress.attach(service); const { bytesWritten } = await cas.restoreFile({ ...restoreOpts, outputPath: opts.out, }); + progress.detach(); process.stdout.write(`${bytesWritten}\n`); } catch (err) { process.stderr.write(`error: ${err.message}\n`); @@ -250,6 +306,7 @@ vault vault .command('info ') .description('Show info for a vault entry') + .option('--encryption', 'Show vault encryption details') .option('--cwd ', 'Git working directory', '.') .action(async (slug, opts) => { try { @@ -257,6 +314,10 @@ vault const treeOid = await cas.resolveVaultEntry({ slug }); process.stdout.write(`slug\t${slug}\n`); process.stdout.write(`tree\t${treeOid}\n`); + if (opts.encryption) { + const metadata = await cas.getVaultMetadata(); + process.stdout.write(`\n${renderEncryptionCard({ metadata })}\n`); + } } catch (err) { process.stderr.write(`error: ${err.message}\n`); process.exit(1); @@ -271,6 +332,7 @@ vault .description('Show vault commit history') .option('--cwd ', 'Git working directory', '.') .option('-n, --max-count ', 'Limit number of commits') + .option('--pretty', 'Render as color-coded timeline') .action(async (opts) => { try { const runner = ShellRunnerFactory.create(); @@ -285,7 +347,29 @@ vault args.push(`-${n}`); } const output = await plumbing.execute({ args }); - process.stdout.write(`${output}\n`); + if (opts.pretty && process.stdout.isTTY) { + process.stdout.write(`${renderHistoryTimeline(output)}\n`); + } else { + process.stdout.write(`${output}\n`); + } + } catch (err) { + process.stderr.write(`error: ${err.message}\n`); + process.exit(1); + } + }); + +// --------------------------------------------------------------------------- +// vault dashboard +// --------------------------------------------------------------------------- +vault + .command('dashboard') + .description('Interactive vault explorer') + .option('--cwd ', 'Git working directory', '.') + .action(async (opts) => { + try { + const cas = createCas(opts.cwd); + const { launchDashboard } = await import('./ui/dashboard.js'); + await launchDashboard(cas); } catch (err) { process.stderr.write(`error: ${err.message}\n`); process.exit(1); diff --git a/bin/ui/dashboard-cmds.js b/bin/ui/dashboard-cmds.js new file mode 100644 index 0000000..3b385ac --- /dev/null +++ b/bin/ui/dashboard-cmds.js @@ -0,0 +1,34 @@ +/** + * Async command factories for the vault dashboard. + */ + +/** + * Load vault entries and metadata in parallel. + */ +export function loadEntriesCmd(cas) { + return async () => { + try { + const [entries, metadata] = await Promise.all([ + cas.listVault(), + cas.getVaultMetadata(), + ]); + return { type: 'loaded-entries', entries, metadata }; + } catch (err) { + return { type: 'load-error', error: err.message }; + } + }; +} + +/** + * Load a single manifest by slug and tree OID. + */ +export function loadManifestCmd(cas, slug, treeOid) { + return async () => { + try { + const manifest = await cas.readManifest({ treeOid }); + return { type: 'loaded-manifest', slug, manifest }; + } catch (err) { + return { type: 'load-error', error: err.message }; + } + }; +} diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js new file mode 100644 index 0000000..c6fb2d0 --- /dev/null +++ b/bin/ui/dashboard-view.js @@ -0,0 +1,123 @@ +/** + * Pure render functions for the vault dashboard. + */ + +import { badge } from '@flyingrobots/bijou'; +import { flex, viewport } from '@flyingrobots/bijou-tui'; +import { renderManifestView } from './manifest-view.js'; + +/** + * Format bytes as compact string. + */ +function formatSize(bytes) { + if (bytes < 1024) { return `${bytes}B`; } + if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)}K`; } + if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(1)}M`; } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`; } + +/** + * Format manifest stats for the list. + */ +function formatStats(manifest) { + const m = manifest.toJSON ? manifest.toJSON() : manifest; + return `${formatSize(m.size)} ${m.chunks?.length ?? 0}c`; +} + +/** + * Render a single list item. + */ +function renderListItem(entry, index, model) { + const prefix = index === model.cursor ? '> ' : ' '; + const manifest = model.manifestCache.get(entry.slug); + const stats = manifest ? formatStats(manifest) : '...'; + return `${prefix}${entry.slug} ${stats}`; +} + +/** + * Compute visible window for cursor scrolling. + */ +function visibleRange(cursor, total, height) { + const start = Math.max(0, Math.min(cursor - Math.floor(height / 2), total - height)); + return { start: Math.max(0, start), end: Math.min(Math.max(0, start) + height, total) }; +} + +/** + * Render the header line. + */ +function renderHeader(model, ctx) { + const parts = []; + if (model.metadata?.encryption) { + parts.push(badge('encrypted', { variant: 'warning', ctx })); + } + parts.push(`${model.entries.length} entries`); + parts.push('refs/cas/vault'); + return parts.join(' '); +} + +/** + * Render the list pane. + */ +function renderListPane(model, size) { + const filterLine = model.filtering ? `/${model.filterText}\u2588` : ''; + const listHeight = model.filtering ? size.height - 1 : size.height; + const items = model.filtered; + + if (items.length === 0) { + const msg = model.status === 'loading' ? 'Loading...' : 'No entries'; + return padToHeight(msg, listHeight, filterLine); + } + + const { start, end } = visibleRange(model.cursor, items.length, listHeight); + const lines = []; + for (let i = start; i < end; i++) { + lines.push(renderListItem(items[i], i, model)); + } + return padToHeight(lines.join('\n'), listHeight, filterLine); +} + +/** + * Pad content to target height, optionally appending a suffix line. + */ +function padToHeight(content, height, suffix) { + const lines = content.split('\n'); + while (lines.length < height) { lines.push(''); } + return suffix ? `${lines.join('\n')}\n${suffix}` : lines.join('\n'); +} + +/** + * Render the detail pane with viewport scrolling. + */ +function renderDetailPane(model, opts) { + const entry = model.filtered[model.cursor]; + if (!entry) { return ''; } + const manifest = model.manifestCache.get(entry.slug); + if (!manifest) { return 'Loading manifest...'; } + const content = renderManifestView({ manifest, ctx: opts.ctx }); + return viewport({ width: opts.width, height: opts.height, content, scrollY: model.detailScroll }); +} + +/** + * Render the body with list and detail panes. + */ +function renderBody(model, deps, size) { + const listBasis = Math.floor(size.width * 0.35); + return flex( + { direction: 'row', width: size.width, height: size.height, gap: 1 }, + { content: (_w, h) => renderListPane(model, { height: h }), basis: listBasis }, + { content: (w, h) => renderDetailPane(model, { width: w, height: h, ctx: deps.ctx }), flex: 1 }, + ); +} + +/** + * Render the full dashboard layout. + */ +export function renderDashboard(model, deps) { + return flex( + { direction: 'column', width: model.columns, height: model.rows }, + { content: renderHeader(model, deps.ctx), basis: 1 }, + { content: (w, _h) => '\u2500'.repeat(w), basis: 1 }, + { content: (w, h) => renderBody(model, deps, { width: w, height: h }), flex: 1 }, + { content: (w, _h) => '\u2500'.repeat(w), basis: 1 }, + { content: 'j/k Navigate enter Load / Filter J/K Scroll q Quit', basis: 1 }, + ); +} diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js new file mode 100644 index 0000000..a2c0bfa --- /dev/null +++ b/bin/ui/dashboard.js @@ -0,0 +1,196 @@ +/** + * TEA app shell for the vault dashboard. + */ + +import { run, quit, createKeyMap } from '@flyingrobots/bijou-tui'; +import { createNodeContext } from '@flyingrobots/bijou-node'; +import { loadEntriesCmd, loadManifestCmd } from './dashboard-cmds.js'; +import { renderDashboard } from './dashboard-view.js'; + +/** + * Create keyboard bindings for normal mode. + */ +export function createKeyBindings() { + return createKeyMap() + .bind('q', 'Quit', { type: 'quit' }) + .bind('j', 'Down', { type: 'move', delta: 1 }) + .bind('down', 'Down', { type: 'move', delta: 1 }) + .bind('k', 'Up', { type: 'move', delta: -1 }) + .bind('up', 'Up', { type: 'move', delta: -1 }) + .bind('enter', 'Load', { type: 'select' }) + .bind('/', 'Filter', { type: 'filter-start' }) + .bind('shift+j', 'Scroll down', { type: 'scroll-detail', delta: 3 }) + .bind('shift+k', 'Scroll up', { type: 'scroll-detail', delta: -3 }); +} + +/** + * Create the initial model. + */ +function createInitModel() { + return { + status: 'loading', + columns: process.stdout.columns ?? 80, + rows: process.stdout.rows ?? 24, + entries: [], + filtered: [], + cursor: 0, + filterText: '', + filtering: false, + metadata: null, + manifestCache: new Map(), + loadingSlug: null, + detailScroll: 0, + error: null, + }; +} + +/** + * Apply filter text to entries. + */ +function applyFilter(entries, text) { + if (!text) { return entries; } + return entries.filter(e => e.slug.includes(text)); +} + +/** + * Handle the loaded-entries message. + */ +function handleLoadedEntries(msg, model, cas) { + const cmds = msg.entries.map(e => loadManifestCmd(cas, e.slug, e.treeOid)); + return [{ + ...model, + status: 'ready', + entries: msg.entries, + filtered: msg.entries, + metadata: msg.metadata, + }, cmds]; +} + +/** + * Handle a loaded-manifest message. + */ +function handleLoadedManifest(msg, model) { + const cache = new Map(model.manifestCache); + cache.set(msg.slug, msg.manifest); + return [{ ...model, manifestCache: cache }, []]; +} + +/** + * Handle cursor movement. + */ +function handleMove(msg, model) { + const max = model.filtered.length - 1; + const cursor = Math.max(0, Math.min(max, model.cursor + msg.delta)); + return [{ ...model, cursor, detailScroll: 0 }, []]; +} + +/** + * Handle filter key input in filter mode. + */ +function handleFilterKey(msg, model) { + if (msg.key === 'escape' || msg.key === 'enter') { + return [{ ...model, filtering: false }, []]; + } + if (msg.key === 'backspace') { + const text = model.filterText.slice(0, -1); + const filtered = applyFilter(model.entries, text); + return [{ ...model, filterText: text, filtered, cursor: 0 }, []]; + } + if (msg.key.length === 1) { + const text = model.filterText + msg.key; + const filtered = applyFilter(model.entries, text); + return [{ ...model, filterText: text, filtered, cursor: 0 }, []]; + } + return [model, []]; +} + +/** + * Handle select (enter key) to load manifest. + */ +function handleSelect(model, deps) { + const entry = model.filtered[model.cursor]; + if (!entry || model.manifestCache.has(entry.slug)) { + return [model, []]; + } + const cmd = loadManifestCmd(deps.cas, entry.slug, entry.treeOid); + return [{ ...model, loadingSlug: entry.slug }, [cmd]]; +} + +/** + * Handle keymap actions. + */ +function handleAction(action, model, deps) { + if (action.type === 'quit') { return [model, [quit()]]; } + if (action.type === 'move') { return handleMove(action, model); } + if (action.type === 'filter-start') { + return [{ ...model, filtering: true, filterText: '' }, []]; + } + if (action.type === 'scroll-detail') { + const scroll = Math.max(0, model.detailScroll + action.delta); + return [{ ...model, detailScroll: scroll }, []]; + } + if (action.type === 'select') { return handleSelect(model, deps); } + return [model, []]; +} + +/** + * Handle app-level messages (data loading results). + */ +function handleAppMsg(msg, model, cas) { + if (msg.type === 'loaded-entries') { return handleLoadedEntries(msg, model, cas); } + if (msg.type === 'loaded-manifest') { return handleLoadedManifest(msg, model); } + if (msg.type === 'load-error') { return [{ ...model, error: msg.error }, []]; } + return [model, []]; +} + +/** + * Route all update messages to the appropriate handler. + */ +function handleUpdate(msg, model, deps) { + if (msg.type === 'key' && model.filtering) { + return handleFilterKey(msg, model); + } + if (msg.type === 'key') { + const action = deps.keyMap.handle(msg); + if (action) { return handleAction(action, model, deps); } + return [model, []]; + } + if (msg.type === 'resize') { + return [{ ...model, columns: msg.columns, rows: msg.rows }, []]; + } + return handleAppMsg(msg, model, deps.cas); +} + +/** + * Create the TEA app object for the dashboard. + */ +export function createDashboardApp(deps) { + return { + init: () => [createInitModel(), [loadEntriesCmd(deps.cas)]], + update: (msg, model) => handleUpdate(msg, model, deps), + view: (model) => renderDashboard(model, deps), + }; +} + +/** + * Print static list for non-TTY environments. + */ +async function printStaticList(cas) { + const entries = await cas.listVault(); + for (const { slug, treeOid } of entries) { + process.stdout.write(`${slug}\t${treeOid}\n`); + } +} + +/** + * Launch the interactive vault dashboard. + */ +export async function launchDashboard(cas) { + if (!process.stdout.isTTY) { + return printStaticList(cas); + } + const ctx = createNodeContext(); + const keyMap = createKeyBindings(); + const deps = { keyMap, cas, ctx }; + return run(createDashboardApp(deps), { ctx }); +} diff --git a/bin/ui/manifest-view.js b/bin/ui/manifest-view.js new file mode 100644 index 0000000..8f06f89 --- /dev/null +++ b/bin/ui/manifest-view.js @@ -0,0 +1,135 @@ +/** + * Manifest anatomy view — rich visual breakdown of a manifest. + */ + +import { box, badge, table, tree, headerBox } from '@flyingrobots/bijou'; +import { getCliContext } from './context.js'; + +/** + * Format bytes as human-readable string. + */ +function formatBytes(bytes) { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KiB`; + } + if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; + } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`; +} + +/** + * Build the header badges line. + */ +function renderBadges(m, ctx) { + const badges = [badge(`v${m.version}`, { ctx })]; + if (m.encryption) { + badges.push(badge('encrypted', { variant: 'warning', ctx })); + } + if (m.compression) { + badges.push(badge(m.compression.algorithm, { variant: 'info', ctx })); + } + if (m.subManifests?.length) { + badges.push(badge('merkle', { variant: 'info', ctx })); + } + return badges.join(' '); +} + +/** + * Build the encryption section. + */ +function renderEncryptionSection(enc, ctx) { + const rows = [` algorithm ${enc.algorithm}`]; + if (enc.kdf) { + rows.push(` kdf ${enc.kdf.algorithm}`); + if (enc.kdf.iterations) { + rows.push(` iterations ${enc.kdf.iterations.toLocaleString()}`); + } + if (enc.kdf.cost) { + rows.push(` cost ${enc.kdf.cost}`); + } + } + if (enc.nonce) { + rows.push(` nonce ${enc.nonce.slice(0, 16)}...`); + } + if (enc.tag) { + rows.push(` tag ${enc.tag.slice(0, 16)}...`); + } + return `${headerBox('Encryption', { ctx })}\n${box(rows.join('\n'), { ctx })}`; +} + +/** + * Build the chunks section. + */ +function renderChunksSection(chunks, ctx) { + const displayChunks = chunks.slice(0, 20); + const chunkRows = displayChunks.map(c => [ + String(c.index), + formatBytes(c.size), + `${c.digest.slice(0, 12)}...`, + `${c.blob.slice(0, 12)}...`, + ]); + const chunkTable = table({ + columns: [{ header: '#' }, { header: 'Size' }, { header: 'Digest' }, { header: 'Blob' }], + rows: chunkRows, + ctx, + }); + const suffix = chunks.length > 20 + ? `\n ...and ${chunks.length - 20} more` + : ''; + return `${headerBox(`Chunks (${chunks.length})`, { ctx })}\n${chunkTable}${suffix}`; +} + +/** + * Build the metadata section. + */ +function renderMetadataSection(m, ctx) { + const meta = [ + ` slug ${m.slug}`, + ` filename ${m.filename}`, + ` size ${formatBytes(m.size)}`, + ` chunks ${m.chunks?.length ?? 0}`, + ]; + return `${headerBox('Metadata', { ctx })}\n${box(meta.join('\n'), { ctx })}`; +} + +/** + * Build the sub-manifests section. + */ +function renderSubManifestsSection(m, ctx) { + const nodes = m.subManifests.map((sm, i) => ({ + label: `sub-${i} ${sm.chunkCount} chunks start: ${sm.startIndex} oid: ${sm.oid.slice(0, 8)}...`, + })); + return `${headerBox(`Sub-manifests (${m.subManifests.length})`, { ctx })}\n${tree(nodes, { ctx })}`; +} + +/** + * Render a full manifest anatomy view. + * + * @param {Object} options + * @param {Object} options.manifest - The manifest (from readManifest). + * @param {Object} [options.ctx] - Optional bijou context override. + * @returns {string} + */ +export function renderManifestView({ manifest, ctx = getCliContext() }) { + const m = manifest.toJSON ? manifest.toJSON() : manifest; + const sections = [renderBadges(m, ctx), renderMetadataSection(m, ctx)]; + + if (m.encryption) { + sections.push(renderEncryptionSection(m.encryption, ctx)); + } + if (m.compression) { + sections.push(`${headerBox('Compression', { ctx })}\n${box(` algorithm ${m.compression.algorithm}`, { ctx })}`); + } + if (m.subManifests?.length) { + sections.push(renderSubManifestsSection(m, ctx)); + } + if (m.chunks?.length) { + sections.push(renderChunksSection(m.chunks, ctx)); + } + + return `${sections.join('\n\n')}\n`; +} diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js new file mode 100644 index 0000000..b92081d --- /dev/null +++ b/test/unit/cli/dashboard.test.js @@ -0,0 +1,150 @@ +import { describe, it, expect, vi } from 'vitest'; +import { makeCtx } from './_testContext.js'; + +vi.mock('../../../bin/ui/context.js', () => ({ + getCliContext: () => makeCtx(), +})); + +const { createDashboardApp, createKeyBindings } = await import('../../../bin/ui/dashboard.js'); + +function mockCas() { + return { + listVault: vi.fn().mockResolvedValue([]), + getVaultMetadata: vi.fn().mockResolvedValue(null), + readManifest: vi.fn(), + }; +} + +function makeDeps() { + return { keyMap: createKeyBindings(), cas: mockCas(), ctx: makeCtx() }; +} + +function makeModel(overrides = {}) { + return { + status: 'ready', + columns: 80, + rows: 24, + entries: [], + filtered: [], + cursor: 0, + filterText: '', + filtering: false, + metadata: null, + manifestCache: new Map(), + loadingSlug: null, + detailScroll: 0, + error: null, + ...overrides, + }; +} + +function keyMsg(key, opts = {}) { + return { type: 'key', key, ctrl: false, alt: false, shift: false, ...opts }; +} + +const entries = [ + { slug: 'alpha', treeOid: 'aaa111' }, + { slug: 'bravo', treeOid: 'bbb222' }, +]; + +describe('dashboard init and navigation', () => { + it('init returns loading model with one cmd', () => { + const app = createDashboardApp(makeDeps()); + const [model, cmds] = app.init(); + expect(model.status).toBe('loading'); + expect(cmds).toHaveLength(1); + }); + + it('move cursor down', () => { + const app = createDashboardApp(makeDeps()); + const model = makeModel({ filtered: entries, entries }); + const [next] = app.update(keyMsg('j'), model); + expect(next.cursor).toBe(1); + }); + + it('move cursor up clamps at 0', () => { + const app = createDashboardApp(makeDeps()); + const model = makeModel({ filtered: entries, entries }); + const [next] = app.update(keyMsg('k'), model); + expect(next.cursor).toBe(0); + }); + + it('quit returns quit command', () => { + const app = createDashboardApp(makeDeps()); + const [, cmds] = app.update(keyMsg('q'), makeModel()); + expect(cmds).toHaveLength(1); + }); + + it('scroll-detail adjusts offset', () => { + const app = createDashboardApp(makeDeps()); + const [next] = app.update(keyMsg('j', { shift: true }), makeModel()); + expect(next.detailScroll).toBe(3); + }); + + it('resize updates dimensions', () => { + const app = createDashboardApp(makeDeps()); + const [next] = app.update({ type: 'resize', columns: 120, rows: 40 }, makeModel()); + expect(next.columns).toBe(120); + expect(next.rows).toBe(40); + }); +}); + +describe('dashboard data loading', () => { + it('loaded-entries sets entries and fires manifest loads', () => { + const app = createDashboardApp(makeDeps()); + const msg = { type: 'loaded-entries', entries, metadata: null }; + const [next, cmds] = app.update(msg, makeModel({ status: 'loading' })); + expect(next.status).toBe('ready'); + expect(next.entries).toEqual(entries); + expect(cmds).toHaveLength(2); + }); + + it('loaded-manifest caches manifest', () => { + const app = createDashboardApp(makeDeps()); + const manifest = { slug: 'alpha', size: 100, chunks: [] }; + const [next] = app.update({ type: 'loaded-manifest', slug: 'alpha', manifest }, makeModel()); + expect(next.manifestCache.get('alpha')).toBe(manifest); + }); + + it('filter mode captures characters and filters entries', () => { + const app = createDashboardApp(makeDeps()); + const model = makeModel({ filtering: true, entries, filtered: entries }); + const [next] = app.update(keyMsg('l'), model); + expect(next.filterText).toBe('l'); + expect(next.filtered).toHaveLength(1); + expect(next.filtered[0].slug).toBe('alpha'); + }); + + it('escape exits filter mode', () => { + const app = createDashboardApp(makeDeps()); + const model = makeModel({ filtering: true, filterText: 'a' }); + const [next] = app.update(keyMsg('escape'), model); + expect(next.filtering).toBe(false); + }); +}); + +describe('dashboard view rendering', () => { + it('renders without errors on empty model', () => { + const app = createDashboardApp(makeDeps()); + const model = makeModel(); + const output = app.view(model); + expect(typeof output).toBe('string'); + expect(output).toContain('0 entries'); + }); + + it('renders entry list when entries exist', () => { + const app = createDashboardApp(makeDeps()); + const model = makeModel({ entries, filtered: entries }); + const output = app.view(model); + expect(output).toContain('alpha'); + expect(output).toContain('bravo'); + }); + + it('renders footer keybinding hints', () => { + const app = createDashboardApp(makeDeps()); + const model = makeModel(); + const output = app.view(model); + expect(output).toContain('Navigate'); + expect(output).toContain('Quit'); + }); +}); From 62b22788ae0fea1766aed762ffa984c8c26d3b7e Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 26 Feb 2026 22:31:33 -0800 Subject: [PATCH 03/11] =?UTF-8?q?feat(cli):=20add=20M13=20Bijou=20TUI=20co?= =?UTF-8?q?mponents=20(13.1,=2013.3=E2=80=9313.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Progress bars, encryption card, history timeline, manifest view, heatmap, and shared CLI context. Includes tests, README rewrite, roadmap updates, and bijou dependency additions. --- README.md | 28 +- ROADMAP.md | 372 ++++++++++++++++++++++++- bin/ui/context.js | 54 ++++ bin/ui/encryption-card.js | 49 ++++ bin/ui/heatmap.js | 103 +++++++ bin/ui/history-timeline.js | 84 ++++++ bin/ui/progress.js | 126 +++++++++ package.json | 9 + pnpm-lock.yaml | 38 +++ test/unit/cli/_testContext.js | 8 + test/unit/cli/encryption-card.test.js | 54 ++++ test/unit/cli/heatmap.test.js | 53 ++++ test/unit/cli/history-timeline.test.js | 46 +++ test/unit/cli/manifest-view.test.js | 62 +++++ test/unit/cli/progress.test.js | 76 +++++ 15 files changed, 1151 insertions(+), 11 deletions(-) create mode 100644 bin/ui/context.js create mode 100644 bin/ui/encryption-card.js create mode 100644 bin/ui/heatmap.js create mode 100644 bin/ui/history-timeline.js create mode 100644 bin/ui/progress.js create mode 100644 test/unit/cli/_testContext.js create mode 100644 test/unit/cli/encryption-card.test.js create mode 100644 test/unit/cli/heatmap.test.js create mode 100644 test/unit/cli/history-timeline.test.js create mode 100644 test/unit/cli/manifest-view.test.js create mode 100644 test/unit/cli/progress.test.js diff --git a/README.md b/README.md index cf1ee5e..56aaf8e 100644 --- a/README.md +++ b/README.md @@ -131,16 +131,30 @@ git cas store ./secret.bin --slug vault-entry --tree git cas restore --slug vault-entry --out ./decrypted.bin ``` -## Why not Git LFS? +## When to use git-cas (and when not to) -Because sometimes you want the Git object database to be the store: +### "I just want screenshots in my README" -- deterministic -- content-addressed -- locally replicable -- commit-addressable +Use an **orphan branch**. Seriously. It's 5 git commands, zero dependencies, and GitHub renders the images inline. Google "git orphan branch assets" — that's all you need. git-cas is overkill for public images and demo GIFs. -Also because LFS is, well... LFS. +### "I need encrypted secrets / large binaries / deduplicated assets in a Git repo" + +That's git-cas. The orphan branch gives you none of: + +| | Orphan branch | git-cas | +|---|---|---| +| **Encryption** | None — plaintext forever in history | AES-256-GCM + passphrase KDF | +| **Large files** | Bloats `git clone` for everyone | Chunked, restored on demand | +| **Dedup** | None | Chunk-level content addressing | +| **Integrity** | Git SHA-1 | SHA-256 per chunk + GCM auth tag | +| **Lifecycle** | `git rm` (still in reflog) | Vault with audit trail + `git gc` reclaims | +| **Compression** | None | gzip before encryption | + +### "Why not Git LFS?" + +Because sometimes you want the Git object database to be the store — deterministic, content-addressed, locally replicable, commit-addressable — with no external server, no LFS endpoint, and no second system to manage. + +If your team uses GitHub and needs file locking + web UI previews, use LFS. If you want encrypted, self-contained, server-free binary storage that travels with `git clone`, use git-cas. --- diff --git a/ROADMAP.md b/ROADMAP.md index e384587..928ed54 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,6 +164,21 @@ Return and throw semantics for every public method (current and planned). - **Exit 0:** Rotation succeeded, vault updated. - **Exit 1:** Wrong old key, unsupported manifest, or vault error. +### CLI: `git cas vault dashboard` *(planned — Task 13.2)* +- **Output:** Interactive full-screen TUI in TTY mode; static table in non-TTY. +- **Exit 0:** User quit normally. +- **Exit 1:** Vault ref missing or error. + +### CLI: `git cas inspect --slug | --oid [--heatmap]` *(planned — Tasks 13.4, 13.5)* +- **Output:** Structured manifest anatomy view in TTY; JSON dump in non-TTY. +- **Exit 0:** Manifest read and displayed. +- **Exit 1:** Manifest not found or error. + +### CLI: `git cas vault history --pretty` *(planned — Task 13.3)* +- **Output:** Color-coded timeline in TTY; plain `git log --oneline` without `--pretty`. +- **Exit 0:** History displayed. +- **Exit 1:** Vault ref missing or error. + --- ## 4) Version Plan @@ -175,6 +190,7 @@ Return and throw semantics for every public method (current and planned). | v3.0.0 | M10 | Hydra | Content-defined chunking | | | v3.1.0 | M11 | Locksmith | Multi-recipient encryption | | | v3.2.0 | M12 | Carousel | Key rotation | | +| v3.3.0 | M13 | Bijou | TUI dashboard & progress | | --- @@ -187,9 +203,13 @@ M7 Horizon (v2.0.0) ✅ ────────────────── v v v v M8 Spit M9 Cockpit M10 Hydra (v3.0.0) M11 Locksmith (v3.1.0) Shine (v2.2.0) │ │ -(v2.1.0) │ v - v M12 Carousel (v3.2.0) - (CDC benchmarks) +(v2.1.0) │ │ v + │ v M12 Carousel (v3.2.0) + │ (CDC benchmarks) + │ + v + M13 Bijou (v3.3.0) + (TUI dashboard & progress) ``` --- @@ -205,7 +225,8 @@ Shine (v2.2.0) │ │ | M10| Hydra | Content-defined chunking | v3.0.0 | 4 | ~690 | ~22h | | M11| Locksmith | Multi-recipient encryption | v3.1.0 | 4 | ~580 | ~20h | | M12| Carousel | Key rotation | v3.2.0 | 4 | ~400 | ~13h | -| | **Total** | | | **20**| **~2,220** | **~69h** | +| M13| Bijou | TUI dashboard & progress | v3.3.0 | 6 | ~650 | ~20h | +| | **Total** | | | **26**| **~2,870** | **~89h** | --- @@ -1567,6 +1588,20 @@ Every competitor in this space either requires an external server (Git LFS), inv ## When NOT to use git-cas +### 0. You just want images or demos in your README + +**Use instead: an orphan branch** + +**Scenario:** You want to put screenshots, demo GIFs, and logos in your repo's README. The assets are public, small (< 5 MB each), and you want GitHub to render them inline. + +**Why not git-cas:** It's overkill. You don't need encryption, chunking, manifests, or a vault for a 200 KiB screenshot. git-cas adds a dependency, a CLI workflow, and conceptual overhead for a problem that's already solved by 5 git commands. + +**Why an orphan branch:** `git checkout --orphan assets`, `git rm -rf .`, add your images, commit, push. Reference them with `![Demo](../assets/demo.gif?raw=true)`. No dependencies, no tooling, no build step. GitHub renders them directly. Every developer on your team already knows how to do this. The approach is documented everywhere and works with every Git host. + +**The honest line:** If your assets are public and small, the orphan branch pattern is simpler and better. git-cas earns its keep when you need encryption, dedup, compression, integrity verification, or lifecycle management — not when you need a GIF in a README. + +--- + ### 1. You need to back up an entire filesystem **Use instead: Restic** @@ -1648,6 +1683,9 @@ Do you need encrypted binary storage inside Git's ODB? │ ├── Need CDC dedup? → Wait for M10 │ └── Need >10 GB with lazy clone? → git-cas is the wrong tool. Use LFS + separate encryption │ +├── NO, I just need images/demos in my README +│ └── Orphan branch (git checkout --orphan assets). Zero dependencies, GitHub renders inline. +│ ├── NO, I need filesystem backups │ └── Restic │ @@ -1676,6 +1714,332 @@ If that's what you want, nothing else does it. If it's not, the right tool proba --- +# M13 — Bijou (v3.3.0) +**Theme:** Beautiful terminal UI powered by `@flyingrobots/bijou`. Replace silent CLI operations with animated progress, and add an interactive vault dashboard for exploring stored assets. Depends on M9 Cockpit for the `--quiet` flag and event wiring foundation. + +--- + +## Task 13.1: Animated store/restore progress + +**User Story** +As a CLI user, I want a smooth animated progress bar with chunk counts and throughput when storing or restoring files, so I can see that the operation is working and estimate time remaining. + +**Requirements** +- R1: Add `@flyingrobots/bijou` and `@flyingrobots/bijou-node` as dependencies. +- R2: Wire `CasService` events (`chunk:stored`, `chunk:restored`) to a bijou `createAnimatedProgressBar()` with spring physics (preset: `gentle`). +- R3: Display gradient progress bar (theme `CYAN_MAGENTA`) with chunk counter (`78/193 chunks`) and throughput (`19.2 MiB/s`). +- R4: Show last-processed chunk digest and blob OID below the progress bar. +- R5: Progress renders to stderr; stdout reserved for structured output. +- R6: Graceful degradation: static counter in CI, plain text in pipe mode, no output with `--quiet`. + +**Acceptance Criteria** +- AC1: `git cas store` shows animated progress bar in TTY mode. +- AC2: `git cas restore` shows animated progress bar in TTY mode. +- AC3: CI mode (`CI=true`) falls back to static line-by-line progress. +- AC4: Pipe mode shows no progress output. +- AC5: `--quiet` suppresses all progress. + +**Scope** +- In scope: Progress bar for store and restore commands. +- Out of scope: Full TUI app, interactive elements, vault commands. + +**Est. Complexity (LoC)** +- Prod: ~80 +- Tests: ~30 +- Total: ~110 + +**Est. Human Working Hours** +- ~3h + +**Test Plan** +- Golden path: + - Store 5-chunk file → progress bar advances 5 times, final state shows 100%. + - Restore 5-chunk file → same. +- Edges: + - 0-chunk file (empty) → no progress bar shown. + - 1-chunk file → bar jumps to 100%. + - Non-TTY → static fallback or silent. + +**Definition of Done** +- DoD1: Animated progress bar visible during store/restore in interactive terminals. +- DoD2: Graceful degradation works across all four output modes. +- DoD3: No visual artifacts or leftover ANSI codes in non-TTY environments. + +**Blocking** +- Blocks: Task 13.2 (vault dashboard uses same bijou dependency) + +**Blocked By** +- Blocked by: Task 9.1 (CLI progress feedback foundation, `--quiet` flag) + +--- + +## Task 13.2: Vault dashboard — interactive TUI app + +**User Story** +As a developer managing multiple vault entries, I want an interactive terminal dashboard to browse entries, inspect manifests, and view encryption status without memorizing CLI flags. + +**Requirements** +- R1: Add `@flyingrobots/bijou-tui` as a dependency. +- R2: New subcommand: `git cas vault dashboard` (or `git cas vault ui`). +- R3: Full-screen TEA app with flexbox layout: entry list (left pane) + detail view (right pane). +- R4: Entry list shows slug, size (human-readable), chunk count, and badges for encryption/compression/merkle. +- R5: Detail view shows manifest anatomy: metadata, encryption config, compression, sub-manifests, and paginated chunk list. +- R6: Keyboard navigation: `j/k` or arrows to move, `enter` to expand, `/` to filter, `q` to quit. +- R7: Vault-level header showing encryption status, asset count, and vault ref. +- R8: Graceful degradation: static table output in CI/pipe mode (reuse Task 9.5 table formatting). + +**Acceptance Criteria** +- AC1: `git cas vault dashboard` launches interactive TUI in TTY mode. +- AC2: All vault entries listed with correct metadata. +- AC3: Selecting an entry shows full manifest detail. +- AC4: Filter narrows the list by slug substring. +- AC5: `q` or `ctrl-c` exits cleanly (restores terminal state). +- AC6: Non-TTY falls back to static vault list. + +**Scope** +- In scope: Read-only dashboard for browsing vault state. +- Out of scope: Mutating operations (store/restore/remove) from the dashboard. + +**Est. Complexity (LoC)** +- Prod: ~200 +- Tests: ~60 +- Total: ~260 + +**Est. Human Working Hours** +- ~7h + +**Test Plan** +- Golden path: + - Launch with 3 vault entries → all listed with correct badges. + - Select entry → detail pane populates with manifest data. + - Filter by substring → list narrows correctly. +- Edges: + - Empty vault → shows "No entries" message. + - Entry with Merkle sub-manifests → sub-manifest section rendered. + - Very long slug names → truncated with ellipsis. +- Failures: + - Vault ref doesn't exist → shows initialization prompt. + +**Definition of Done** +- DoD1: Interactive dashboard launches and renders vault state. +- DoD2: Navigation, selection, and filtering work. +- DoD3: Clean exit restores terminal state. +- DoD4: Static fallback works in non-TTY. + +**Blocking** +- Blocks: Task 13.4, Task 13.5 + +**Blocked By** +- Blocked by: Task 13.1 (bijou dependency), Task 9.5 (vault table formatting) + +--- + +## Task 13.3: Vault history timeline view + +**User Story** +As a developer, I want to see vault commit history as a visual timeline so I can understand how the vault has evolved over time. + +**Requirements** +- R1: New subcommand: `git cas vault history --pretty` (or integrate into dashboard as a tab). +- R2: Render vault commits using bijou `timeline()` component with status indicators. +- R3: Color-code by operation: green for `add`, yellow for `update`, red for `remove`, blue for `init`. +- R4: Show commit OID (short), operation, slug, and relative timestamp. +- R5: Paginate with bijou `paginator()` for long histories. +- R6: Static fallback: plain `git log --oneline` output (current behavior). + +**Acceptance Criteria** +- AC1: `git cas vault history --pretty` renders color-coded timeline in TTY mode. +- AC2: Operations correctly color-coded by parsing commit messages. +- AC3: Pagination works for vaults with >20 commits. +- AC4: Without `--pretty`, behavior unchanged (backward compatible). + +**Scope** +- In scope: Timeline rendering of vault history. +- Out of scope: Interactive revert, diff between history points. + +**Est. Complexity (LoC)** +- Prod: ~60 +- Tests: ~25 +- Total: ~85 + +**Est. Human Working Hours** +- ~2h + +**Test Plan** +- Golden path: + - Vault with 5 commits → 5 timeline entries, correctly colored. +- Edges: + - Empty vault (no commits) → "No history" message. + - 100+ commits → paginated display. + +**Definition of Done** +- DoD1: Timeline renders with color-coded operations. +- DoD2: Pagination functional. +- DoD3: `--pretty` flag documented in `--help`. + +**Blocking** +- Blocks: None + +**Blocked By** +- Blocked by: Task 13.1 (bijou dependency) + +--- + +## Task 13.4: Manifest anatomy view + +**User Story** +As a developer debugging storage issues, I want a rich visual breakdown of a manifest showing its structure, encryption metadata, compression settings, and chunk layout. + +**Requirements** +- R1: New subcommand: `git cas inspect --slug ` (or `--oid `). +- R2: Render manifest using bijou `box()`, `accordion()`, and `tree()` components. +- R3: Sections: metadata (slug, filename, size, version), encryption (algorithm, KDF params), compression, sub-manifests (if Merkle), and chunks. +- R4: Chunks section uses `paginator()` — show 20 chunks per page with index, size, digest (truncated), and blob OID. +- R5: Badges for encryption status, compression, Merkle, manifest version. +- R6: Static fallback: JSON dump (current `readManifest` behavior). + +**Acceptance Criteria** +- AC1: `git cas inspect --slug ` renders structured manifest view. +- AC2: Accordion sections expand/collapse. +- AC3: Chunk pagination works. +- AC4: Encrypted manifests show full KDF parameter breakdown. +- AC5: Merkle manifests show sub-manifest tree. + +**Scope** +- In scope: Read-only manifest inspection. +- Out of scope: Editing manifests, verifying integrity (that's `git cas verify`). + +**Est. Complexity (LoC)** +- Prod: ~70 +- Tests: ~30 +- Total: ~100 + +**Est. Human Working Hours** +- ~3h + +**Test Plan** +- Golden path: + - Inspect unencrypted v1 manifest → metadata + chunks displayed. + - Inspect encrypted v2 Merkle manifest → all sections populated. +- Edges: + - Empty manifest (0 chunks) → shows "No chunks" in chunks section. + - Very large manifest (1000+ chunks) → pagination handles cleanly. + +**Definition of Done** +- DoD1: Manifest anatomy renders with all sections. +- DoD2: Accordion expand/collapse works. +- DoD3: Chunk pagination works. + +**Blocking** +- Blocks: None + +**Blocked By** +- Blocked by: Task 13.2 (shared component patterns) + +--- + +## Task 13.5: Chunk heatmap visualization + +**User Story** +As a developer, I want a visual block map of chunks in a stored file so I can quickly see the storage layout, Merkle boundaries, and progress during operations. + +**Requirements** +- R1: Render a grid of `█` / `░` blocks, one per chunk, sized to terminal width. +- R2: Color via bijou `gradientText()` from start to end of file. +- R3: Show Merkle sub-manifest boundaries with `│` separators in the grid. +- R4: Legend showing chunk count, sub-manifest count, chunk size. +- R5: Integrate into `git cas inspect` as an optional `--heatmap` flag. +- R6: During store/restore (Task 13.1), optionally show filling heatmap instead of progress bar via `--heatmap` flag. + +**Acceptance Criteria** +- AC1: `git cas inspect --slug --heatmap` renders chunk grid. +- AC2: Gradient coloring spans the full grid. +- AC3: Merkle boundaries visually distinct. +- AC4: Grid reflows to terminal width. + +**Scope** +- In scope: Static heatmap for stored files. +- Out of scope: Live-updating heatmap during store/restore (stretch goal for R6). + +**Est. Complexity (LoC)** +- Prod: ~40 +- Tests: ~15 +- Total: ~55 + +**Est. Human Working Hours** +- ~2h + +**Test Plan** +- Golden path: + - 40-chunk file, 80-col terminal → 2 rows of 40 blocks. + - 2500-chunk Merkle file → blocks with boundary markers. +- Edges: + - 1-chunk file → single block. + - Terminal narrower than chunk count → wraps correctly. + +**Definition of Done** +- DoD1: Heatmap renders correctly for v1 and v2 manifests. +- DoD2: Gradient coloring works. +- DoD3: Terminal width adaptation works. + +**Blocking** +- Blocks: None + +**Blocked By** +- Blocked by: Task 13.2 (shared component patterns) + +--- + +## Task 13.6: Encryption info card + +**User Story** +As a security-conscious user, I want a clear visual summary of my vault's encryption configuration so I can verify the crypto parameters at a glance. + +**Requirements** +- R1: Render encryption details using bijou `box()` with labeled rows. +- R2: Show cipher, KDF algorithm, KDF parameters (iterations/cost/blockSize/parallelization), salt (truncated), and key length. +- R3: Status badge: `● locked` (red) when no key provided, `● unlocked` (green) when key resolved. +- R4: Integrate into vault dashboard header and `git cas inspect` encryption accordion. +- R5: Standalone via `git cas vault info --encryption`. + +**Acceptance Criteria** +- AC1: Encryption card renders all KDF parameters. +- AC2: Correct badge for locked/unlocked state. +- AC3: Works for both pbkdf2 and scrypt vaults. +- AC4: Non-encrypted vault → "No encryption configured" message. + +**Scope** +- In scope: Display-only encryption summary. +- Out of scope: Key verification, passphrase prompting. + +**Est. Complexity (LoC)** +- Prod: ~30 +- Tests: ~10 +- Total: ~40 + +**Est. Human Working Hours** +- ~1h + +**Test Plan** +- Golden path: + - PBKDF2 vault → shows iterations, salt, key length. + - Scrypt vault → shows cost, blockSize, parallelization. +- Edges: + - Non-encrypted vault → "No encryption" message. + +**Definition of Done** +- DoD1: Encryption card renders with correct parameters. +- DoD2: Badge reflects locked/unlocked state. +- DoD3: Both KDF algorithms handled. + +**Blocking** +- Blocks: None + +**Blocked By** +- Blocked by: Task 13.1 (bijou dependency) + +--- + ## Backlog (unscheduled) Ideas for future milestones. Not committed, not prioritized — just captured. diff --git a/bin/ui/context.js b/bin/ui/context.js new file mode 100644 index 0000000..5a72f2f --- /dev/null +++ b/bin/ui/context.js @@ -0,0 +1,54 @@ +/** + * Shared bijou context configured for CLI use (writes to stderr). + */ + +import { createBijou } from '@flyingrobots/bijou'; +import { nodeRuntime, chalkStyle } from '@flyingrobots/bijou-node'; + +let ctx = null; + +/** + * Returns a bijou context that writes to stderr instead of stdout. + * Stdout is reserved for structured output (OIDs, JSON). + */ +export function getCliContext() { + if (ctx) { + return ctx; + } + const runtime = nodeRuntime(); + const noColor = runtime.env('NO_COLOR') !== undefined; + ctx = createBijou({ + runtime, + io: stderrIO(), + style: chalkStyle(noColor), + }); + return ctx; +} + +function stderrIO() { + return { + write(data) { + process.stderr.write(data); + }, + question() { + throw new Error('question() not supported in CLI context'); + }, + rawInput() { + throw new Error('rawInput() not supported in CLI context'); + }, + onResize(callback) { + const handler = () => { + callback(process.stderr.columns ?? 80, process.stderr.rows ?? 24); + }; + process.stderr.on('resize', handler); + return { dispose() { process.stderr.removeListener('resize', handler); } }; + }, + setInterval(callback, ms) { + const id = globalThis.setInterval(callback, ms); + return { dispose() { globalThis.clearInterval(id); } }; + }, + readFile() { throw new Error('readFile() not supported'); }, + readDir() { throw new Error('readDir() not supported'); }, + joinPath(...segments) { return segments.join('/'); }, + }; +} diff --git a/bin/ui/encryption-card.js b/bin/ui/encryption-card.js new file mode 100644 index 0000000..ab6a595 --- /dev/null +++ b/bin/ui/encryption-card.js @@ -0,0 +1,49 @@ +/** + * Encryption info card — visual summary of vault crypto configuration. + */ + +import { box, badge, headerBox } from '@flyingrobots/bijou'; +import { getCliContext } from './context.js'; + +/** + * Render an encryption info card for the vault. + * + * @param {Object} options + * @param {Object|null} options.metadata - Vault metadata (from getVaultMetadata()). + * @param {boolean} [options.unlocked] - Whether a key/passphrase was provided. + * @returns {string} + */ +export function renderEncryptionCard({ metadata, unlocked = false }) { + const ctx = getCliContext(); + + if (!metadata?.encryption) { + return box('No encryption configured', { ctx }); + } + + const { encryption } = metadata; + const { kdf } = encryption; + + const status = unlocked + ? badge('unlocked', { variant: 'success', ctx }) + : badge('locked', { variant: 'error', ctx }); + + const rows = [ + ` cipher ${encryption.cipher}`, + ` kdf ${kdf.algorithm}`, + ]; + + if (kdf.algorithm === 'pbkdf2') { + rows.push(` iterations ${kdf.iterations.toLocaleString()}`); + } else if (kdf.algorithm === 'scrypt') { + rows.push(` cost ${kdf.cost}`); + rows.push(` blockSize ${kdf.blockSize}`); + rows.push(` parallel ${kdf.parallelization}`); + } + + rows.push(` key length ${kdf.keyLength} bytes`); + rows.push(` salt ${kdf.salt.slice(0, 12)}...`); + rows.push(` status ${status}`); + + const content = rows.join('\n'); + return `${headerBox('Encryption', { ctx })}\n${box(content, { ctx })}`; +} diff --git a/bin/ui/heatmap.js b/bin/ui/heatmap.js new file mode 100644 index 0000000..9d01d38 --- /dev/null +++ b/bin/ui/heatmap.js @@ -0,0 +1,103 @@ +/** + * Chunk heatmap visualization — visual block map of chunks. + */ + +import { gradientText } from '@flyingrobots/bijou'; +import { getCliContext } from './context.js'; + +const GRADIENT_STOPS = [ + { pos: 0, color: [0, 255, 255] }, + { pos: 1, color: [255, 0, 255] }, +]; + +/** + * Format bytes as human-readable string. + */ +function formatBytes(bytes) { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KiB`; + } + if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; + } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`; +} + +/** + * Build the block grid string from chunks. + */ +function buildGrid(chunks, boundaries, width) { + let grid = ''; + let col = 0; + + for (let i = 0; i < chunks.length; i++) { + if (boundaries.has(i) && col > 0) { + grid += '\n'; + col = 0; + } + + grid += '\u2588'; + col++; + + if (col >= width) { + grid += '\n'; + col = 0; + } + } + + if (col > 0) { + grid += '\n'; + } + return grid; +} + +/** + * Build the legend line. + */ +function buildLegend(chunks, subManifests) { + const chunkSize = chunks.length > 0 ? chunks[0].size : 0; + const parts = [`${chunks.length} chunks`]; + if (subManifests.length) { + parts.push(`${subManifests.length} sub-manifests`); + } + if (chunkSize) { + parts.push(`${formatBytes(chunkSize)}/chunk`); + } + return parts.join(' '); +} + +/** + * Render a chunk heatmap for a manifest. + * + * @param {Object} options + * @param {Object} options.manifest - The manifest (from readManifest). + * @returns {string} + */ +export function renderHeatmap({ manifest }) { + const ctx = getCliContext(); + const m = manifest.toJSON ? manifest.toJSON() : manifest; + const chunks = m.chunks || []; + + if (chunks.length === 0) { + return 'No chunks to display\n'; + } + + const width = Math.min(60, (ctx.runtime.columns || 80) - 10); + const subManifests = m.subManifests || []; + + const boundaries = new Set(); + for (const sm of subManifests) { + if (sm.startIndex > 0) { + boundaries.add(sm.startIndex); + } + } + + const grid = buildGrid(chunks, boundaries, width); + const colored = gradientText(grid, GRADIENT_STOPS, { style: ctx.style }); + const legend = buildLegend(chunks, subManifests); + + return `${colored}\n${legend}\n`; +} diff --git a/bin/ui/history-timeline.js b/bin/ui/history-timeline.js new file mode 100644 index 0000000..f915db1 --- /dev/null +++ b/bin/ui/history-timeline.js @@ -0,0 +1,84 @@ +/** + * Vault history as a color-coded timeline. + */ + +import { timeline, paginator } from '@flyingrobots/bijou'; +import { getCliContext } from './context.js'; + +/** + * Parse a vault commit line into structured data. + * Input format: "eff5569 vault: add photos/beach" + * + * @param {string} line + * @returns {{ oid: string, operation: string, slug: string|null }|null} + */ +function parseCommitLine(line) { + const trimmed = line.trim(); + if (!trimmed) { + return null; + } + + const spaceIdx = trimmed.indexOf(' '); + if (spaceIdx === -1) { + return null; + } + + const oid = trimmed.slice(0, spaceIdx); + const message = trimmed.slice(spaceIdx + 1); + + const match = message.match(/^vault:\s*(init|add|update|remove)\s*(.*)$/); + if (!match) { + return { oid, operation: 'unknown', slug: message }; + } + + return { oid, operation: match[1], slug: match[2] || null }; +} + +const STATUS_MAP = { + init: 'info', + add: 'success', + update: 'warning', + remove: 'error', + unknown: 'pending', +}; + +/** + * Render vault history as a color-coded timeline. + * + * @param {string} gitLogOutput - Raw output from `git log --oneline`. + * @param {Object} [options] + * @param {number} [options.page] - Current page (1-based). + * @param {number} [options.perPage] - Entries per page (default 20). + * @returns {string} + */ +export function renderHistoryTimeline(gitLogOutput, options = {}) { + const ctx = getCliContext(); + const perPage = options.perPage ?? 20; + const page = options.page ?? 1; + + const lines = gitLogOutput.split('\n').filter(Boolean); + if (lines.length === 0) { + return 'No history\n'; + } + + const totalPages = Math.ceil(lines.length / perPage); + const start = (page - 1) * perPage; + const pageLines = lines.slice(start, start + perPage); + + const events = pageLines + .map(parseCommitLine) + .filter(Boolean) + .map(({ oid, operation, slug }) => ({ + label: slug ? `vault: ${operation} ${slug}` : `vault: ${operation}`, + description: oid, + status: STATUS_MAP[operation] || 'pending', + })); + + let output = timeline(events, { ctx }); + + if (totalPages > 1) { + output += `\n${paginator({ current: page, total: totalPages, ctx })}`; + } + + return output; +} diff --git a/bin/ui/progress.js b/bin/ui/progress.js new file mode 100644 index 0000000..1c47ba6 --- /dev/null +++ b/bin/ui/progress.js @@ -0,0 +1,126 @@ +/** + * Animated progress bar for store/restore operations. + * Wires CasService EventEmitter events to a bijou progress bar on stderr. + */ + +import { statSync } from 'node:fs'; +import { createAnimatedProgressBar } from '@flyingrobots/bijou'; +import { getCliContext } from './context.js'; + +/** + * Format bytes as human-readable string. + */ +function formatBytes(bytes) { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KiB`; + } + if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; + } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`; +} + +/** + * Create a progress tracker for store operations. + * + * @param {Object} options + * @param {string} options.filePath - Path to the file being stored. + * @param {number} options.chunkSize - Chunk size in bytes. + * @param {boolean} [options.quiet] - Suppress all progress output. + * @returns {{ attach(service: EventEmitter): void, detach(): void }} + */ +export function createStoreProgress({ filePath, chunkSize, quiet }) { + if (quiet) { + return { attach() {}, detach() {} }; + } + + const ctx = getCliContext(); + if (ctx.mode === 'pipe') { + return { attach() {}, detach() {} }; + } + + const fileSize = statSync(filePath).size; + const totalChunks = fileSize === 0 ? 0 : Math.ceil(fileSize / chunkSize); + + if (totalChunks === 0) { + return { attach() {}, detach() {} }; + } + + return createProgressTracker({ ctx, totalChunks, event: 'chunk:stored', label: 'Storing' }); +} + +/** + * Create a progress tracker for restore operations. + * + * @param {Object} options + * @param {number} options.totalChunks - Number of chunks to restore. + * @param {boolean} [options.quiet] - Suppress all progress output. + * @returns {{ attach(service: EventEmitter): void, detach(): void }} + */ +export function createRestoreProgress({ totalChunks, quiet }) { + if (quiet || totalChunks === 0) { + return { attach() {}, detach() {} }; + } + + const ctx = getCliContext(); + if (ctx.mode === 'pipe') { + return { attach() {}, detach() {} }; + } + + return createProgressTracker({ ctx, totalChunks, event: 'chunk:restored', label: 'Restoring' }); +} + +/** + * Internal: builds the progress tracker object. + */ +function createProgressTracker({ ctx, totalChunks, event, label }) { + const width = Math.min(40, (ctx.runtime.columns || 80) - 30); + const bar = createAnimatedProgressBar({ width, showPercent: false, ctx }); + + let chunksProcessed = 0; + let bytesProcessed = 0; + let startTime = null; + let service = null; + let handler = null; + + function onChunk({ size }) { + if (!startTime) { + startTime = Date.now(); + } + chunksProcessed++; + bytesProcessed += size; + + const pct = (chunksProcessed / totalChunks) * 100; + const elapsed = (Date.now() - startTime) / 1000; + const throughput = elapsed > 0 ? bytesProcessed / elapsed : 0; + + if (ctx.mode === 'interactive') { + const status = ` ${label} ${chunksProcessed}/${totalChunks} ${formatBytes(throughput)}/s `; + process.stderr.write(`\r\x1b[K${status}`); + bar.update(pct); + } else if (chunksProcessed === 1 || chunksProcessed === totalChunks || chunksProcessed % 10 === 0) { + ctx.io.write(`${label} ${chunksProcessed}/${totalChunks} ${Math.round(pct)}%\n`); + } + } + + return { + attach(svc) { + service = svc; + handler = onChunk; + bar.start(); + service.on(event, handler); + }, + detach() { + if (service && handler) { + service.removeListener(event, handler); + } + const elapsed = startTime ? (Date.now() - startTime) / 1000 : 0; + const throughput = elapsed > 0 ? bytesProcessed / elapsed : 0; + const msg = ` ${label} ${chunksProcessed}/${totalChunks} done ${formatBytes(throughput)}/s`; + bar.stop(msg); + }, + }; +} diff --git a/package.json b/package.json index c25d413..02bc153 100644 --- a/package.json +++ b/package.json @@ -66,12 +66,21 @@ "format": "prettier --write ." }, "dependencies": { + "@flyingrobots/bijou": "^0.2.0", + "@flyingrobots/bijou-node": "^0.2.0", + "@flyingrobots/bijou-tui": "^0.2.0", "@git-stunts/alfred": "^0.10.0", "@git-stunts/plumbing": "^2.8.0", "cbor-x": "^1.6.0", "commander": "^14.0.3", "zod": "^3.24.1" }, + "pnpm": { + "onlyBuiltDependencies": [ + "cbor-extract", + "esbuild" + ] + }, "devDependencies": { "@eslint/js": "^9.17.0", "eslint": "^9.17.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5028011..488d30a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@flyingrobots/bijou': + specifier: ^0.2.0 + version: 0.2.0 + '@flyingrobots/bijou-node': + specifier: ^0.2.0 + version: 0.2.0 + '@flyingrobots/bijou-tui': + specifier: ^0.2.0 + version: 0.2.0 '@git-stunts/alfred': specifier: ^0.10.0 version: 0.10.0 @@ -248,6 +257,18 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@flyingrobots/bijou-node@0.2.0': + resolution: {integrity: sha512-QaIaoBF0OMRHGtLsga1knplfFEmAeC6Lt4SxWkCKIJahMdNqXatCWM3RdzXcbjfcXqRIXyeEpm1agmmwi4gneQ==} + engines: {node: '>=18'} + + '@flyingrobots/bijou-tui@0.2.0': + resolution: {integrity: sha512-pXEo/Am6svRIKvez7926avdGUbfVndlSOpidBPc42YjCQHU5ZQrEuJpjI7niJb63N0ruxu0VXHci8N0wzBYSow==} + engines: {node: '>=18'} + + '@flyingrobots/bijou@0.2.0': + resolution: {integrity: sha512-Oix2Kqq4w87KCkyK2W+8u4E4aGVQiraUy8BF3Bk/NRtT+UlUI0ETs+E7GwpwOyOvHvt0cIOjcMmVPxzKa52P4A==} + engines: {node: '>=18'} + '@git-stunts/alfred@0.10.0': resolution: {integrity: sha512-0DPhJdKhYTcsPuoOnYIIyvlwaIM7yIx4fQM4Q48abe/VDLTfZef1ubeT1pYio+ZTp1lKXtSG663973ewBbi/yw==} engines: {node: '>=20.0.0'} @@ -501,6 +522,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -1062,6 +1087,17 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@flyingrobots/bijou-node@0.2.0': + dependencies: + '@flyingrobots/bijou': 0.2.0 + chalk: 5.6.2 + + '@flyingrobots/bijou-tui@0.2.0': + dependencies: + '@flyingrobots/bijou': 0.2.0 + + '@flyingrobots/bijou@0.2.0': {} + '@git-stunts/alfred@0.10.0': {} '@git-stunts/plumbing@2.8.0': @@ -1261,6 +1297,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + check-error@2.1.3: {} color-convert@2.0.1: diff --git a/test/unit/cli/_testContext.js b/test/unit/cli/_testContext.js new file mode 100644 index 0000000..1087310 --- /dev/null +++ b/test/unit/cli/_testContext.js @@ -0,0 +1,8 @@ +/** + * Shared test context factory for CLI UI tests. + */ +import { createTestContext } from '@flyingrobots/bijou/adapters/test'; + +export function makeCtx(mode = 'interactive') { + return createTestContext({ mode, noColor: true }); +} diff --git a/test/unit/cli/encryption-card.test.js b/test/unit/cli/encryption-card.test.js new file mode 100644 index 0000000..5b5afb9 --- /dev/null +++ b/test/unit/cli/encryption-card.test.js @@ -0,0 +1,54 @@ +import { describe, it, expect, vi } from 'vitest'; +import { makeCtx } from './_testContext.js'; + +vi.mock('../../../bin/ui/context.js', () => ({ + getCliContext: () => makeCtx(), +})); + +const { renderEncryptionCard } = await import('../../../bin/ui/encryption-card.js'); + +describe('renderEncryptionCard', () => { + it('renders no-encryption for null metadata', () => { + expect(renderEncryptionCard({ metadata: null })).toContain('No encryption configured'); + }); + + it('renders no-encryption for metadata without encryption', () => { + expect(renderEncryptionCard({ metadata: { version: 1 } })).toContain('No encryption configured'); + }); + + it('renders pbkdf2 details', () => { + const output = renderEncryptionCard({ + metadata: { + version: 1, + encryption: { cipher: 'aes-256-gcm', kdf: { algorithm: 'pbkdf2', salt: 'c2FsdHNhbHRzYWx0', iterations: 600000, keyLength: 32 } }, + }, + }); + expect(output).toContain('aes-256-gcm'); + expect(output).toContain('pbkdf2'); + expect(output).toContain('600,000'); + expect(output).toContain('32 bytes'); + expect(output).toContain('locked'); + }); + + it('renders scrypt details', () => { + const output = renderEncryptionCard({ + metadata: { + version: 1, + encryption: { cipher: 'aes-256-gcm', kdf: { algorithm: 'scrypt', salt: 'c2FsdHNhbHRzYWx0', cost: 16, blockSize: 8, parallelization: 1, keyLength: 32 } }, + }, + }); + expect(output).toContain('scrypt'); + expect(output).toContain('16'); + }); + + it('shows unlocked badge', () => { + const output = renderEncryptionCard({ + metadata: { + version: 1, + encryption: { cipher: 'aes-256-gcm', kdf: { algorithm: 'pbkdf2', salt: 'c2FsdHNhbHRzYWx0', iterations: 100000, keyLength: 32 } }, + }, + unlocked: true, + }); + expect(output).toContain('unlocked'); + }); +}); diff --git a/test/unit/cli/heatmap.test.js b/test/unit/cli/heatmap.test.js new file mode 100644 index 0000000..895b7dc --- /dev/null +++ b/test/unit/cli/heatmap.test.js @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from 'vitest'; +import { makeCtx } from './_testContext.js'; + +vi.mock('../../../bin/ui/context.js', () => ({ + getCliContext: () => makeCtx(), +})); + +const { renderHeatmap } = await import('../../../bin/ui/heatmap.js'); + +function makeManifest(chunkCount, subManifests) { + const chunks = Array.from({ length: chunkCount }, (_, i) => ({ + index: i, size: 262144, digest: 'a'.repeat(64), blob: 'b'.repeat(40), + })); + const m = { toJSON() { return this; }, version: 1, slug: 'test', filename: 'test.bin', size: chunkCount * 262144, chunks }; + if (subManifests) { + m.subManifests = subManifests; + m.version = 2; + } + return m; +} + +describe('renderHeatmap', () => { + it('shows "No chunks" for empty manifest', () => { + expect(renderHeatmap({ manifest: makeManifest(0) })).toBe('No chunks to display\n'); + }); + + it('renders blocks for each chunk', () => { + const output = renderHeatmap({ manifest: makeManifest(5) }); + const blocks = (output.match(/\u2588/g) || []).length; + expect(blocks).toBe(5); + }); + + it('renders legend with chunk count', () => { + const output = renderHeatmap({ manifest: makeManifest(10) }); + expect(output).toContain('10 chunks'); + expect(output).toContain('256.0 KiB/chunk'); + }); + + it('renders sub-manifest info in legend', () => { + const subs = [ + { oid: 'aaa', chunkCount: 5, startIndex: 0 }, + { oid: 'bbb', chunkCount: 5, startIndex: 5 }, + ]; + const output = renderHeatmap({ manifest: makeManifest(10, subs) }); + expect(output).toContain('2 sub-manifests'); + }); + + it('single chunk renders correctly', () => { + const output = renderHeatmap({ manifest: makeManifest(1) }); + const blocks = (output.match(/\u2588/g) || []).length; + expect(blocks).toBe(1); + }); +}); diff --git a/test/unit/cli/history-timeline.test.js b/test/unit/cli/history-timeline.test.js new file mode 100644 index 0000000..efd4941 --- /dev/null +++ b/test/unit/cli/history-timeline.test.js @@ -0,0 +1,46 @@ +import { describe, it, expect, vi } from 'vitest'; +import { makeCtx } from './_testContext.js'; + +vi.mock('../../../bin/ui/context.js', () => ({ + getCliContext: () => makeCtx(), +})); + +const { renderHistoryTimeline } = await import('../../../bin/ui/history-timeline.js'); + +describe('renderHistoryTimeline', () => { + it('renders "No history" for empty input', () => { + expect(renderHistoryTimeline('')).toBe('No history\n'); + }); + + it('renders timeline entries from git log output', () => { + const log = 'eff5569 vault: add photos/beach\nc3bde6c vault: init'; + const output = renderHistoryTimeline(log); + expect(output).toContain('vault: add photos/beach'); + expect(output).toContain('vault: init'); + expect(output).toContain('eff5569'); + expect(output).toContain('c3bde6c'); + }); + + it('handles all operation types', () => { + const log = [ + 'aaa1111 vault: init', + 'bbb2222 vault: add my-asset', + 'ccc3333 vault: update my-asset', + 'ddd4444 vault: remove my-asset', + ].join('\n'); + const output = renderHistoryTimeline(log); + expect(output).toContain('vault: init'); + expect(output).toContain('vault: add'); + expect(output).toContain('vault: update'); + expect(output).toContain('vault: remove'); + }); + + it('paginates when entries exceed perPage', () => { + const lines = Array.from({ length: 25 }, (_, i) => + `${String(i).padStart(7, '0')} vault: add asset-${i}` + ); + const page1 = renderHistoryTimeline(lines.join('\n'), { page: 1, perPage: 10 }); + expect(page1).toContain('asset-0'); + expect(page1).not.toContain('asset-10'); + }); +}); diff --git a/test/unit/cli/manifest-view.test.js b/test/unit/cli/manifest-view.test.js new file mode 100644 index 0000000..a60e2ae --- /dev/null +++ b/test/unit/cli/manifest-view.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from 'vitest'; +import { makeCtx } from './_testContext.js'; + +vi.mock('../../../bin/ui/context.js', () => ({ + getCliContext: () => makeCtx(), +})); + +const { renderManifestView } = await import('../../../bin/ui/manifest-view.js'); + +function makeManifest(overrides = {}) { + return { + toJSON() { return this; }, + version: 1, + slug: 'test-asset', + filename: 'photo.jpg', + size: 524288, + chunks: [ + { index: 0, size: 262144, digest: 'a'.repeat(64), blob: 'b'.repeat(40) }, + { index: 1, size: 262144, digest: 'c'.repeat(64), blob: 'd'.repeat(40) }, + ], + ...overrides, + }; +} + +describe('renderManifestView', () => { + it('renders metadata', () => { + const output = renderManifestView({ manifest: makeManifest() }); + expect(output).toContain('test-asset'); + expect(output).toContain('photo.jpg'); + expect(output).toContain('Metadata'); + }); + + it('renders chunk table', () => { + const output = renderManifestView({ manifest: makeManifest() }); + expect(output).toContain('Chunks (2)'); + expect(output).toContain('aaaaaaaaaaaa...'); + }); + + it('renders encryption section', () => { + const enc = { algorithm: 'aes-256-gcm', nonce: 'bm9uY2U=bm9u', tag: 'dGFndGFn', encrypted: true, kdf: { algorithm: 'pbkdf2', iterations: 100000 } }; + const output = renderManifestView({ manifest: makeManifest({ encryption: enc }) }); + expect(output).toContain('Encryption'); + expect(output).toContain('aes-256-gcm'); + }); + + it('renders compression section', () => { + const output = renderManifestView({ manifest: makeManifest({ compression: { algorithm: 'gzip' } }) }); + expect(output).toContain('Compression'); + }); + + it('renders sub-manifests', () => { + const subs = [{ oid: 'aaaa1111bbbb2222', chunkCount: 1000, startIndex: 0 }, { oid: 'cccc3333dddd4444', chunkCount: 500, startIndex: 1000 }]; + const output = renderManifestView({ manifest: makeManifest({ version: 2, subManifests: subs }) }); + expect(output).toContain('Sub-manifests (2)'); + }); + + it('truncates chunks beyond 20', () => { + const chunks = Array.from({ length: 30 }, (_, i) => ({ index: i, size: 262144, digest: 'a'.repeat(64), blob: 'b'.repeat(40) })); + const output = renderManifestView({ manifest: makeManifest({ chunks }) }); + expect(output).toContain('Chunks (30)'); + }); +}); diff --git a/test/unit/cli/progress.test.js b/test/unit/cli/progress.test.js new file mode 100644 index 0000000..003a94e --- /dev/null +++ b/test/unit/cli/progress.test.js @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from 'vitest'; +import EventEmitter from 'node:events'; +import { makeCtx } from './_testContext.js'; + +vi.mock('../../../bin/ui/context.js', () => ({ + getCliContext: () => makeCtx('static'), +})); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + statSync: vi.fn(() => ({ size: 5 * 256 * 1024 })), + }; +}); + +const { createStoreProgress, createRestoreProgress } = await import('../../../bin/ui/progress.js'); + +describe('createStoreProgress', () => { + it('returns no-op when quiet is true', () => { + const p = createStoreProgress({ filePath: 'test.bin', chunkSize: 256 * 1024, quiet: true }); + const emitter = new EventEmitter(); + p.attach(emitter); + emitter.emit('chunk:stored', { index: 0, size: 256 * 1024 }); + p.detach(); + expect(emitter.listenerCount('chunk:stored')).toBe(0); + }); + + it('attaches and detaches from EventEmitter', () => { + const p = createStoreProgress({ filePath: 'test.bin', chunkSize: 256 * 1024, quiet: false }); + const emitter = new EventEmitter(); + p.attach(emitter); + expect(emitter.listenerCount('chunk:stored')).toBe(1); + p.detach(); + expect(emitter.listenerCount('chunk:stored')).toBe(0); + }); + + it('tracks chunk events without throwing', () => { + const p = createStoreProgress({ filePath: 'test.bin', chunkSize: 256 * 1024, quiet: false }); + const emitter = new EventEmitter(); + p.attach(emitter); + for (let i = 0; i < 5; i++) { + emitter.emit('chunk:stored', { index: i, size: 256 * 1024 }); + } + p.detach(); + expect(emitter.listenerCount('chunk:stored')).toBe(0); + }); +}); + +describe('createRestoreProgress', () => { + it('returns no-op when quiet is true', () => { + const p = createRestoreProgress({ totalChunks: 5, quiet: true }); + const emitter = new EventEmitter(); + p.attach(emitter); + p.detach(); + expect(emitter.listenerCount('chunk:restored')).toBe(0); + }); + + it('returns no-op for 0-chunk manifests', () => { + const p = createRestoreProgress({ totalChunks: 0, quiet: false }); + const emitter = new EventEmitter(); + p.attach(emitter); + p.detach(); + expect(emitter.listenerCount('chunk:restored')).toBe(0); + }); + + it('attaches and detaches from EventEmitter', () => { + const p = createRestoreProgress({ totalChunks: 3, quiet: false }); + const emitter = new EventEmitter(); + p.attach(emitter); + expect(emitter.listenerCount('chunk:restored')).toBe(1); + emitter.emit('chunk:restored', { index: 0, size: 256 * 1024 }); + p.detach(); + expect(emitter.listenerCount('chunk:restored')).toBe(0); + }); +}); From 62dee28403ac2de78f45e7590ed1660c94621975 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 26 Feb 2026 23:09:42 -0800 Subject: [PATCH 04/11] fix(cli): address M13 dashboard code review findings Fix filter-start not resetting filtered list, handleLoadedEntries ignoring active filter, list pane ignoring width, and formatSize closing brace style. Add 3 missing tests and update ROADMAP labels. --- ROADMAP.md | 6 +++--- bin/ui/dashboard-view.js | 16 +++++++++------- bin/ui/dashboard.js | 4 ++-- test/unit/cli/dashboard.test.js | 25 +++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 928ed54..9d852b2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,17 +164,17 @@ Return and throw semantics for every public method (current and planned). - **Exit 0:** Rotation succeeded, vault updated. - **Exit 1:** Wrong old key, unsupported manifest, or vault error. -### CLI: `git cas vault dashboard` *(planned — Task 13.2)* +### CLI: `git cas vault dashboard` *(implemented)* - **Output:** Interactive full-screen TUI in TTY mode; static table in non-TTY. - **Exit 0:** User quit normally. - **Exit 1:** Vault ref missing or error. -### CLI: `git cas inspect --slug | --oid [--heatmap]` *(planned — Tasks 13.4, 13.5)* +### CLI: `git cas inspect --slug | --oid [--heatmap]` *(implemented)* - **Output:** Structured manifest anatomy view in TTY; JSON dump in non-TTY. - **Exit 0:** Manifest read and displayed. - **Exit 1:** Manifest not found or error. -### CLI: `git cas vault history --pretty` *(planned — Task 13.3)* +### CLI: `git cas vault history --pretty` *(implemented)* - **Output:** Color-coded timeline in TTY; plain `git log --oneline` without `--pretty`. - **Exit 0:** History displayed. - **Exit 1:** Vault ref missing or error. diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index c6fb2d0..8800623 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -13,7 +13,8 @@ function formatSize(bytes) { if (bytes < 1024) { return `${bytes}B`; } if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)}K`; } if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(1)}M`; } - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`; } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`; +} /** * Format manifest stats for the list. @@ -26,11 +27,12 @@ function formatStats(manifest) { /** * Render a single list item. */ -function renderListItem(entry, index, model) { - const prefix = index === model.cursor ? '> ' : ' '; - const manifest = model.manifestCache.get(entry.slug); +function renderListItem(entry, index, opts) { + const prefix = index === opts.model.cursor ? '> ' : ' '; + const manifest = opts.model.manifestCache.get(entry.slug); const stats = manifest ? formatStats(manifest) : '...'; - return `${prefix}${entry.slug} ${stats}`; + const line = `${prefix}${entry.slug} ${stats}`; + return opts.width ? line.slice(0, opts.width) : line; } /** @@ -70,7 +72,7 @@ function renderListPane(model, size) { const { start, end } = visibleRange(model.cursor, items.length, listHeight); const lines = []; for (let i = start; i < end; i++) { - lines.push(renderListItem(items[i], i, model)); + lines.push(renderListItem(items[i], i, { model, width: size.width })); } return padToHeight(lines.join('\n'), listHeight, filterLine); } @@ -103,7 +105,7 @@ function renderBody(model, deps, size) { const listBasis = Math.floor(size.width * 0.35); return flex( { direction: 'row', width: size.width, height: size.height, gap: 1 }, - { content: (_w, h) => renderListPane(model, { height: h }), basis: listBasis }, + { content: (_w, h) => renderListPane(model, { height: h, width: listBasis }), basis: listBasis }, { content: (w, h) => renderDetailPane(model, { width: w, height: h, ctx: deps.ctx }), flex: 1 }, ); } diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js index a2c0bfa..70e0bb5 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -61,7 +61,7 @@ function handleLoadedEntries(msg, model, cas) { ...model, status: 'ready', entries: msg.entries, - filtered: msg.entries, + filtered: applyFilter(msg.entries, model.filterText), metadata: msg.metadata, }, cmds]; } @@ -123,7 +123,7 @@ function handleAction(action, model, deps) { if (action.type === 'quit') { return [model, [quit()]]; } if (action.type === 'move') { return handleMove(action, model); } if (action.type === 'filter-start') { - return [{ ...model, filtering: true, filterText: '' }, []]; + return [{ ...model, filtering: true, filterText: '', filtered: model.entries }, []]; } if (action.type === 'scroll-detail') { const scroll = Math.max(0, model.detailScroll + action.delta); diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index b92081d..2cb42c8 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -121,6 +121,31 @@ describe('dashboard data loading', () => { const [next] = app.update(keyMsg('escape'), model); expect(next.filtering).toBe(false); }); + +}); + +describe('dashboard edge cases', () => { + it('filter-backspace removes last char and re-filters', () => { + const app = createDashboardApp(makeDeps()); + const model = makeModel({ filtering: true, filterText: 'al', entries, filtered: [entries[0]] }); + const [next] = app.update(keyMsg('backspace'), model); + expect(next.filterText).toBe('a'); + expect(next.filtered).toHaveLength(2); + }); + + it('load-error sets error on model', () => { + const app = createDashboardApp(makeDeps()); + const [next] = app.update({ type: 'load-error', error: 'boom' }, makeModel()); + expect(next.error).toBe('boom'); + }); + + it('select on uncached entry returns loadManifestCmd', () => { + const app = createDashboardApp(makeDeps()); + const model = makeModel({ entries, filtered: entries, cursor: 0 }); + const [next, cmds] = app.update(keyMsg('enter'), model); + expect(next.loadingSlug).toBe('alpha'); + expect(cmds).toHaveLength(1); + }); }); describe('dashboard view rendering', () => { From 2532527f064e1cbe0623f752e6584a42e540b683 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 26 Feb 2026 23:24:49 -0800 Subject: [PATCH 05/11] fix(cli): address remaining dashboard review findings Reset cursor on filter-start, use flex-allocated width in list pane, and add regression tests for filter-start reset and active-filter application on loaded-entries. --- bin/ui/dashboard-view.js | 2 +- bin/ui/dashboard.js | 2 +- test/unit/cli/dashboard.test.js | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index 8800623..f45f58e 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -105,7 +105,7 @@ function renderBody(model, deps, size) { const listBasis = Math.floor(size.width * 0.35); return flex( { direction: 'row', width: size.width, height: size.height, gap: 1 }, - { content: (_w, h) => renderListPane(model, { height: h, width: listBasis }), basis: listBasis }, + { content: (w, h) => renderListPane(model, { height: h, width: w }), basis: listBasis }, { content: (w, h) => renderDetailPane(model, { width: w, height: h, ctx: deps.ctx }), flex: 1 }, ); } diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js index 70e0bb5..a1b7257 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -123,7 +123,7 @@ function handleAction(action, model, deps) { if (action.type === 'quit') { return [model, [quit()]]; } if (action.type === 'move') { return handleMove(action, model); } if (action.type === 'filter-start') { - return [{ ...model, filtering: true, filterText: '', filtered: model.entries }, []]; + return [{ ...model, filtering: true, filterText: '', filtered: model.entries, cursor: 0 }, []]; } if (action.type === 'scroll-detail') { const scroll = Math.max(0, model.detailScroll + action.delta); diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index 2cb42c8..6a6bd5e 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -122,6 +122,14 @@ describe('dashboard data loading', () => { expect(next.filtering).toBe(false); }); + it('loaded-entries applies active filter', () => { + const app = createDashboardApp(makeDeps()); + const model = makeModel({ status: 'loading', filterText: 'al', filtering: true }); + const msg = { type: 'loaded-entries', entries, metadata: null }; + const [next] = app.update(msg, model); + expect(next.filtered).toHaveLength(1); + expect(next.filtered[0].slug).toBe('alpha'); + }); }); describe('dashboard edge cases', () => { @@ -131,6 +139,7 @@ describe('dashboard edge cases', () => { const [next] = app.update(keyMsg('backspace'), model); expect(next.filterText).toBe('a'); expect(next.filtered).toHaveLength(2); + expect(next.cursor).toBe(0); }); it('load-error sets error on model', () => { @@ -139,6 +148,14 @@ describe('dashboard edge cases', () => { expect(next.error).toBe('boom'); }); + it('filter-start resets filtered to all entries', () => { + const app = createDashboardApp(makeDeps()); + const model = makeModel({ entries, filtered: [entries[0]], filterText: 'al' }); + const [next] = app.update(keyMsg('/'), model); + expect(next.filtered).toHaveLength(2); + expect(next.filterText).toBe(''); + }); + it('select on uncached entry returns loadManifestCmd', () => { const app = createDashboardApp(makeDeps()); const model = makeModel({ entries, filtered: entries, cursor: 0 }); From 5a9757115698d32669e98fe694a75eba7de53992 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 27 Feb 2026 00:47:35 -0800 Subject: [PATCH 06/11] fix(cli): address CodeRabbit review nits - Use locale-agnostic regex for iterations assertion - Add default return for readManifest mock - Reuse validateRestoreFlags in inspect command - Set status to 'error' on load-error and render it in the view --- bin/git-cas.js | 9 +-------- bin/ui/dashboard-view.js | 2 +- bin/ui/dashboard.js | 2 +- test/unit/cli/dashboard.test.js | 12 ++++++++++-- test/unit/cli/encryption-card.test.js | 2 +- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/bin/git-cas.js b/bin/git-cas.js index fc74db4..456d04a 100755 --- a/bin/git-cas.js +++ b/bin/git-cas.js @@ -165,14 +165,7 @@ program .option('--cwd ', 'Git working directory', '.') .action(async (opts) => { try { - if (opts.slug && opts.oid) { - process.stderr.write('error: Provide --slug or --oid, not both\n'); - process.exit(1); - } - if (!opts.slug && !opts.oid) { - process.stderr.write('error: Provide --slug or --oid \n'); - process.exit(1); - } + validateRestoreFlags(opts); const cas = createCas(opts.cwd); const treeOid = opts.oid || await cas.resolveVaultEntry({ slug: opts.slug }); const manifest = await cas.readManifest({ treeOid }); diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index f45f58e..25411ae 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -65,7 +65,7 @@ function renderListPane(model, size) { const items = model.filtered; if (items.length === 0) { - const msg = model.status === 'loading' ? 'Loading...' : 'No entries'; + const msg = model.status === 'loading' ? 'Loading...' : model.error ? `Error: ${model.error}` : 'No entries'; return padToHeight(msg, listHeight, filterLine); } diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js index a1b7257..f1a6d1c 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -139,7 +139,7 @@ function handleAction(action, model, deps) { function handleAppMsg(msg, model, cas) { if (msg.type === 'loaded-entries') { return handleLoadedEntries(msg, model, cas); } if (msg.type === 'loaded-manifest') { return handleLoadedManifest(msg, model); } - if (msg.type === 'load-error') { return [{ ...model, error: msg.error }, []]; } + if (msg.type === 'load-error') { return [{ ...model, status: 'error', error: msg.error }, []]; } return [model, []]; } diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index 6a6bd5e..b6f6d1f 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -11,7 +11,7 @@ function mockCas() { return { listVault: vi.fn().mockResolvedValue([]), getVaultMetadata: vi.fn().mockResolvedValue(null), - readManifest: vi.fn(), + readManifest: vi.fn().mockResolvedValue(null), }; } @@ -142,10 +142,11 @@ describe('dashboard edge cases', () => { expect(next.cursor).toBe(0); }); - it('load-error sets error on model', () => { + it('load-error sets error and status on model', () => { const app = createDashboardApp(makeDeps()); const [next] = app.update({ type: 'load-error', error: 'boom' }, makeModel()); expect(next.error).toBe('boom'); + expect(next.status).toBe('error'); }); it('filter-start resets filtered to all entries', () => { @@ -182,6 +183,13 @@ describe('dashboard view rendering', () => { expect(output).toContain('bravo'); }); + it('renders error message on error status', () => { + const app = createDashboardApp(makeDeps()); + const model = makeModel({ status: 'error', error: 'connection failed' }); + const output = app.view(model); + expect(output).toContain('Error: connection failed'); + }); + it('renders footer keybinding hints', () => { const app = createDashboardApp(makeDeps()); const model = makeModel(); diff --git a/test/unit/cli/encryption-card.test.js b/test/unit/cli/encryption-card.test.js index 5b5afb9..45e3d5b 100644 --- a/test/unit/cli/encryption-card.test.js +++ b/test/unit/cli/encryption-card.test.js @@ -25,7 +25,7 @@ describe('renderEncryptionCard', () => { }); expect(output).toContain('aes-256-gcm'); expect(output).toContain('pbkdf2'); - expect(output).toContain('600,000'); + expect(output).toMatch(/600[,.]?000/); expect(output).toContain('32 bytes'); expect(output).toContain('locked'); }); From 1c397a93dfdc3a9e7e0fcba900f2aed1fe86631c Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 27 Feb 2026 11:57:14 -0800 Subject: [PATCH 07/11] fix(test): simplify node:fs mock for Bun compatibility The async importOriginal pattern in vi.mock('node:fs') interfered with the context.js mock under Bun's vitest integration in Docker, causing progress tracker tests to fail. Since progress.js only imports statSync, the importOriginal spread is unnecessary. --- test/unit/cli/progress.test.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/test/unit/cli/progress.test.js b/test/unit/cli/progress.test.js index 003a94e..cccb4d6 100644 --- a/test/unit/cli/progress.test.js +++ b/test/unit/cli/progress.test.js @@ -6,13 +6,9 @@ vi.mock('../../../bin/ui/context.js', () => ({ getCliContext: () => makeCtx('static'), })); -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - statSync: vi.fn(() => ({ size: 5 * 256 * 1024 })), - }; -}); +vi.mock('node:fs', () => ({ + statSync: vi.fn(() => ({ size: 5 * 256 * 1024 })), +})); const { createStoreProgress, createRestoreProgress } = await import('../../../bin/ui/progress.js'); From 63b60160f5ff2d7662350eecdc0cda607e83a1e4 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 27 Feb 2026 11:57:19 -0800 Subject: [PATCH 08/11] fix(cli): wrap progress attach/detach in try/finally Prevents event listener leaks and stale terminal state when storeFile or restoreFile throws during progress tracking. --- bin/git-cas.js | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/bin/git-cas.js b/bin/git-cas.js index 456d04a..eaa615c 100755 --- a/bin/git-cas.js +++ b/bin/git-cas.js @@ -116,8 +116,12 @@ program filePath: file, chunkSize: cas.chunkSize, quiet: program.opts().quiet, }); progress.attach(service); - const manifest = await cas.storeFile(storeOpts); - progress.detach(); + let manifest; + try { + manifest = await cas.storeFile(storeOpts); + } finally { + progress.detach(); + } if (opts.tree) { const treeOid = await cas.createTree({ manifest }); @@ -213,11 +217,15 @@ program totalChunks: manifest.chunks.length, quiet: program.opts().quiet, }); progress.attach(service); - const { bytesWritten } = await cas.restoreFile({ - ...restoreOpts, - outputPath: opts.out, - }); - progress.detach(); + let bytesWritten; + try { + ({ bytesWritten } = await cas.restoreFile({ + ...restoreOpts, + outputPath: opts.out, + })); + } finally { + progress.detach(); + } process.stdout.write(`${bytesWritten}\n`); } catch (err) { process.stderr.write(`error: ${err.message}\n`); From a458be54d1587b34c0aa7ac4ad93b72155ce9f4f Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 27 Feb 2026 11:57:25 -0800 Subject: [PATCH 09/11] fix(cli): clamp filter and error lines to pane width Prevents wrapping artifacts in narrow terminals by truncating filter input and empty-state messages to the list pane width. --- bin/ui/dashboard-view.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index 25411ae..bb93f42 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -60,12 +60,19 @@ function renderHeader(model, ctx) { * Render the list pane. */ function renderListPane(model, size) { - const filterLine = model.filtering ? `/${model.filterText}\u2588` : ''; + const clamp = (s) => (size.width ? s.slice(0, size.width) : s); + const filterLine = model.filtering ? clamp(`/${model.filterText}\u2588`) : ''; const listHeight = model.filtering ? size.height - 1 : size.height; const items = model.filtered; if (items.length === 0) { - const msg = model.status === 'loading' ? 'Loading...' : model.error ? `Error: ${model.error}` : 'No entries'; + const msg = clamp( + model.status === 'loading' + ? 'Loading...' + : model.error + ? `Error: ${model.error}` + : 'No entries', + ); return padToHeight(msg, listHeight, filterLine); } From 5c48df5c59aee230b62eba55e1d766418881331b Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 27 Feb 2026 11:57:31 -0800 Subject: [PATCH 10/11] fix(cli): differentiate entry vs manifest load errors A single manifest preload failure no longer sets the global dashboard error state. Also clamps cursor after applying filters on entry load to prevent stale cursor positions. --- bin/ui/dashboard-cmds.js | 4 ++-- bin/ui/dashboard.js | 12 ++++++++++-- test/unit/cli/dashboard.test.js | 21 +++++++++++++++++++-- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/bin/ui/dashboard-cmds.js b/bin/ui/dashboard-cmds.js index 3b385ac..0096170 100644 --- a/bin/ui/dashboard-cmds.js +++ b/bin/ui/dashboard-cmds.js @@ -14,7 +14,7 @@ export function loadEntriesCmd(cas) { ]); return { type: 'loaded-entries', entries, metadata }; } catch (err) { - return { type: 'load-error', error: err.message }; + return { type: 'load-error', source: 'entries', error: err.message }; } }; } @@ -28,7 +28,7 @@ export function loadManifestCmd(cas, slug, treeOid) { const manifest = await cas.readManifest({ treeOid }); return { type: 'loaded-manifest', slug, manifest }; } catch (err) { - return { type: 'load-error', error: err.message }; + return { type: 'load-error', source: 'manifest', slug, error: err.message }; } }; } diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js index f1a6d1c..0a89fce 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -56,12 +56,15 @@ function applyFilter(entries, text) { * Handle the loaded-entries message. */ function handleLoadedEntries(msg, model, cas) { + const filtered = applyFilter(msg.entries, model.filterText); + const cursor = Math.max(0, Math.min(model.cursor, filtered.length - 1)); const cmds = msg.entries.map(e => loadManifestCmd(cas, e.slug, e.treeOid)); return [{ ...model, status: 'ready', entries: msg.entries, - filtered: applyFilter(msg.entries, model.filterText), + filtered, + cursor, metadata: msg.metadata, }, cmds]; } @@ -139,7 +142,12 @@ function handleAction(action, model, deps) { function handleAppMsg(msg, model, cas) { if (msg.type === 'loaded-entries') { return handleLoadedEntries(msg, model, cas); } if (msg.type === 'loaded-manifest') { return handleLoadedManifest(msg, model); } - if (msg.type === 'load-error') { return [{ ...model, status: 'error', error: msg.error }, []]; } + if (msg.type === 'load-error') { + if (msg.source === 'manifest') { + return [model, []]; + } + return [{ ...model, status: 'error', error: msg.error }, []]; + } return [model, []]; } diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index b6f6d1f..e2c484b 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -142,13 +142,30 @@ describe('dashboard edge cases', () => { expect(next.cursor).toBe(0); }); - it('load-error sets error and status on model', () => { + it('load-error from entries sets error and status on model', () => { const app = createDashboardApp(makeDeps()); - const [next] = app.update({ type: 'load-error', error: 'boom' }, makeModel()); + const [next] = app.update({ type: 'load-error', source: 'entries', error: 'boom' }, makeModel()); expect(next.error).toBe('boom'); expect(next.status).toBe('error'); }); + it('load-error from manifest does not set global error', () => { + const app = createDashboardApp(makeDeps()); + const model = makeModel({ status: 'ready', entries, filtered: entries }); + const [next] = app.update({ type: 'load-error', source: 'manifest', slug: 'alpha', error: 'oops' }, model); + expect(next.status).toBe('ready'); + expect(next.error).toBeNull(); + }); + + it('loaded-entries clamps cursor to filtered bounds', () => { + const app = createDashboardApp(makeDeps()); + const model = makeModel({ status: 'loading', cursor: 5, filterText: 'al' }); + const msg = { type: 'loaded-entries', entries, metadata: null }; + const [next] = app.update(msg, model); + expect(next.cursor).toBe(0); + expect(next.filtered).toHaveLength(1); + }); + it('filter-start resets filtered to all entries', () => { const app = createDashboardApp(makeDeps()); const model = makeModel({ entries, filtered: [entries[0]], filterText: 'al' }); From 4fc85e9e67b83ec29481354fca751f5a3eab7456 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 27 Feb 2026 12:06:42 -0800 Subject: [PATCH 11/11] fix(test): eliminate node:fs mock for Bun Docker compatibility Multiple vi.mock calls in a single file cause mock factory resolution issues under Bun's vitest integration in Docker. Add optional fileSize parameter to createStoreProgress so tests can bypass statSync directly, removing the need for the second vi.mock entirely. --- bin/ui/progress.js | 4 ++-- test/unit/cli/progress.test.js | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/bin/ui/progress.js b/bin/ui/progress.js index 1c47ba6..422c633 100644 --- a/bin/ui/progress.js +++ b/bin/ui/progress.js @@ -32,7 +32,7 @@ function formatBytes(bytes) { * @param {boolean} [options.quiet] - Suppress all progress output. * @returns {{ attach(service: EventEmitter): void, detach(): void }} */ -export function createStoreProgress({ filePath, chunkSize, quiet }) { +export function createStoreProgress({ filePath, chunkSize, quiet, fileSize: providedSize }) { if (quiet) { return { attach() {}, detach() {} }; } @@ -42,7 +42,7 @@ export function createStoreProgress({ filePath, chunkSize, quiet }) { return { attach() {}, detach() {} }; } - const fileSize = statSync(filePath).size; + const fileSize = providedSize ?? statSync(filePath).size; const totalChunks = fileSize === 0 ? 0 : Math.ceil(fileSize / chunkSize); if (totalChunks === 0) { diff --git a/test/unit/cli/progress.test.js b/test/unit/cli/progress.test.js index cccb4d6..1e9e8b4 100644 --- a/test/unit/cli/progress.test.js +++ b/test/unit/cli/progress.test.js @@ -6,12 +6,10 @@ vi.mock('../../../bin/ui/context.js', () => ({ getCliContext: () => makeCtx('static'), })); -vi.mock('node:fs', () => ({ - statSync: vi.fn(() => ({ size: 5 * 256 * 1024 })), -})); - const { createStoreProgress, createRestoreProgress } = await import('../../../bin/ui/progress.js'); +const FILE_SIZE = 5 * 256 * 1024; + describe('createStoreProgress', () => { it('returns no-op when quiet is true', () => { const p = createStoreProgress({ filePath: 'test.bin', chunkSize: 256 * 1024, quiet: true }); @@ -23,7 +21,7 @@ describe('createStoreProgress', () => { }); it('attaches and detaches from EventEmitter', () => { - const p = createStoreProgress({ filePath: 'test.bin', chunkSize: 256 * 1024, quiet: false }); + const p = createStoreProgress({ filePath: 'test.bin', chunkSize: 256 * 1024, quiet: false, fileSize: FILE_SIZE }); const emitter = new EventEmitter(); p.attach(emitter); expect(emitter.listenerCount('chunk:stored')).toBe(1); @@ -32,7 +30,7 @@ describe('createStoreProgress', () => { }); it('tracks chunk events without throwing', () => { - const p = createStoreProgress({ filePath: 'test.bin', chunkSize: 256 * 1024, quiet: false }); + const p = createStoreProgress({ filePath: 'test.bin', chunkSize: 256 * 1024, quiet: false, fileSize: FILE_SIZE }); const emitter = new EventEmitter(); p.attach(emitter); for (let i = 0; i < 5; i++) {