diff --git a/packages/cli/src/__tests__/logs.test.ts b/packages/cli/src/__tests__/logs.test.ts new file mode 100644 index 0000000..470de86 --- /dev/null +++ b/packages/cli/src/__tests__/logs.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2c52d88..21e18b7 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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, }, diff --git a/packages/cli/src/commands/logs.ts b/packages/cli/src/commands/logs.ts new file mode 100644 index 0000000..b709b95 --- /dev/null +++ b/packages/cli/src/commands/logs.ts @@ -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((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 { + 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 { + 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; + } + } +}