Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Review automation baseline** — added `.github/CODEOWNERS` with repo-wide ownership for `@git-stunts`.
- **Release runbook** — added `docs/RELEASE.md` and linked it from `CONTRIBUTING.md` as the canonical patch-release workflow.
- **`pnpm release:verify`** — new maintainer-facing release helper runs the full release checklist, captures observed test counts, and prints a Markdown summary that can be pasted into release notes or changelog prep.
- **`git cas vault stats`** — new vault summary command reports logical size, chunk references, dedupe ratio, encryption coverage, compression usage, and chunking strategy breakdowns.
- **`git cas doctor`** — new diagnostics command scans `refs/cas/vault`, validates every referenced manifest, and exits non-zero with structured issue output when it finds broken entries or a missing vault ref.
- **Deterministic property-based envelope coverage** — added a `fast-check`-backed property suite for envelope-encrypted store/restore round-trips and tamper rejection across empty, boundary-adjacent, and multi-chunk payload sizes.

### Changed
Expand All @@ -21,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **Bun blob writes in Git persistence** — `GitPersistenceAdapter.writeBlob()` now hashes temp files instead of piping large buffers through `git hash-object --stdin` under Bun, avoiding unhandled `EPIPE` failures during real Git-backed stores.
- **Release verification runner failures** — `runReleaseVerify()` now converts thrown step-runner errors into structured step failures with a `ReleaseVerifyError` summary instead of letting raw exceptions escape.
- **Machine-readable release verification** — `pnpm release:verify --json` now emits structured JSON on both success and failure paths, making CI automation and release-note tooling consume the same verification source of truth.
- **Dashboard launch context normalization** — `launchDashboard()` now treats injected Bijou contexts without an explicit `mode` as interactive, avoiding an incorrect static fallback, and the CLI mode tests now lock the `BIJOU_ACCESSIBLE` and `TERM=dumb` branches.

## [5.3.2] — 2026-03-15

Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ We use the object database.
- **Full round-trip** store, tree, and restore — get your bytes back, verified.
- **Lifecycle management** `readManifest`, `inspectAsset`, `collectReferencedChunks` — inspect trees, plan deletions, audit storage.
- **Vault** GC-safe ref-based storage. One ref (`refs/cas/vault`) indexes all assets by slug. No more silent data loss from `git gc`.
- **Vault diagnostics** `git cas vault stats` summarizes size/dedupe/encryption coverage, and `git cas doctor` scans the vault for broken manifests before they surprise you.
- **Interactive dashboard** `git cas inspect` with chunk heatmap, animated progress bars, and rich manifest views.
- **Verify & JSON output** `git cas verify` checks integrity; `--json` on all current human-facing commands provides convenient structured output for CI/scripting.
- **Verify & JSON output** `git cas verify` checks integrity; `--json` on all current human-facing commands provides convenient structured output for CI/scripting, including `pnpm release:verify --json` for release automation.

**Use it for:** binary assets, build artifacts, model weights, data packs, secret bundles, weird experiments, etc.

Expand Down Expand Up @@ -180,6 +181,8 @@ See [CHANGELOG.md](./CHANGELOG.md) for the full list of changes.

**`--json` everywhere** — all commands now support `--json` for structured output. Pipe `git cas vault list --json | jq` in CI.

**Vault diagnostics** — `git cas vault stats` surfaces logical size, dedupe, chunking, and encryption coverage; `git cas doctor` scans the current vault and exits non-zero when it finds trouble.

**CryptoPort base class** — shared key validation, metadata building, and KDF normalization. All three adapters (Node/Bun/Web) inherit from a single source of truth.

**Centralized error handling** — `runAction` wrapper with CasError codes and actionable hints (e.g., "Provide --key-file or --vault-passphrase").
Expand Down Expand Up @@ -296,9 +299,12 @@ git cas vault init
git cas vault list # TTY table
git cas vault list --json # structured JSON
git cas vault list --filter "photos/*" # glob filter
git cas vault stats # size / dedupe / coverage summary
git cas vault info my-image
git cas vault remove my-image
git cas vault history
git cas doctor # vault health scan
pnpm release:verify --json # machine-readable release report

# Multi-recipient encryption
git cas store ./secret.bin --slug shared \
Expand Down
50 changes: 50 additions & 0 deletions bin/git-cas.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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';
import { buildVaultStats, inspectVaultHealth, renderDoctorReport, renderVaultStats } from './ui/vault-report.js';
import { runAction } from './actions.js';
import { flushStdioAndExit, installBrokenPipeHandlers } from './io.js';
import { filterEntries, formatTable, formatTabSeparated } from './ui/vault-list.js';
Expand Down Expand Up @@ -415,6 +416,29 @@ program
}
}, getJson));

