Files
shannon/apps/cli/src/commands/logs.ts
T
ezl-keygraph d41ae9c20d feat(cli): overhaul commands and add live scan status (#424)
* refactor(cli): list workspaces natively instead of via the worker image

* feat(cli): preflight that Docker is installed and running

* feat(cli): stop scans by workspace or --all, terminating their Temporal workflows

* fix(worker): abort the running agent on cancellation so Temporal cancel takes effect

* refactor(cli): split destructive teardown out of stop into a reset command

* refactor(cli): centralise flag parsing and confirmation across commands

* fix(cli): pass provider credentials to docker by name to keep secrets out of argv

* feat(cli): add per-command help via <command> --help/-h and help <command>

* feat(cli): replace raw docker output with clack spinners for infra and scan teardown

* fix(cli): verify scan stop by re-querying container and workflow state instead of assuming success

* fix(cli): resolve running state before prompting on stop and report no-op stops honestly

* refactor(cli): show splash first and drive start with one spinner resolving to a clean line

* fix(cli): validate --url up front so a bad value fails cleanly instead of a late crash

* refactor(cli): centralize error reporting with fail() for expected errors and a crash handler that logs the stack and links the issue tracker

* feat(cli): add --json/--plain machine-readable output to workspaces and status

* refactor(cli): remove the workspaces command

* refactor(cli): remove the status command

* feat(cli): add 'progress <workspace>' — live scan progress from Temporal

* fix(cli): mark metric-less agents as skipped in progress, not done

* feat(cli): animate running agents in progress with a clack-style spinner

* feat(cli): rename progress->status, reveal agents as they run, show live per-agent elapsed

* fix(cli): mark passed-over phases as skipped live, not pending

* style(cli): rename status footer 'Wall-clock' to 'Time Taken', drop the parenthetical

* style(cli): drop '(sum of agents)' from status total cost line

* style(cli): green filled circle for completed, Shannon gold for running

* style(cli): use Shannon gold in place of green in status

* feat(cli): suggest closest command or flag on typo

* refactor(cli): single-source start help and drop ./repos bare-name shortcut

* feat(cli): name providers and fix in multi-provider credential error

* feat(cli): support --flag=value syntax and expand leading ~ in paths

* refactor(cli): centralize ANSI color codes in colors.ts

* feat(cli): add scans command listing completed scans with cost and duration

* fix(cli): keep stdout clean off-TTY for logs and start

* feat(cli): add repo link to top-level help

* feat(worker): record auth-validation metrics and register resume attempts early

* refactor(cli): share resume-aware workflow-id resolution and surface root-cause failures

* feat(cli): add status --json, auth phase, dashboard link, and stable live redraw

* refactor(cli): drop cost from status and scans output

* feat(worker): surface both PDF and markdown report at run root

* refactor(cli): normalize error/warning prefixing through fail and warn

* feat(cli): add version --json for machine-readable output

* refactor(cli): rename start --debug to --keep-container

* refactor(cli): point start's progress hint at status instead of the Temporal dashboard

* refactor(cli): centralize the mode-aware command prefix

* refactor(cli): trim start and logs output to durable facts off-TTY

* feat(cli): require typed confirmation for reset instead of --yes

reset permanently wipes all Temporal data and volumes — a severe,
irreversible action. Replace its default y/N confirm (bypassable with
--yes) with a typed-word confirmation that has no bypass, so the wipe
can only be triggered by a deliberate interactive answer.

* feat(cli): surface logs and status hints after start on a TTY

* feat(cli): exit 2 on usage errors, distinct from operational failures

* feat(cli): add start --follow to stream logs and exit on scan outcome

* refactor(cli): redesign splash with sunset-gradient wordmark and truecolor

* refactor(cli): remove the uninstall command

* docs: sync CLI docs with removed uninstall/workspaces, new scans and --follow

* docs: fix reset confirmation — typed confirm, not --yes/-y

* style(cli): restructure status footer with divider, aligned Logs/Temporal rows

* feat(cli): show splash in the status command

* fix(worker): validate auth-state shape, not entry count

* docs: correct reset confirmation and add markdown report to run-root docs
2026-08-18 15:46:25 +05:30

122 lines
3.8 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 { fail } from '../errors.js';
import { getWorkspacesDir } from '../home.js';
import { resolveRunFile } from '../paths.js';
import { stdoutIsTerminal } from '../tty.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. */
export 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;
}
fail(
`No scan found named: ${workspaceId}`,
'',
'Possible causes:',
" - The scan hasn't started yet",
' - The workspace name is incorrect',
'',
'Check the dashboard at http://localhost:8233 for scan details',
);
}
/**
* Tail a scan's log until it reports completion, resolving when the completion marker appears
* (or the file is gone, or Ctrl-C stops it). Never exits the process, so the caller decides what
* happens next: plain `logs` exits 0; `start --follow` reads the workflow outcome first.
*/
export function tailUntilComplete(logFile: string): Promise<void> {
return new Promise((resolve) => {
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;
}
}
// 1. Output existing content
if (flush()) {
resolve();
return;
}
// 2. Watch for appended content via chokidar
const watcher = watch(logFile, { persistent: true });
const stop = (): void => {
watcher.close().finally(() => resolve());
// Safety net — resolve anyway if watcher.close() stalls
setTimeout(() => resolve(), 1000).unref();
};
watcher.on('change', () => {
if (flush()) stop();
});
process.on('SIGINT', stop);
});
}
export function logs(workspaceId: string): void {
const logFile = resolveLogFile(workspaceId);
console.error(stdoutIsTerminal() ? `Tailing scan log: ${logFile}` : 'Tailing scan log');
tailUntilComplete(logFile).finally(() => process.exit(0));
}