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..9d852b2 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` *(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]` *(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` *(implemented)* +- **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/git-cas.js b/bin/git-cas.js index 28b3931..eaa615c 100755 --- a/bin/git-cas.js +++ b/bin/git-cas.js @@ -5,11 +5,17 @@ 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. @@ -70,20 +76,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). */ @@ -119,7 +111,17 @@ program storeOpts.encryptionKey = encryptionKey; } - const manifest = await cas.storeFile(storeOpts); + const service = await cas.getService(); + const progress = createStoreProgress({ + filePath: file, chunkSize: cas.chunkSize, quiet: program.opts().quiet, + }); + progress.attach(service); + let manifest; + try { + manifest = await cas.storeFile(storeOpts); + } finally { + progress.detach(); + } if (opts.tree) { const treeOid = await cas.createTree({ manifest }); @@ -155,6 +157,36 @@ 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 { + validateRestoreFlags(opts); + 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 // --------------------------------------------------------------------------- @@ -172,8 +204,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); @@ -181,10 +212,20 @@ program restoreOpts.encryptionKey = encryptionKey; } - const { bytesWritten } = await cas.restoreFile({ - ...restoreOpts, - outputPath: opts.out, + const service = await cas.getService(); + const progress = createRestoreProgress({ + totalChunks: manifest.chunks.length, quiet: program.opts().quiet, }); + progress.attach(service); + 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`); @@ -266,6 +307,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 { @@ -273,6 +315,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); @@ -287,6 +333,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(); @@ -301,7 +348,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/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/dashboard-cmds.js b/bin/ui/dashboard-cmds.js new file mode 100644 index 0000000..0096170 --- /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', source: 'entries', 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', source: 'manifest', slug, error: err.message }; + } + }; +} diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js new file mode 100644 index 0000000..bb93f42 --- /dev/null +++ b/bin/ui/dashboard-view.js @@ -0,0 +1,132 @@ +/** + * 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, opts) { + const prefix = index === opts.model.cursor ? '> ' : ' '; + const manifest = opts.model.manifestCache.get(entry.slug); + const stats = manifest ? formatStats(manifest) : '...'; + const line = `${prefix}${entry.slug} ${stats}`; + return opts.width ? line.slice(0, opts.width) : line; +} + +/** + * 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 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 = clamp( + model.status === 'loading' + ? 'Loading...' + : model.error + ? `Error: ${model.error}` + : '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, width: size.width })); + } + 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, width: w }), 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..0a89fce --- /dev/null +++ b/bin/ui/dashboard.js @@ -0,0 +1,204 @@ +/** + * 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 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, + cursor, + 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: '', filtered: model.entries, cursor: 0 }, []]; + } + 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') { + if (msg.source === 'manifest') { + return [model, []]; + } + return [{ ...model, status: 'error', 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/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/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/bin/ui/progress.js b/bin/ui/progress.js new file mode 100644 index 0000000..422c633 --- /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, fileSize: providedSize }) { + if (quiet) { + return { attach() {}, detach() {} }; + } + + const ctx = getCliContext(); + if (ctx.mode === 'pipe') { + return { attach() {}, detach() {} }; + } + + const fileSize = providedSize ?? 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/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 }); + }); +}); 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/dashboard.test.js b/test/unit/cli/dashboard.test.js new file mode 100644 index 0000000..e2c484b --- /dev/null +++ b/test/unit/cli/dashboard.test.js @@ -0,0 +1,217 @@ +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().mockResolvedValue(null), + }; +} + +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); + }); + + 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', () => { + 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); + expect(next.cursor).toBe(0); + }); + + it('load-error from entries sets error and status on model', () => { + const app = createDashboardApp(makeDeps()); + 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' }); + 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 }); + const [next, cmds] = app.update(keyMsg('enter'), model); + expect(next.loadingSlug).toBe('alpha'); + expect(cmds).toHaveLength(1); + }); +}); + +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 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(); + const output = app.view(model); + expect(output).toContain('Navigate'); + expect(output).toContain('Quit'); + }); +}); diff --git a/test/unit/cli/encryption-card.test.js b/test/unit/cli/encryption-card.test.js new file mode 100644 index 0000000..45e3d5b --- /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).toMatch(/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..1e9e8b4 --- /dev/null +++ b/test/unit/cli/progress.test.js @@ -0,0 +1,70 @@ +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'), +})); + +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 }); + 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, fileSize: FILE_SIZE }); + 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, fileSize: FILE_SIZE }); + 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); + }); +});