Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/green-buses-log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hexbus": patch
---

Add `--log-file` and `--log-format` transcript capture for CLI output.
63 changes: 63 additions & 0 deletions packages/hexbus/src/__tests__/spawned-cli.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const versionCachePath = path.join(
versionCacheDir,
`${FIXTURE_PACKAGE_NAME}.json`
);
const transcriptDir = path.join(os.tmpdir(), "hexbus-transcripts");

interface FixtureCliResult {
code: number | null;
Expand Down Expand Up @@ -47,6 +48,14 @@ async function writeVersionCache(): Promise<void> {
);
}

async function makeTranscriptPath(name: string): Promise<string> {
await fs.mkdir(transcriptDir, { recursive: true });
return path.join(
transcriptDir,
`${name}-${Date.now()}-${Math.random().toString(16).slice(2)}.log`
);
}

function runFixtureCli(args: string[]): Promise<FixtureCliResult> {
return new Promise((resolve, reject) => {
const child = spawn("bun", [fixturePath, ...args], {
Expand Down Expand Up @@ -102,6 +111,7 @@ beforeEach(async () => {

afterAll(async () => {
await fs.rm(versionCachePath, { force: true });
await fs.rm(transcriptDir, { force: true, recursive: true });
});

describe("spawned fixture CLI", () => {
Expand Down Expand Up @@ -195,4 +205,57 @@ describe("spawned fixture CLI", () => {
expectCleanExit(result);
expect(result.stdout).toContain("telemetry disabled: true");
});

it("writes a plain text output transcript with --log-file", async () => {
const transcriptPath = await makeTranscriptPath("plain");
const result = await runFixtureCli([
"--log-file",
transcriptPath,
"hello",
"transcript",
]);

expectCleanExit(result);
const transcript = await fs.readFile(transcriptPath, "utf-8");
expect(transcript).toContain("hello args: transcript");
});

it("writes structured JSONL output with --log-format jsonl", async () => {
const transcriptPath = await makeTranscriptPath("jsonl");
const result = await runFixtureCli([
"--log-file",
transcriptPath,
"--log-format",
"jsonl",
"hello",
"agent",
]);

expectCleanExit(result);
const transcript = await fs.readFile(transcriptPath, "utf-8");
const lines = transcript
.trim()
.split("\n")
.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(lines[0]).toMatchObject({
appName: "fixture-cli",
format: "jsonl",
type: "start",
});
expect(lines).toContainEqual(
expect.objectContaining({
stream: "stdout",
type: "output",
})
);
expect(
lines.some(
(line) =>
line.type === "output" &&
typeof line.text === "string" &&
line.text.includes("hello args: agent")
)
).toBe(true);
expect(lines.at(-1)).toMatchObject({ type: "end" });
});
});
24 changes: 24 additions & 0 deletions packages/hexbus/src/__tests__/transcript.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";

import { parseTranscriptFlags } from "../transcript";

describe(parseTranscriptFlags, () => {
it("parses spaced and equals-form transcript flags", () => {
expect(
parseTranscriptFlags(["--log-file", "run.log", "--log-format=jsonl"])
).toStrictEqual({
filePath: "run.log",
format: "jsonl",
});
});

it("does not consume another flag as a missing transcript value", () => {
expect(parseTranscriptFlags(["--log-file", "--help"])).toBeNull();
expect(
parseTranscriptFlags(["--log-file=run.log", "--log-format", "--help"])
).toStrictEqual({
filePath: "run.log",
format: "text",
});
});
});
8 changes: 8 additions & 0 deletions packages/hexbus/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,14 @@ export {
type TelemetryEventNameType,
type TelemetryOptions,
} from "./telemetry";
export {
type OutputTranscript,
parseTranscriptFlags,
type StartTranscriptOptions,
startOutputTranscript,
type TranscriptFlagOptions,
type TranscriptFormat,
} from "./transcript";
export type {
CliCommandAlias,
CliCommandCategory,
Expand Down
13 changes: 13 additions & 0 deletions packages/hexbus/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ export const globalFlags: CliFlag[] = [
names: ["--logger"],
type: "string",
},
{
description: "Write stdout and stderr to a transcript file",
expectsValue: true,
names: ["--log-file"],
type: "string",
},
{
defaultValue: "text",
description: "Set transcript format (text, jsonl)",
expectsValue: true,
names: ["--log-format"],
type: "string",
},
{
defaultValue: false,
description: "Force color output",
Expand Down
26 changes: 20 additions & 6 deletions packages/hexbus/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { displayIntro } from "./intro";
import type { DisplayIntroOptions } from "./intro";
import { globalFlags } from "./parser";
import { TelemetryEventName } from "./telemetry";
import { parseTranscriptFlags, startOutputTranscript } from "./transcript";
import type { CliCommand, CliContext, CliFlag, PackageInfo } from "./types";
import {
isVersionRequest,
Expand Down Expand Up @@ -796,14 +797,23 @@ export async function runCli<
TContext extends CliContext<TPackage> = CliContext<TPackage>,
>(options: RunCliOptions<TPackage, TContext>): Promise<void> {
const rawArgs = options.rawArgs ?? process.argv.slice(2);

if (await handleVersionRequest(options, rawArgs)) {
return;
}

const state: RunCliState<TPackage, TContext> = { outcome: "completed" };
const transcriptFlags = parseTranscriptFlags(rawArgs);
const transcript =
transcriptFlags === null
? null
: startOutputTranscript({
...transcriptFlags,
appName: options.appName,
cwd: options.context?.cwd ?? process.cwd(),
rawArgs,
});

try {
if (await handleVersionRequest(options, rawArgs)) {
return;
}

state.context = await createRunnerContext(options, rawArgs);
state.context.telemetry.trackEvent(TelemetryEventName.CLI_INVOKED, {
command: state.context.commandName ?? "none",
Expand Down Expand Up @@ -833,5 +843,9 @@ export async function runCli<
await shutdownRunnerTelemetry(state);
}

handleRunnerError(state);
try {
handleRunnerError(state);
} finally {
await transcript?.close(state.errorToHandle === undefined ? 0 : 1);
}
Comment on lines +846 to +850

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect how handleError / openTuiExit terminate the process
ast-grep --pattern 'export function openTuiExit($$$) { $$$ }'
rg -nP --type=ts -C5 '\bprocess\.exit\s*\('
rg -nP --type=ts -C3 '\bhandleError\b'

Repository: inthhq/hexbus

Length of output: 6247


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the exact runner try/finally region mentioned in the review comment
sed -n '820,870p' packages/hexbus/src/runner.ts

# Inspect runner's handleRunnerError implementation and how it invokes error handling
rg -n "function handleRunnerError|handleRunnerError\\(" packages/hexbus/src/runner.ts
rg -n "handleRunnerError\\(" packages/hexbus/src/runner.ts -n

# Show the relevant part of errors.ts where handleError terminates
sed -n '220,330p' packages/hexbus/src/errors.ts

# Show the full openTuiExit implementation area (including where process.exit is called)
rg -n "function openTuiExit\\b|export function openTuiExit\\b" packages/hexbus/src/opentui.ts
sed -n '740,870p' packages/hexbus/src/opentui.ts

Repository: inthhq/hexbus

Length of output: 7131


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show handleRunnerError implementation and the exact call into context.error.handleError
rg -n "function handleRunnerError|const handleRunnerError|handleRunnerError\\s*=" packages/hexbus/src/runner.ts
# Print a focused window around the call site and the function definition
sed -n '730,830p' packages/hexbus/src/runner.ts
sed -n '830,880p' packages/hexbus/src/runner.ts

# Also inspect transcript.close usage if present
rg -n "transcript\\?\\.close|transcript\\.close" packages/hexbus/src/runner.ts

Repository: inthhq/hexbus

Length of output: 3761


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Find canUseOpenTui() definition and its conditions
rg -n "function canUseOpenTui\\b|canUseOpenTui\\s*\\(" packages/hexbus/src/opentui.ts
# Show around the definition
# (If rg reports multiple hits, grab the closest window around each definition)
rg -n "canUseOpenTui\\b" packages/hexbus/src/opentui.ts -n -C3

Repository: inthhq/hexbus

Length of output: 2204


Fix transcript closure on non-TTY error exits (process.exit bypasses finally)

handleRunnerError(state) calls state.context.error.handleError(...) synchronously (packages/hexbus/src/runner.ts), and handleError(...) (packages/hexbus/src/errors.ts) routes to openTuiExit(messages, 1). In packages/hexbus/src/opentui.ts, openTuiExit() calls process.exit(exitCode) synchronously when canUseOpenTui() is false (process.stdin.isTTY && process.stdout.isTTY). In that non-TTY case, the finally { await transcript?.close(...) } block in runner.ts (lines 848-852) will not run, so the JSONL end record/flush can be lost for error runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/hexbus/src/runner.ts` around lines 848 - 852, handleRunnerError ->
state.context.error.handleError -> handleError -> openTuiExit currently calls
process.exit synchronously on non-TTY, which bypasses the finally block that
calls transcript.close in runner.ts; to fix, change openTuiExit (and its callers
like handleError) to not call process.exit directly but instead surface the
intended exit code by throwing a dedicated ExitError (or returning the code) so
the caller (handleRunnerError/runner.ts) can let the exception propagate and run
the finally block where transcript?.close(...) executes; ensure the thrown
ExitError carries the exit code and update handleError/openTuiExit docs/comments
to reflect the new behavior.

}
Loading
Loading