mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-10-03 23:06:51 +02:00
* feat: surface report at run root and nest run internals under .shannon * feat: use plain-language wording in user-facing terminal messages * feat(cli): guide users to watch scan progress and surface report path on start * docs: sync run-folder layout and CLI wording across docs and comments * feat(cli): add version command reporting package version or git SHA * feat(cli): detect TTY for interactive prompts, color, and progress output * docs: document --yes flag, version command, and tty module * fix(cli): FORCE_COLOR precedence and plain uninstall --yes output * fix(cli): respect empty NO_COLOR * fix(cli): let NO_COLOR take precedence over FORCE_COLOR * docs: mark claude-code-router integration as removed
108 lines
3.3 KiB
TypeScript
108 lines
3.3 KiB
TypeScript
/**
|
|
* `shannon logs` command — tail a scan's live log.
|
|
*
|
|
* Uses chokidar for reliable cross-platform file watching and
|
|
* bounded synchronous reads to prevent duplicate output.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { watch } from 'chokidar';
|
|
import { getWorkspacesDir } from '../home.js';
|
|
import { resolveRunFile } from '../paths.js';
|
|
|
|
// Match the exact line the worker writes — anchored to prevent false positives from agent output
|
|
const COMPLETION_PATTERN = /^Scan (COMPLETED|FAILED)$/m;
|
|
|
|
/** Read a byte range from a file and return it as a UTF-8 string. */
|
|
function readRange(filePath: string, start: number, end: number): string {
|
|
const length = end - start;
|
|
const buffer = Buffer.alloc(length);
|
|
const fd = fs.openSync(filePath, 'r');
|
|
try {
|
|
fs.readSync(fd, buffer, 0, length, start);
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
return buffer.toString('utf-8');
|
|
}
|
|
|
|
/** Resolve a workspace ID to its workflow.log path, or exit with an error. */
|
|
function resolveLogFile(workspaceId: string): string {
|
|
const workspacesDir = getWorkspacesDir();
|
|
|
|
// 1. Direct match
|
|
const directPath = resolveRunFile(path.join(workspacesDir, workspaceId), 'workflow.log');
|
|
if (fs.existsSync(directPath)) return directPath;
|
|
|
|
// 2. Resume workflow ID (e.g. workspace_resume_123)
|
|
const resumeBase = workspaceId.replace(/_resume_\d+$/, '');
|
|
if (resumeBase !== workspaceId) {
|
|
const resumePath = resolveRunFile(path.join(workspacesDir, resumeBase), 'workflow.log');
|
|
if (fs.existsSync(resumePath)) return resumePath;
|
|
}
|
|
|
|
// 3. Named workspace ID (e.g. workspace_shannon-123)
|
|
const namedBase = workspaceId.replace(/_shannon-\d+$/, '');
|
|
if (namedBase !== workspaceId) {
|
|
const namedPath = resolveRunFile(path.join(workspacesDir, namedBase), 'workflow.log');
|
|
if (fs.existsSync(namedPath)) return namedPath;
|
|
}
|
|
|
|
console.error(`ERROR: No scan found named: ${workspaceId}`);
|
|
console.error('');
|
|
console.error('Possible causes:');
|
|
console.error(" - The scan hasn't started yet");
|
|
console.error(' - The workspace name is incorrect');
|
|
console.error('');
|
|
console.error('Check the dashboard at http://localhost:8233 for scan details');
|
|
process.exit(1);
|
|
}
|
|
|
|
export function logs(workspaceId: string): void {
|
|
const logFile = resolveLogFile(workspaceId);
|
|
let position = 0;
|
|
|
|
/**
|
|
* Output any new content appended since the last read.
|
|
* Returns true when the workflow completion marker is detected.
|
|
*/
|
|
function flush(): boolean {
|
|
try {
|
|
const { size } = fs.statSync(logFile);
|
|
if (size <= position) return false;
|
|
|
|
const data = readRange(logFile, position, size);
|
|
process.stdout.write(data);
|
|
position = size;
|
|
|
|
return COMPLETION_PATTERN.test(data);
|
|
} catch {
|
|
// File deleted or unreadable — treat as done
|
|
return true;
|
|
}
|
|
}
|
|
|
|
console.log(`Tailing scan log: ${logFile}`);
|
|
|
|
// 1. Output existing content
|
|
if (flush()) {
|
|
process.exit(0);
|
|
}
|
|
|
|
// 2. Watch for appended content via chokidar
|
|
const watcher = watch(logFile, { persistent: true });
|
|
|
|
const shutdown = (): void => {
|
|
watcher.close().finally(() => process.exit(0));
|
|
// Safety net — force exit if watcher.close() stalls
|
|
setTimeout(() => process.exit(0), 1000).unref();
|
|
};
|
|
|
|
watcher.on('change', () => {
|
|
if (flush()) shutdown();
|
|
});
|
|
|
|
process.on('SIGINT', shutdown);
|
|
}
|