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:
ezl-keygraph
2026-08-18 15:46:25 +05:30
committed by GitHub
parent 1ae0a142f8
commit d41ae9c20d
44 changed files with 2605 additions and 801 deletions
+124 -85
View File
@@ -8,14 +8,27 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js';
import { setTimeout as sleep } from 'node:timers/promises';
import * as p from '@clack/prompts';
import { ensureDocker, ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js';
import { buildEnvFlags, loadEnv, resolveHostPiAuthPath, shouldUsePiAuth, validateCredentials } from '../env.js';
import { fail } from '../errors.js';
import { getWorkspacesDir, initHome } from '../home.js';
import { isLocal } from '../mode.js';
import { commandPrefix, isLocal } from '../mode.js';
import { resolveModelSpec } from '../model-spec.js';
import { FINAL_REPORT_PDF_FILENAME, INTERNAL_DIR, resolveConfig, resolveRepo, resolveRunFile } from '../paths.js';
import {
expandHome,
FINAL_REPORT_PDF_FILENAME,
INTERNAL_DIR,
resolveConfig,
resolveRepo,
resolveRunFile,
} from '../paths.js';
import { resolveWorkflowId } from '../session.js';
import { displaySplash } from '../splash.js';
import { getTerminalOutcome } from '../temporal-client.js';
import { stdoutIsTerminal } from '../tty.js';
import { tailUntilComplete } from './logs.js';
export interface StartArgs {
url: string;
@@ -24,7 +37,8 @@ export interface StartArgs {
workspace?: string;
output?: string;
pipelineTesting: boolean;
debug: boolean;
keepContainer: boolean;
follow: boolean;
version: string;
}
@@ -59,22 +73,29 @@ export async function start(args: StartArgs): Promise<void> {
// 2. Validate credentials
const creds = validateCredentials();
if (!creds.valid) {
console.error(`ERROR: ${creds.error}`);
process.exit(1);
fail(creds.error ?? 'Invalid credentials');
}
// 3. Resolve paths
const repo = resolveRepo(args.repo);
const config = args.config ? resolveConfig(args.config) : undefined;
// Inputs are valid — show the splash before the Docker/Temporal setup work.
displaySplash(isLocal() ? undefined : args.version);
// 4. Ensure workspaces dir is writable by container user (UID 1001)
const workspacesDir = getWorkspacesDir();
fs.mkdirSync(workspacesDir, { recursive: true });
fs.chmodSync(workspacesDir, 0o777);
// 5. Ensure image (auto-build in dev, pull in npx) and start infra
// 5. Ensure Docker and the worker image are available (pull/build prints its own progress).
ensureDocker();
ensureImage(args.version);
await ensureInfra();
// One spinner spans the whole launch: bringing up Temporal and registering the worker.
const spinner = p.spinner();
spinner.start('Starting scan');
await ensureInfra(spinner);
// 6. Generate unique task queue and container name
const suffix = randomSuffix();
@@ -109,7 +130,7 @@ export async function start(args: StartArgs): Promise<void> {
fs.mkdirSync(path.join(repo.hostPath, '.playwright'), { recursive: true });
// 10. Resolve output directory
const outputDir = args.output ? path.resolve(args.output) : undefined;
const outputDir = args.output ? path.resolve(expandHome(args.output)) : undefined;
if (outputDir) {
fs.mkdirSync(outputDir, { recursive: true });
}
@@ -117,10 +138,7 @@ export async function start(args: StartArgs): Promise<void> {
// 11. Resolve prompts directory (local mode only)
const promptsDir = isLocal() ? path.resolve('apps/worker/prompts') : undefined;
// 12. Display splash screen
displaySplash(isLocal() ? undefined : args.version);
// 13. Spawn worker container
// 12. Spawn worker container
const proc = spawnWorker({
version: args.version,
url: args.url,
@@ -134,20 +152,18 @@ export async function start(args: StartArgs): Promise<void> {
...(outputDir && { outputDir }),
workspace,
...(args.pipelineTesting && { pipelineTesting: true }),
...(args.debug && { debug: true }),
...(args.keepContainer && { keepContainer: true }),
...(shouldUsePiAuth() && { piAuthHostPath: resolveHostPiAuthPath() }),
});
// 14. Bail if `docker run -d` itself fails (mount error, image missing, etc.)
// Bail if `docker run -d` itself fails (mount error, image missing, etc.)
const dockerExitCode = await new Promise<number>((resolve) => {
proc.once('exit', (code) => resolve(code ?? 1));
proc.once('error', (err) => {
console.error(`Failed to start the scan: ${err.message}`);
resolve(1);
});
proc.once('error', () => resolve(1));
});
if (dockerExitCode !== 0) {
spinner.error('Could not start the scan');
process.exit(1);
}
@@ -164,64 +180,23 @@ export async function start(args: StartArgs): Promise<void> {
}
}
// Poll for workflow to register in session.json. Off-TTY, skip the dots and
// clear-line escape so redirected logs stay clean.
const animate = stdoutIsTerminal();
process.stdout.write('Waiting for the scan to start...');
let workflowId = '';
let started = false;
let attempts = 0;
const pollInterval = setInterval(() => {
attempts++;
if (attempts > 60) {
clearInterval(pollInterval);
process.stdout.write('\n');
console.error('Timed out waiting for the scan to start');
process.exit(1);
}
try {
const session = JSON.parse(fs.readFileSync(sessionJson, 'utf-8'));
const resumeAttempts: { workflowId: string }[] = session.session?.resumeAttempts ?? [];
// Fresh: session.json appears with originalWorkflowId. Resume: new resumeAttempts entry.
const ready = isResume ? resumeAttempts.length > initialResumeCount : !!session.session?.originalWorkflowId;
if (ready) {
clearInterval(pollInterval);
started = true;
// Latest workflow ID: last resume attempt, or originalWorkflowId for fresh scans
workflowId = resumeAttempts.at(-1)?.workflowId ?? session.session?.originalWorkflowId ?? '';
// Clear the waiting line, or just break it off-TTY
process.stdout.write(animate ? '\r\x1b[K' : '\n');
printInfo(args, workspace, workflowId, repo.hostPath, workspacesDir);
return;
}
} catch {
// File doesn't exist yet
}
if (animate) process.stdout.write('.');
}, 2000);
// Stop the worker container only if it hasn't started yet
// Stop the worker only if the scan hasn't registered yet (e.g. Ctrl-C mid-startup).
let cleaned = false;
const cleanup = (): void => {
if (cleaned || started) return;
cleaned = true;
clearInterval(pollInterval);
console.log('\nStopping scan...');
spinner.stop('Stopping scan');
try {
execFileSync('docker', ['stop', containerName], { stdio: 'pipe' });
} catch {
// Container may have already exited
}
if (args.debug) {
printDebugHint(containerName);
if (args.keepContainer) {
printPreservedContainerHint(containerName);
}
};
process.on('SIGINT', () => {
cleanup();
process.exit(0);
@@ -231,9 +206,69 @@ export async function start(args: StartArgs): Promise<void> {
process.exit(0);
});
process.on('exit', cleanup);
// Poll for the workflow to register in session.json; the spinner resolves once it does.
spinner.message('Waiting for the scan to start');
for (let attempts = 0; attempts < 60; attempts++) {
try {
const session = JSON.parse(fs.readFileSync(sessionJson, 'utf-8'));
const resumeAttempts: { workflowId: string }[] = session.session?.resumeAttempts ?? [];
// Fresh: session.json appears with originalWorkflowId. Resume: new resumeAttempts entry.
const ready = isResume ? resumeAttempts.length > initialResumeCount : !!session.session?.originalWorkflowId;
if (ready) {
started = true;
spinner.stop(`Scan started — ${workspace}`);
printInfo(args, workspace, repo.hostPath, workspacesDir);
if (args.follow) {
await followScan(workspace, workspacesDir);
}
return;
}
} catch {
// File doesn't exist yet
}
await sleep(2000);
}
spinner.error('Timed out waiting for the scan to start');
process.exit(1);
}
function printDebugHint(containerName: string): void {
/**
* Follow a just-started scan (for `--follow`, aimed at CI): stream its log to completion, then
* exit on the workflow outcome — 0 if the assessment ran, 1 if the scan failed. That tracks
* whether the pipeline ran, not whether vulnerabilities were found.
*/
async function followScan(workspace: string, workspacesDir: string): Promise<never> {
const logFile = resolveRunFile(path.join(workspacesDir, workspace), 'workflow.log');
// The worker creates workflow.log as it starts; wait briefly so the first read doesn't
// mistake a not-yet-created file for an already-finished scan.
for (let attempts = 0; attempts < 30 && !fs.existsSync(logFile); attempts++) {
await sleep(1000);
}
if (stdoutIsTerminal()) {
console.error('\n Following scan log (Ctrl-C to stop watching):\n');
}
await tailUntilComplete(logFile);
const workflowId = resolveWorkflowId(workspace);
if (!workflowId) {
fail('Scan finished but its workflow id could not be resolved from session.json.');
}
try {
const outcome = await getTerminalOutcome(workflowId);
process.exit(outcome.kind === 'success' ? 0 : 1);
} catch {
fail('Could not reach Temporal at 127.0.0.1:7233 to read the scan outcome.');
}
}
function printPreservedContainerHint(containerName: string): void {
console.log('');
console.log(` Worker container preserved: ${containerName}`);
console.log(` Inspect logs: docker logs ${containerName}`);
@@ -241,23 +276,19 @@ function printDebugHint(containerName: string): void {
console.log('');
}
function printInfo(
args: StartArgs,
workspace: string,
workflowId: string,
repoPath: string,
workspacesDir: string,
): void {
const logsCmd = isLocal() ? `./shannon logs ${workspace}` : `npx @keygraph/shannon logs ${workspace}`;
const reportPath = path.join(workspacesDir, workspace, FINAL_REPORT_PDF_FILENAME);
function printInfo(args: StartArgs, workspace: string, repoPath: string, workspacesDir: string): void {
const interactive = stdoutIsTerminal();
if (interactive && !args.follow) {
console.log(' It runs in the background — you can close this terminal.');
console.log('');
}
console.log(' Scan started — it runs in the background, so you can close this terminal.');
console.log('');
console.log(` Target: ${args.url}`);
console.log(` Repository: ${repoPath}`);
console.log(` Repository: ${interactive ? repoPath : path.basename(repoPath)}`);
console.log(` Workspace: ${workspace}`);
if (args.config) {
console.log(` Config: ${path.resolve(args.config)}`);
console.log(` Config: ${interactive ? path.resolve(args.config) : path.basename(args.config)}`);
}
if (args.pipelineTesting) {
console.log(' Mode: Pipeline Testing');
@@ -268,14 +299,22 @@ function printInfo(
console.log(` Model: ${spec.providerId}:${spec.modelId}`);
}
console.log('');
console.log(' Watch scan progress:');
console.log(` Live logs: ${logsCmd}`);
if (workflowId) {
console.log(` Dashboard: http://localhost:8233/namespaces/default/workflows/${workflowId}`);
} else {
console.log(' Dashboard: http://localhost:8233');
if (!interactive) {
return;
}
const reportPath = path.join(workspacesDir, workspace, FINAL_REPORT_PDF_FILENAME);
// When following, the scan log streams inline next, so the "run these to watch it" hints
// would only contradict that.
if (!args.follow) {
const prefix = commandPrefix();
console.log('');
console.log(' Watch scan progress:');
console.log(` Live logs: ${prefix} logs ${workspace}`);
console.log(` Progress: ${prefix} status ${workspace}`);
}
console.log('');
console.log(' Report (when the scan finishes):');
console.log(` ${reportPath}`);