feat: add nerve logs command with AI-friendly pagination — closes #29 #34
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user