feat: add nerve logs command with AI-friendly pagination — closes #29 #34

Merged
xiaomo merged 2 commits from feat/nerve-logs into main 2026-04-22 15:04:53 +00:00
3 changed files with 449 additions and 0 deletions
+250
View File
@@ -0,0 +1,250 @@
/**
* Tests for nerve logs command — pure helper functions only.
*
* We test sliceLogs and buildLogFooter without touching the filesystem or
* spawning a real process.
*/
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_LOG_LINES, buildLogFooter, readAllLines, sliceLogs } from "../commands/logs.js";
import { logsCommand } from "../commands/logs.js";
// ---------------------------------------------------------------------------
// sliceLogs
// ---------------------------------------------------------------------------
describe("sliceLogs", () => {
const make = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`);
it("returns empty result for empty array", () => {
const r = sliceLogs([], 0, 50);
expect(r.lines).toHaveLength(0);
expect(r.total).toBe(0);
expect(r.nextOffset).toBeNull();
});
it("tail mode (offset=0): returns last N lines", () => {
const lines = make(100);
const r = sliceLogs(lines, 0, 10);
expect(r.lines).toHaveLength(10);
expect(r.lines[0]).toBe("line 91");
expect(r.lines[9]).toBe("line 100");
expect(r.startLine).toBe(91);
expect(r.endLine).toBe(100);
});
it("tail mode: when file shorter than limit, returns all", () => {
const lines = make(20);
const r = sliceLogs(lines, 0, 50);
expect(r.lines).toHaveLength(20);
expect(r.startLine).toBe(1);
expect(r.endLine).toBe(20);
expect(r.nextOffset).toBeNull();
});
it("tail mode: provides nextOffset when earlier lines exist", () => {
const lines = make(200);
const r = sliceLogs(lines, 0, 50);
expect(r.nextOffset).not.toBeNull();
expect(r.nextOffset).toBe(151 - 50); // startLine=151, prev page starts at 101
});
it("tail mode: nextOffset is null when showing from line 1", () => {
const lines = make(40);
const r = sliceLogs(lines, 0, 50);
expect(r.nextOffset).toBeNull();
});
it("offset mode: starts at given 1-based line number", () => {
const lines = make(100);
const r = sliceLogs(lines, 10, 5);
expect(r.lines[0]).toBe("line 10");
expect(r.startLine).toBe(10);
expect(r.endLine).toBe(14);
});
it("offset mode: clamps start to 0 for offset=1", () => {
const lines = make(50);
const r = sliceLogs(lines, 1, 10);
expect(r.startLine).toBe(1);
});
it("offset mode: nextOffset is null when slice starts at line 1", () => {
const lines = make(50);
const r = sliceLogs(lines, 1, 20);
expect(r.nextOffset).toBeNull();
});
it("offset mode: nextOffset points to previous page", () => {
const lines = make(100);
const r = sliceLogs(lines, 51, 50); // lines 51-100
expect(r.nextOffset).toBe(1); // previous page starts at line 1
});
});
// ---------------------------------------------------------------------------
// buildLogFooter
// ---------------------------------------------------------------------------
describe("buildLogFooter", () => {
it("returns empty-file message when total=0", () => {
const slice = { lines: [], total: 0, startLine: 0, endLine: 0, nextOffset: null };
expect(buildLogFooter(slice, 50, "/path/to/nerve.log")).toContain("empty");
});
it("includes range and path in footer", () => {
const slice = { lines: ["x"], total: 200, startLine: 151, endLine: 200, nextOffset: 101 };
const footer = buildLogFooter(slice, 50, "/var/log/nerve.log");
expect(footer).toContain("lines 151-200 of 200");
expect(footer).toContain("/var/log/nerve.log");
});
it("includes pagination hint when nextOffset is set", () => {
const slice = { lines: ["x"], total: 200, startLine: 151, endLine: 200, nextOffset: 101 };
const footer = buildLogFooter(slice, 50, "/path/nerve.log");
expect(footer).toContain("nerve logs --offset 101 -n 50");
});
it("no pagination hint when nextOffset is null", () => {
const slice = { lines: ["x"], total: 20, startLine: 1, endLine: 20, nextOffset: null };
const footer = buildLogFooter(slice, 50, "/path/nerve.log");
expect(footer).not.toContain("nerve logs --offset");
});
});
// ---------------------------------------------------------------------------
// DEFAULT_LOG_LINES constant
// ---------------------------------------------------------------------------
describe("DEFAULT_LOG_LINES", () => {
it("is 50", () => {
expect(DEFAULT_LOG_LINES).toBe(50);
});
});
// ---------------------------------------------------------------------------
// readAllLines
// ---------------------------------------------------------------------------
describe("readAllLines", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "nerve-logs-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("returns empty array for nonexistent file", async () => {
const result = await readAllLines(join(tmpDir, "missing.log"));
expect(result).toHaveLength(0);
});
it("reads all lines from a file", async () => {
const logFile = join(tmpDir, "test.log");
writeFileSync(logFile, "line1\nline2\nline3\n");
const result = await readAllLines(logFile);
expect(result).toEqual(["line1", "line2", "line3"]);
});
it("handles file with no trailing newline", async () => {
const logFile = join(tmpDir, "test.log");
writeFileSync(logFile, "a\nb\nc");
const result = await readAllLines(logFile);
expect(result).toEqual(["a", "b", "c"]);
});
it("returns empty array for empty file", async () => {
const logFile = join(tmpDir, "empty.log");
writeFileSync(logFile, "");
const result = await readAllLines(logFile);
expect(result).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Integration: readAllLines + sliceLogs end-to-end
// ---------------------------------------------------------------------------
describe("readAllLines + sliceLogs integration", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "nerve-logs-int-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("tail-paginates a large log file correctly", async () => {
const logFile = join(tmpDir, "big.log");
const content = Array.from({ length: 120 }, (_, i) => `entry ${i + 1}`).join("\n");
writeFileSync(logFile, content);
const all = await readAllLines(logFile);
const page1 = sliceLogs(all, 0, 50); // last 50: lines 71-120
expect(page1.startLine).toBe(71);
expect(page1.endLine).toBe(120);
expect(page1.nextOffset).toBe(21); // max(1, 71-50)
const page2 = sliceLogs(all, page1.nextOffset!, 50); // lines 21-70
expect(page2.startLine).toBe(21);
expect(page2.endLine).toBe(70);
expect(page2.nextOffset).toBe(1); // max(1, 21-50) = 1
const page3 = sliceLogs(all, page2.nextOffset!, 50); // lines 1-50
expect(page3.startLine).toBe(1);
expect(page3.endLine).toBe(50);
expect(page3.nextOffset).toBeNull();
});
});
// ---------------------------------------------------------------------------
// logsCommand: negative offset validation
// ---------------------------------------------------------------------------
describe("logsCommand negative offset", () => {
let stderrOutput: string;
let exitCode: number | undefined;
beforeEach(() => {
stderrOutput = "";
exitCode = undefined;
vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
stderrOutput += typeof chunk === "string" ? chunk : chunk.toString();
return true;
});
vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => {
exitCode = typeof code === "number" ? code : 1;
throw new Error(`process.exit(${exitCode})`);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("exits with code 1 and writes to stderr when offset is negative", async () => {
await expect(
logsCommand.run!({ args: { n: "50", offset: "-5", follow: false }, rawArgs: [], cmd: logsCommand as never }),
).rejects.toThrow("process.exit(1)");
expect(exitCode).toBe(1);
expect(stderrOutput).toContain("--offset must be a non-negative integer");
expect(stderrOutput).toContain("-5");
});
it("exits with code 1 for offset=-1", async () => {
await expect(
logsCommand.run!({ args: { n: "10", offset: "-1", follow: false }, rawArgs: [], cmd: logsCommand as never }),
).rejects.toThrow("process.exit(1)");
expect(exitCode).toBe(1);
});
});
+2
View File
@@ -3,6 +3,7 @@
import { defineCommand, runMain } from "citty";
import { initCommand } from "./commands/init.js";
import { logsCommand } from "./commands/logs.js";
import { startCommand } from "./commands/start.js";
import { statusCommand } from "./commands/status.js";
import { stopCommand } from "./commands/stop.js";
@@ -19,6 +20,7 @@ const main = defineCommand({
start: startCommand,
stop: stopCommand,
status: statusCommand,
logs: logsCommand,
validate: validateCommand,
workflow: workflowCommand,
},
+197
View File
@@ -0,0 +1,197 @@
import { createReadStream, existsSync, statSync } from "node:fs";
import { createInterface } from "node:readline";
import { defineCommand } from "citty";
import { getLogPath } from "../workspace.js";
export const DEFAULT_LOG_LINES = 50;
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
/**
* Read all lines from a file. Returns empty array if file does not exist.
*
* TODO: For tail mode (offset=0), avoid reading the whole file into memory by
* seeking to the last N bytes via createReadStream({ start: max(0, size - CHUNK) }).
*/
export async function readAllLines(filePath: string): Promise<string[]> {
if (!existsSync(filePath)) return [];
const lines: string[] = [];
const rl = createInterface({
input: createReadStream(filePath, { encoding: "utf8" }),
crlfDelay: Number.POSITIVE_INFINITY,
});
for await (const line of rl) {
lines.push(line);
}
return lines;
}
/**
* Slice a log line array respecting offset + limit semantics.
*
* When offset is 0 the function returns the *last* `limit` lines (tail mode).
* When offset > 0 it is treated as a 1-based line number and the slice starts
* there (for pagination of earlier pages from the tail).
*
* Returns the selected lines plus metadata used to build the footer.
*/
export type LogSlice = {
lines: string[];
total: number;
startLine: number; // 1-based, inclusive
endLine: number; // 1-based, inclusive
nextOffset: number | null; // null when no previous page exists
};
export function sliceLogs(allLines: string[], offset: number, limit: number): LogSlice {
const total = allLines.length;
if (total === 0) {
return { lines: [], total: 0, startLine: 0, endLine: 0, nextOffset: null };
}
let start: number;
if (offset === 0) {
// Tail mode: last `limit` lines
start = Math.max(0, total - limit);
} else {
// offset is 1-based line number
start = Math.max(0, offset - 1);
}
const end = Math.min(start + limit, total);
const lines = allLines.slice(start, end);
const startLine = start + 1;
const endLine = end;
// nextOffset points to lines *before* current slice (earlier in file)
const nextOffset = start > 0 ? Math.max(1, startLine - limit) : null;
return { lines, total, startLine, endLine, nextOffset };
}
/**
* Build the footer string shown after the log lines.
*/
export function buildLogFooter(slice: LogSlice, nArg: number, logPath: string): string {
if (slice.total === 0) {
return "📭 Log file is empty.\n";
}
const rangeStr = `lines ${slice.startLine}-${slice.endLine} of ${slice.total}`;
let footer = `\n📄 ${rangeStr} | ${logPath}\n`;
if (slice.nextOffset !== null) {
footer += `⏩ Earlier lines available. Fetch previous page:\n`;
footer += ` nerve logs --offset ${slice.nextOffset} -n ${nArg}\n`;
}
return footer;
}
// ---------------------------------------------------------------------------
// nerve logs
// ---------------------------------------------------------------------------
export const logsCommand = defineCommand({
meta: {
name: "logs",
description: "Show daemon log output",
},
args: {
n: {
type: "string",
description: `Number of lines to show (default: ${DEFAULT_LOG_LINES})`,
default: String(DEFAULT_LOG_LINES),
},
offset: {
type: "string",
description: "Start from line N (1-based, for pagination)",
default: "0",
},
follow: {
type: "boolean",
alias: "f",
description: "Stream new log lines in real time",
default: false,
},
},
async run({ args }) {
const logPath = getLogPath();
const nLines = Math.max(1, Number.parseInt(args.n, 10) || DEFAULT_LOG_LINES);
const rawOffset = Number.parseInt(args.offset, 10) || 0;
if (rawOffset < 0) {
process.stderr.write(`❌ --offset must be a non-negative integer, got: ${args.offset}\n`);
process.exit(1);
}
const offset = rawOffset;
if (!existsSync(logPath)) {
process.stderr.write(`❌ Log file not found: ${logPath}\n`);
process.stderr.write(" Has the daemon been started? Try: nerve start\n");
process.exit(1);
}
if (args.follow) {
await followLog(logPath, nLines);
return;
}
const allLines = await readAllLines(logPath);
const slice = sliceLogs(allLines, offset, nLines);
for (const line of slice.lines) {
process.stdout.write(`${line}\n`);
}
process.stdout.write(buildLogFooter(slice, nLines, logPath));
},
});
/**
* Stream new lines from a log file as they are appended.
* Shows the last `tailLines` lines first, then watches for new content.
*/
async function followLog(logPath: string, tailLines: number): Promise<void> {
const allLines = await readAllLines(logPath);
const initial = allLines.slice(Math.max(0, allLines.length - tailLines));
for (const line of initial) {
process.stdout.write(`${line}\n`);
}
let size = statSync(logPath).size;
process.stdout.write(`\n👁 Following ${logPath} — press Ctrl+C to stop\n`);
let stopped = false;
process.once("SIGINT", () => {
stopped = true;
});
while (!stopped) {
await sleep(300);
if (stopped) break;
try {
const newSize = statSync(logPath).size;
if (newSize < size) {
// Log rotation: file was truncated or replaced, read from the beginning
size = 0;
}
if (newSize <= size) continue;
const stream = createReadStream(logPath, { start: size, encoding: "utf8" });
const rl = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
for await (const line of rl) {
process.stdout.write(`${line}\n`);
}
size = newSize;
} catch {
stopped = true;
}
}
}