Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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.
- **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
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
51 changes: 38 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,
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,50 @@ 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;
}
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 });
}
7 changes: 6 additions & 1 deletion test/integration/round-trip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* MUST run inside Docker (GIT_STUNTS_DOCKER=1). Refuses to run on the host.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { spawnSync } from 'node:child_process';
Expand All @@ -27,6 +27,11 @@ if (process.env.GIT_STUNTS_DOCKER !== '1') {
);
}

vi.setConfig({
testTimeout: 15000,
hookTimeout: 30000,
});

let repoDir;
let cas;
let casCbor;
Expand Down
2 changes: 1 addition & 1 deletion test/integration/vault-cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const RUNTIME_CMD = globalThis.Bun
function runCli(args, cwd) {
return spawnSync(RUNTIME_CMD[0], [...RUNTIME_CMD.slice(1), ...args, '--cwd', cwd], {
encoding: 'utf8',
timeout: 30_000,
timeout: 90_000,
});
}

Expand Down
4 changes: 2 additions & 2 deletions test/unit/cli/_testContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
*/
import { createTestContext } from '@flyingrobots/bijou/adapters/test';

export function makeCtx(mode = 'interactive') {
return createTestContext({ mode, noColor: true });
export function makeCtx(mode = 'interactive', runtime = {}) {
return createTestContext({ mode, noColor: true, runtime });
}
67 changes: 67 additions & 0 deletions test/unit/cli/context.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';

import { detectCliTuiMode } from '../../../bin/ui/context.js';

function makeRuntime(overrides = {}) {
return {
env: (key) => overrides.env?.[key],
stdoutIsTTY: overrides.stdoutIsTTY ?? true,
stdinIsTTY: overrides.stdinIsTTY ?? true,
columns: overrides.columns ?? 80,
rows: overrides.rows ?? 24,
};
}

describe('detectCliTuiMode interactive modes', () => {
it('uses accessible mode when BIJOU_ACCESSIBLE=1', () => {
const mode = detectCliTuiMode(makeRuntime({
env: { BIJOU_ACCESSIBLE: '1', TERM: 'xterm-256color' },
}));

expect(mode).toBe('accessible');
});

it('falls back to pipe when TERM is dumb', () => {
const mode = detectCliTuiMode(makeRuntime({
env: { TERM: 'dumb' },
}));

expect(mode).toBe('pipe');
});

it('stays interactive on a TTY when NO_COLOR is set', () => {
const mode = detectCliTuiMode(makeRuntime({
env: { NO_COLOR: '1', TERM: 'xterm-256color' },
}));

expect(mode).toBe('interactive');
});
});

describe('detectCliTuiMode non-interactive fallbacks', () => {
it('falls back to pipe when stdout is not a TTY', () => {
const mode = detectCliTuiMode(makeRuntime({
env: { TERM: 'xterm-256color' },
stdoutIsTTY: false,
}));

expect(mode).toBe('pipe');
});

it('falls back to pipe when stdin is not a TTY', () => {
const mode = detectCliTuiMode(makeRuntime({
env: { TERM: 'xterm-256color' },
stdinIsTTY: false,
}));

expect(mode).toBe('pipe');
});

it('falls back to static in CI on a TTY', () => {
const mode = detectCliTuiMode(makeRuntime({
env: { CI: 'true', TERM: 'xterm-256color' },
}));

expect(mode).toBe('static');
});
});
55 changes: 55 additions & 0 deletions test/unit/cli/dashboard.launch.default.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockRuntime, mockIO, plainStyle } from '@flyingrobots/bijou/adapters/test';

const runMock = vi.fn().mockResolvedValue(undefined);

function mockCas(entries = []) {
return {
listVault: vi.fn().mockResolvedValue(entries),
getVaultMetadata: vi.fn().mockResolvedValue(null),
readManifest: vi.fn().mockResolvedValue(null),
};
}

beforeEach(() => {
vi.resetModules();
runMock.mockClear();
});

describe('launchDashboard default context path', () => {
it('stays interactive on a tty when NO_COLOR is set', async () => {
vi.doMock('@flyingrobots/bijou-tui', async () => {
const actual = await vi.importActual('@flyingrobots/bijou-tui');
return { ...actual, run: runMock };
});

vi.doMock('@flyingrobots/bijou-node', async () => {
const actual = await vi.importActual('@flyingrobots/bijou-node');
return {
...actual,
nodeRuntime: () => mockRuntime({
env: { NO_COLOR: '1', TERM: 'xterm-256color' },
stdoutIsTTY: true,
stdinIsTTY: true,
columns: 111,
rows: 42,
}),
nodeIO: () => mockIO(),
chalkStyle: () => plainStyle(),
};
});

const { launchDashboard } = await import('../../../bin/ui/dashboard.js');
const cas = mockCas();

await launchDashboard(cas);

expect(runMock).toHaveBeenCalledTimes(1);
expect(cas.listVault).not.toHaveBeenCalled();

const [app] = runMock.mock.calls[0];
const [model] = app.init();
expect(model.columns).toBe(111);
expect(model.rows).toBe(42);
});
});
Loading