mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-08-29 14:30:45 +02:00
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
This commit is contained in:
+188
-16
@@ -1,24 +1,196 @@
|
||||
/**
|
||||
* `shannon status` command — show running scans and Temporal health.
|
||||
* `shannon status <workspace>` — one scan's live progress from Temporal.
|
||||
*
|
||||
* While the scan runs, polls Temporal and redraws the phase/agent tree on a
|
||||
* terminal (a pipe or a finished scan gets a single frame). When the scan reaches
|
||||
* a terminal state, prints the overall result and exits. Reads Temporal directly —
|
||||
* no worker, no session files — so it needs Temporal up and shows scans within its
|
||||
* ~24h retention window.
|
||||
*/
|
||||
|
||||
import { isTemporalReady, listRunningWorkers } from '../docker.js';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { fail } from '../errors.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
import { type RenderInput, renderScan } from '../scan/render.js';
|
||||
import { toStatusJson } from '../scan/status-json.js';
|
||||
import { resolveWorkflowId } from '../session.js';
|
||||
import { displaySplash } from '../splash.js';
|
||||
import { describeScan, getTerminalOutcome, queryProgress, type ScanDescription } from '../temporal-client.js';
|
||||
import { stdoutIsTerminal, supportsColor } from '../tty.js';
|
||||
import { getVersion } from '../version.js';
|
||||
|
||||
export function status(): void {
|
||||
// 1. Temporal health
|
||||
const temporalUp = isTemporalReady();
|
||||
console.log(`Temporal: ${temporalUp ? 'running' : 'not running'}`);
|
||||
if (temporalUp) {
|
||||
console.log(' Dashboard: http://localhost:8233');
|
||||
const HIDE_CURSOR = '\x1b[?25l';
|
||||
const SHOW_CURSOR = '\x1b[?25h';
|
||||
/** Redraw cadence for the spinner animation; data is refreshed on the slower poll. */
|
||||
const RENDER_MS = 120;
|
||||
const POLL_MS = 1200;
|
||||
|
||||
/** Terminal = anything other than an open, running execution. */
|
||||
function isTerminalStatus(status: string): boolean {
|
||||
return status !== 'RUNNING' && status !== 'UNSPECIFIED';
|
||||
}
|
||||
|
||||
// Match SGR color escapes (ESC[…m) so a line's on-screen width excludes them. Built from the ESC
|
||||
// char code so the source carries no literal control character.
|
||||
const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
|
||||
|
||||
/**
|
||||
* Physical terminal rows a frame occupies, so the live redraw moves the cursor up by the right
|
||||
* amount. A line wider than the terminal wraps onto extra rows, so counting logical lines alone
|
||||
* undercounts and the redraw drifts downward. Color escapes don't take screen columns, so strip them.
|
||||
*/
|
||||
function physicalRows(frame: string): number {
|
||||
const columns = process.stdout.columns || 80;
|
||||
return frame.split('\n').reduce((rows, line) => {
|
||||
const width = line.replace(ANSI_PATTERN, '').length;
|
||||
return rows + Math.max(1, Math.ceil(width / columns));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function exitCodeFor(input: RenderInput): number {
|
||||
if (input.temporalStatus === 'FAILED' || input.temporalStatus === 'TIMED_OUT') return 1;
|
||||
if (input.state?.status === 'failed') return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Live view of a running scan: its progress query plus the in-flight agents from describe. */
|
||||
async function buildRunningInput(workspace: string, workflowId: string, desc: ScanDescription): Promise<RenderInput> {
|
||||
const state = await queryProgress(workflowId);
|
||||
return {
|
||||
workspace,
|
||||
workflowId,
|
||||
temporalStatus: desc.status,
|
||||
state,
|
||||
running: desc.runningAgents,
|
||||
...(desc.startedAt !== undefined && { startedAt: desc.startedAt }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Final view of a closed scan: its result (or the failure) plus timing from describe. */
|
||||
async function buildTerminalInput(workspace: string, workflowId: string, desc: ScanDescription): Promise<RenderInput> {
|
||||
const outcome = await getTerminalOutcome(workflowId);
|
||||
const timing = {
|
||||
...(desc.startedAt !== undefined && { startedAt: desc.startedAt }),
|
||||
...(desc.closedAt !== undefined && { endedAt: desc.closedAt }),
|
||||
};
|
||||
if (outcome.kind === 'success') {
|
||||
return { workspace, workflowId, temporalStatus: desc.status, state: outcome.state, running: [], ...timing };
|
||||
}
|
||||
console.log('');
|
||||
return {
|
||||
workspace,
|
||||
workflowId,
|
||||
temporalStatus: desc.status,
|
||||
state: null,
|
||||
running: [],
|
||||
failureMessage: outcome.message,
|
||||
...timing,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Running scans
|
||||
const workers = listRunningWorkers();
|
||||
if (workers) {
|
||||
console.log('Running scans:');
|
||||
console.log(workers);
|
||||
} else {
|
||||
console.log('No scans running');
|
||||
function printFrame(input: RenderInput): void {
|
||||
const frame = renderScan(input, {
|
||||
now: Date.now(),
|
||||
color: supportsColor(),
|
||||
unicode: stdoutIsTerminal(),
|
||||
live: false,
|
||||
frame: 0,
|
||||
});
|
||||
process.stdout.write(`${frame}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll Temporal and redraw until the scan reaches a terminal state, then print the
|
||||
* final frame and exit. A fast ticker animates the running spinner off the cached
|
||||
* snapshot; the network poll refreshes that snapshot on a slower cadence.
|
||||
*/
|
||||
async function watch(workspace: string, workflowId: string): Promise<never> {
|
||||
let prevRows = 0;
|
||||
let frame = 0;
|
||||
let cached: RenderInput | null = null;
|
||||
|
||||
const draw = (input: RenderInput, live: boolean): void => {
|
||||
const out = renderScan(input, { now: Date.now(), color: supportsColor(), unicode: true, live, frame });
|
||||
if (prevRows > 0) process.stdout.write(`\x1b[${prevRows}A\x1b[0J`);
|
||||
process.stdout.write(`${out}\n`);
|
||||
prevRows = physicalRows(out);
|
||||
};
|
||||
|
||||
process.on('exit', () => process.stdout.write(SHOW_CURSOR));
|
||||
process.on('SIGINT', () => {
|
||||
process.stdout.write('\n');
|
||||
process.exit(0);
|
||||
});
|
||||
process.stdout.write(HIDE_CURSOR);
|
||||
|
||||
const ticker = setInterval(() => {
|
||||
frame++;
|
||||
if (cached) draw(cached, true);
|
||||
}, RENDER_MS);
|
||||
|
||||
for (;;) {
|
||||
const desc = await describeScan(workflowId);
|
||||
if (!desc) {
|
||||
clearInterval(ticker);
|
||||
fail(`Scan "${workspace}" is no longer in Temporal.`);
|
||||
}
|
||||
|
||||
if (isTerminalStatus(desc.status)) {
|
||||
clearInterval(ticker);
|
||||
const input = await buildTerminalInput(workspace, workflowId, desc);
|
||||
draw(input, false);
|
||||
process.exit(exitCodeFor(input));
|
||||
}
|
||||
|
||||
cached = await buildRunningInput(workspace, workflowId, desc);
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one point-in-time snapshot from Temporal: the terminal result if closed, else live progress. */
|
||||
async function snapshot(workspace: string, workflowId: string, desc: ScanDescription): Promise<RenderInput> {
|
||||
return isTerminalStatus(desc.status)
|
||||
? buildTerminalInput(workspace, workflowId, desc)
|
||||
: buildRunningInput(workspace, workflowId, desc);
|
||||
}
|
||||
|
||||
export async function status(workspace: string, opts: { readonly json: boolean }): Promise<void> {
|
||||
// A resume spawns a new workflow id (recorded in session.json); resolve through there so status
|
||||
// follows the current resume, not the superseded original. Fresh scans: the name is the id.
|
||||
const workflowId = resolveWorkflowId(workspace) ?? workspace;
|
||||
|
||||
let desc: ScanDescription | null;
|
||||
try {
|
||||
desc = await describeScan(workflowId);
|
||||
} catch {
|
||||
fail('Could not reach Temporal at 127.0.0.1:7233.', 'Start Temporal (it comes up with a scan) and try again.');
|
||||
}
|
||||
|
||||
if (!desc) {
|
||||
fail(
|
||||
`No scan found for "${workspace}".`,
|
||||
'',
|
||||
'Scans are visible while running and for ~24h after they finish (Temporal retention).',
|
||||
);
|
||||
}
|
||||
|
||||
// --json is always a single snapshot then exit, even on a TTY — it never enters the live watch loop.
|
||||
if (opts.json) {
|
||||
const input = await snapshot(workspace, workflowId, desc);
|
||||
process.stdout.write(`${JSON.stringify(toStatusJson(input, Date.now()), null, 2)}\n`);
|
||||
process.exit(exitCodeFor(input));
|
||||
}
|
||||
|
||||
// Human-facing views open with the splash; skip it off a real terminal so piped output stays clean.
|
||||
if (stdoutIsTerminal()) {
|
||||
displaySplash(isLocal() ? undefined : getVersion());
|
||||
}
|
||||
|
||||
// A finished scan, or output that isn't a live terminal, gets a single frame.
|
||||
if (isTerminalStatus(desc.status) || !stdoutIsTerminal()) {
|
||||
const input = await snapshot(workspace, workflowId, desc);
|
||||
printFrame(input);
|
||||
process.exit(exitCodeFor(input));
|
||||
}
|
||||
|
||||
await watch(workspace, workflowId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user