// ---------------------------------------------------------------------------
// doctor
// ---------------------------------------------------------------------------
program
.command('doctor')
.description('Inspect vault health and surface integrity issues')
.option('--cwd <dir>', 'Git working directory', '.')
.action(runAction(async (/** @type {Record<string, any>} */ opts) => {
const cas = createCas(opts.cwd);
const report = await inspectVaultHealth(cas);
const json = program.opts().json;

if (json) {
process.stdout.write(`${JSON.stringify(report)}\n`);
} else {
process.stdout.write(renderDoctorReport(report));
}

if (report.status !== 'ok') {
process.exitCode = 1;
}
}, getJson));

// ---------------------------------------------------------------------------
// vault init
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -469,6 +493,32 @@ vault
}
}, getJson));

// ---------------------------------------------------------------------------
// vault stats
// ---------------------------------------------------------------------------
vault
.command('stats')
.description('Summarize vault size, dedupe, and encryption coverage')
.option('--filter <pattern>', 'Filter entries by glob pattern')
.option('--cwd <dir>', 'Git working directory', '.')
.action(runAction(async (/** @type {Record<string, any>} */ opts) => {
const cas = createCas(opts.cwd);
const all = await cas.listVault();
const entries = filterEntries(all, opts.filter);
const records = [];
for (const entry of entries) {
const manifest = await cas.readManifest({ treeOid: entry.treeOid });
records.push({ ...entry, manifest });
}
const stats = buildVaultStats(records);
const json = program.opts().json;
if (json) {
process.stdout.write(`${JSON.stringify(stats)}\n`);
} else {
process.stdout.write(renderVaultStats(stats));
}
}, getJson));

// ---------------------------------------------------------------------------
// vault remove
// ---------------------------------------------------------------------------
Expand Down
54 changes: 53 additions & 1 deletion bin/ui/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import { createBijou } from '@flyingrobots/bijou';
import { nodeRuntime, chalkStyle } from '@flyingrobots/bijou-node';
import { nodeRuntime, nodeIO, chalkStyle } from '@flyingrobots/bijou-node';

/** @type {import('@flyingrobots/bijou').BijouContext | null} */
let ctx = null;
Expand All @@ -28,6 +28,58 @@ export function getCliContext() {
return ctx;
}

/**
* Detect the display mode for full-screen CLI TUI flows.
*
* Unlike Bijou's default detection, NO_COLOR only disables styling here.
* It must not downgrade a real TTY session out of interactive mode.
*
* @param {import('@flyingrobots/bijou').RuntimePort} runtime
* @returns {'interactive' | 'pipe' | 'static' | 'accessible'}
*/
export function detectCliTuiMode(runtime) {
if (runtime.env('BIJOU_ACCESSIBLE') === '1') {
return 'accessible';
}
if (runtime.env('TERM') === 'dumb') {
return 'pipe';
}
if (!runtime.stdoutIsTTY || !runtime.stdinIsTTY) {
return 'pipe';
}
if (runtime.env('CI') !== undefined) {
return 'static';
}
return 'interactive';
}

/**
* Returns a bijou context for interactive CLI TUI flows.
*
* This keeps NO_COLOR behavior for styling while preserving interactive mode
* on real TTYs.
*
* @param {{
* runtime?: import('@flyingrobots/bijou').RuntimePort,
* io?: import('@flyingrobots/bijou').IOPort,
* style?: import('@flyingrobots/bijou').StylePort,
* }} [options]
* @returns {import('@flyingrobots/bijou').BijouContext}
*/
export function createCliTuiContext(options = {}) {
const runtime = options.runtime || nodeRuntime();
const noColor = runtime.env('NO_COLOR') !== undefined;
const base = createBijou({
runtime,
io: options.io || nodeIO(),
style: options.style || chalkStyle(noColor),
});
return {
...base,
mode: detectCliTuiMode(runtime),
};
}

/**
* @returns {import('@flyingrobots/bijou').IOPort}
*/
Expand Down
54 changes: 41 additions & 13 deletions bin/ui/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
*/

import { run, quit, createKeyMap } from '@flyingrobots/bijou-tui';
import { createNodeContext } from '@flyingrobots/bijou-node';
import { loadEntriesCmd, loadManifestCmd } from './dashboard-cmds.js';
import { createCliTuiContext, detectCliTuiMode } from './context.js';
import { renderDashboard } from './dashboard-view.js';

/**
Expand Down Expand Up @@ -79,13 +79,14 @@ export function createKeyBindings() {
/**
* Create the initial model.
*
* @param {BijouContext} ctx
* @returns {DashModel}
*/
function createInitModel() {
function createInitModel(ctx) {
return {
status: 'loading',
columns: process.stdout.columns ?? 80,
rows: process.stdout.rows ?? 24,
columns: ctx.runtime.columns ?? 80,
rows: ctx.runtime.rows ?? 24,
Comment on lines +85 to +89
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential TypeError if ctx has mode but no runtime.

normalizeLaunchContext only validates that runtime exists when mode is absent (lines 306-308). If a caller passes a context with mode set but runtime undefined, this function will throw TypeError: Cannot read properties of undefined (reading 'columns').

Consider adding a defensive check or documenting the invariant that ctx.runtime is always required.

Suggested defensive check
 function createInitModel(ctx) {
+  const runtime = ctx.runtime ?? {};
   return {
     status: 'loading',
-    columns: ctx.runtime.columns ?? 80,
-    rows: ctx.runtime.rows ?? 24,
+    columns: runtime.columns ?? 80,
+    rows: runtime.rows ?? 24,
     entries: [],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/ui/dashboard.js` around lines 85 - 89, The createInitModel function reads
ctx.runtime.columns and ctx.runtime.rows but normalizeLaunchContext only ensures
runtime when mode is missing, so add a defensive check in createInitModel: if
ctx.runtime is falsy, either assign a default runtime object (e.g., {columns:
80, rows: 24}) or throw a clear error; update createInitModel (and/or
normalizeLaunchContext) to guarantee ctx.runtime exists before accessing
properties so calls that pass a context with mode set but no runtime don't cause
a TypeError.

entries: [],
filtered: [],
cursor: 0,
Expand Down Expand Up @@ -272,7 +273,7 @@ function handleUpdate(msg, model, deps) {
*/
export function createDashboardApp(deps) {
return {
init: () => /** @type {[DashModel, DashCmd[]]} */ ([createInitModel(), [/** @type {DashCmd} */ (loadEntriesCmd(deps.cas))]]),
init: () => /** @type {[DashModel, DashCmd[]]} */ ([createInitModel(deps.ctx), [/** @type {DashCmd} */ (loadEntriesCmd(deps.cas))]]),
update: (/** @type {KeyMsg | ResizeMsg | DashMsg} */ msg, /** @type {DashModel} */ model) => handleUpdate(msg, model, deps),
view: (/** @type {DashModel} */ model) => renderDashboard(model, deps),
};
Expand All @@ -281,26 +282,53 @@ export function createDashboardApp(deps) {
/**
* Print static list for non-TTY environments.
*
* @param {ContentAddressableStore} cas
* @param {ContentAddressableStore} cas Content-addressable store read by printStaticList.
* @param {Pick<NodeJS.WriteStream, 'write'> | NodeJS.WriteStream} [output=process.stdout] Output stream used by printStaticList to write each entry.
*/
async function printStaticList(cas) {
async function printStaticList(cas, output = process.stdout) {
const entries = await cas.listVault();
for (const { slug, treeOid } of entries) {
process.stdout.write(`${slug}\t${treeOid}\n`);
output.write(`${slug}\t${treeOid}\n`);
}
}

/**
* Ensure launchDashboard has a mode before branching on interactive behavior.
*
* @param {BijouContext} ctx
* @returns {BijouContext}
*/
function normalizeLaunchContext(ctx) {
const candidate = /** @type {BijouContext & { mode?: import('@flyingrobots/bijou').OutputMode }} */ (ctx);
if (candidate.mode) {
return candidate;
}
if (!candidate.runtime) {
throw new TypeError('launchDashboard requires ctx.runtime when ctx.mode is absent');
}
return {
...candidate,
mode: detectCliTuiMode(candidate.runtime),
};
}

/**
* Launch the interactive vault dashboard.
*
* @param {ContentAddressableStore} cas
* @param {{
* ctx?: BijouContext,
* runApp?: typeof run,
* output?: Pick<NodeJS.WriteStream, 'write'>,
* }} [options]
*/
export async function launchDashboard(cas) {
if (!process.stdout.isTTY) {
return printStaticList(cas);
export async function launchDashboard(cas, options = {}) {
const ctx = options.ctx ? normalizeLaunchContext(options.ctx) : createCliTuiContext();
if (ctx.mode !== 'interactive') {
return printStaticList(cas, options.output);
}
const ctx = createNodeContext();
const keyMap = createKeyBindings();
const deps = { keyMap, cas, ctx };
return run(createDashboardApp(deps), { ctx });
const runApp = options.runApp || run;
return runApp(createDashboardApp(deps), { ctx });
}
Loading