mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-07-05 12:47:57 +02:00
feat(cli): restructure run folder and improve terminal UX (#384)
* 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
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* `shannon logs` command — tail a workspace's workflow log.
|
||||
* `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.
|
||||
@@ -9,9 +9,10 @@ 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 = /^Workflow (COMPLETED|FAILED)$/m;
|
||||
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 {
|
||||
@@ -31,30 +32,30 @@ function resolveLogFile(workspaceId: string): string {
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
|
||||
// 1. Direct match
|
||||
const directPath = path.join(workspacesDir, workspaceId, 'workflow.log');
|
||||
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 = path.join(workspacesDir, resumeBase, 'workflow.log');
|
||||
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 = path.join(workspacesDir, namedBase, 'workflow.log');
|
||||
const namedPath = resolveRunFile(path.join(workspacesDir, namedBase), 'workflow.log');
|
||||
if (fs.existsSync(namedPath)) return namedPath;
|
||||
}
|
||||
|
||||
console.error(`ERROR: Workflow log not found for: ${workspaceId}`);
|
||||
console.error(`ERROR: No scan found named: ${workspaceId}`);
|
||||
console.error('');
|
||||
console.error('Possible causes:');
|
||||
console.error(" - Workflow hasn't started yet");
|
||||
console.error(' - Workspace ID is incorrect');
|
||||
console.error(" - The scan hasn't started yet");
|
||||
console.error(' - The workspace name is incorrect');
|
||||
console.error('');
|
||||
console.error('Check the Temporal Web UI at http://localhost:8233 for workflow details');
|
||||
console.error('Check the dashboard at http://localhost:8233 for scan details');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -82,7 +83,7 @@ export function logs(workspaceId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Tailing workflow log: ${logFile}`);
|
||||
console.log(`Tailing scan log: ${logFile}`);
|
||||
|
||||
// 1. Output existing content
|
||||
if (flush()) {
|
||||
|
||||
@@ -10,12 +10,14 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as p from '@clack/prompts';
|
||||
import { type ShannonConfig, saveConfig } from '../config/writer.js';
|
||||
import { requireInteractive } from '../tty.js';
|
||||
|
||||
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
|
||||
|
||||
type Provider = 'anthropic' | 'custom_base_url' | 'bedrock' | 'vertex';
|
||||
|
||||
export async function setup(): Promise<void> {
|
||||
requireInteractive('setup', 'For non-interactive use, export credentials as env vars (e.g. ANTHROPIC_API_KEY).');
|
||||
p.intro('Shannon Setup');
|
||||
|
||||
// 1. Select provider
|
||||
|
||||
@@ -12,8 +12,9 @@ import { ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.j
|
||||
import { buildEnvFlags, loadEnv, validateCredentials } from '../env.js';
|
||||
import { getCredentialsPath, getWorkspacesDir, initHome } from '../home.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
import { resolveConfig, resolveRepo } from '../paths.js';
|
||||
import { FINAL_REPORT_FILENAME, INTERNAL_DIR, resolveConfig, resolveRepo, resolveRunFile } from '../paths.js';
|
||||
import { displaySplash } from '../splash.js';
|
||||
import { stdoutIsTerminal } from '../tty.js';
|
||||
|
||||
export interface StartArgs {
|
||||
url: string;
|
||||
@@ -26,6 +27,29 @@ export interface StartArgs {
|
||||
version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade a pre-restructure workspace (flat layout, no INTERNAL_DIR) before it is mounted,
|
||||
* so resume finds the old deliverables and their git checkpoints instead of re-running every
|
||||
* agent. For a legacy run every top-level entry is internal, so move them all into INTERNAL_DIR
|
||||
* (a same-filesystem rename carries the deliverables .git along).
|
||||
*/
|
||||
function migrateLegacyWorkspaceLayout(workspacePath: string): void {
|
||||
const legacySessionJson = path.join(workspacePath, 'session.json');
|
||||
const internalPath = path.join(workspacePath, INTERNAL_DIR);
|
||||
if (!fs.existsSync(legacySessionJson) || fs.existsSync(internalPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(internalPath, { recursive: true });
|
||||
for (const entry of fs.readdirSync(workspacePath)) {
|
||||
if (entry === INTERNAL_DIR) {
|
||||
continue;
|
||||
}
|
||||
fs.renameSync(path.join(workspacePath, entry), path.join(internalPath, entry));
|
||||
}
|
||||
console.log(`Migrated workspace to ${INTERNAL_DIR}/ layout: ${workspacePath}`);
|
||||
}
|
||||
|
||||
export async function start(args: StartArgs): Promise<void> {
|
||||
// 1. Initialize state directories and load env
|
||||
initHome();
|
||||
@@ -61,12 +85,17 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
|
||||
|
||||
// 8. Create writable overlay directories (mounted over :ro repo paths inside container)
|
||||
// Workspace dir must be 0o777 so the container user (UID 1001) can create audit subdirs
|
||||
// The run dir and its INTERNAL_DIR must be 0o777 so the container user can create audit
|
||||
// subdirs and the overlay backing dirs.
|
||||
const workspacePath = path.join(workspacesDir, workspace);
|
||||
const internalPath = path.join(workspacePath, INTERNAL_DIR);
|
||||
fs.mkdirSync(workspacePath, { recursive: true });
|
||||
fs.chmodSync(workspacePath, 0o777);
|
||||
migrateLegacyWorkspaceLayout(workspacePath);
|
||||
fs.mkdirSync(internalPath, { recursive: true });
|
||||
fs.chmodSync(internalPath, 0o777);
|
||||
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli', '.playwright']) {
|
||||
const dirPath = path.join(workspacePath, dir);
|
||||
const dirPath = path.join(internalPath, dir);
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.chmodSync(dirPath, 0o777);
|
||||
}
|
||||
@@ -119,7 +148,7 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
const dockerExitCode = await new Promise<number>((resolve) => {
|
||||
proc.once('exit', (code) => resolve(code ?? 1));
|
||||
proc.once('error', (err) => {
|
||||
console.error(`Failed to start worker: ${err.message}`);
|
||||
console.error(`Failed to start the scan: ${err.message}`);
|
||||
resolve(1);
|
||||
});
|
||||
});
|
||||
@@ -129,7 +158,7 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
}
|
||||
|
||||
// Detect whether this is a fresh workspace or a resume by checking session.json existence
|
||||
const sessionJson = path.join(workspacesDir, workspace, 'session.json');
|
||||
const sessionJson = resolveRunFile(path.join(workspacesDir, workspace), 'session.json');
|
||||
const isResume = fs.existsSync(sessionJson);
|
||||
let initialResumeCount = 0;
|
||||
if (isResume) {
|
||||
@@ -141,8 +170,10 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Poll for workflow to register in session.json
|
||||
process.stdout.write('Waiting for workflow to start...');
|
||||
// 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;
|
||||
@@ -151,7 +182,7 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
if (attempts > 60) {
|
||||
clearInterval(pollInterval);
|
||||
process.stdout.write('\n');
|
||||
console.error('Timeout waiting for workflow to start');
|
||||
console.error('Timed out waiting for the scan to start');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -169,15 +200,15 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
// Latest workflow ID: last resume attempt, or originalWorkflowId for fresh scans
|
||||
workflowId = resumeAttempts.at(-1)?.workflowId ?? session.session?.originalWorkflowId ?? '';
|
||||
|
||||
// Clear waiting line and show info
|
||||
process.stdout.write('\r\x1b[K');
|
||||
// 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
|
||||
}
|
||||
process.stdout.write('.');
|
||||
if (animate) process.stdout.write('.');
|
||||
}, 2000);
|
||||
|
||||
// Stop the worker container only if it hasn't started yet
|
||||
@@ -186,7 +217,7 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
if (cleaned || started) return;
|
||||
cleaned = true;
|
||||
clearInterval(pollInterval);
|
||||
console.log(`\nStopping worker ${containerName}...`);
|
||||
console.log('\nStopping scan...');
|
||||
try {
|
||||
execFileSync('docker', ['stop', containerName], { stdio: 'pipe' });
|
||||
} catch {
|
||||
@@ -224,8 +255,10 @@ function printInfo(
|
||||
workspacesDir: string,
|
||||
): void {
|
||||
const logsCmd = isLocal() ? `./shannon logs ${workspace}` : `npx @keygraph/shannon logs ${workspace}`;
|
||||
const reportsPath = path.join(workspacesDir, workspace);
|
||||
const reportPath = path.join(workspacesDir, workspace, FINAL_REPORT_FILENAME);
|
||||
|
||||
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(` Workspace: ${workspace}`);
|
||||
@@ -252,15 +285,15 @@ function printInfo(
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(' Monitor:');
|
||||
console.log(' Watch scan progress:');
|
||||
console.log(` Live logs: ${logsCmd}`);
|
||||
if (workflowId) {
|
||||
console.log(` Web UI: http://localhost:8233/namespaces/default/workflows/${workflowId}`);
|
||||
console.log(` Dashboard: http://localhost:8233/namespaces/default/workflows/${workflowId}`);
|
||||
} else {
|
||||
console.log(' Web UI: http://localhost:8233');
|
||||
console.log(' Dashboard: http://localhost:8233');
|
||||
}
|
||||
console.log(` Logs: ${logsCmd}`);
|
||||
console.log('');
|
||||
console.log(' Output:');
|
||||
console.log(` Reports: ${reportsPath}/`);
|
||||
console.log(' Report (when the scan finishes):');
|
||||
console.log(` ${reportPath}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* `shannon status` command — show running workers and Temporal health.
|
||||
* `shannon status` command — show running scans and Temporal health.
|
||||
*/
|
||||
|
||||
import { isTemporalReady, listRunningWorkers } from '../docker.js';
|
||||
@@ -9,16 +9,16 @@ export function status(): void {
|
||||
const temporalUp = isTemporalReady();
|
||||
console.log(`Temporal: ${temporalUp ? 'running' : 'not running'}`);
|
||||
if (temporalUp) {
|
||||
console.log(' Web UI: http://localhost:8233');
|
||||
console.log(' Dashboard: http://localhost:8233');
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// 2. Running workers
|
||||
// 2. Running scans
|
||||
const workers = listRunningWorkers();
|
||||
if (workers) {
|
||||
console.log('Workers:');
|
||||
console.log('Running scans:');
|
||||
console.log(workers);
|
||||
} else {
|
||||
console.log('Workers: none running');
|
||||
console.log('No scans running');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import { stopInfra, stopWorkers } from '../docker.js';
|
||||
import { requireInteractive } from '../tty.js';
|
||||
|
||||
export async function stop(clean: boolean): Promise<void> {
|
||||
if (clean) {
|
||||
export async function stop(clean: boolean, yes: boolean): Promise<void> {
|
||||
if (clean && !yes) {
|
||||
requireInteractive('stop --clean', 'Re-run with --yes to skip this confirmation.');
|
||||
const confirmed = await p.confirm({
|
||||
message: 'This will stop all running scans and remove the Temporal data. Continue?',
|
||||
});
|
||||
|
||||
@@ -7,24 +7,34 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as p from '@clack/prompts';
|
||||
import { stopInfra, stopWorkers } from '../docker.js';
|
||||
import { requireInteractive } from '../tty.js';
|
||||
|
||||
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
|
||||
|
||||
export async function uninstall(): Promise<void> {
|
||||
p.intro('Shannon Uninstall');
|
||||
export async function uninstall(yes: boolean): Promise<void> {
|
||||
const interactive = !yes;
|
||||
if (interactive) p.intro('Shannon Uninstall');
|
||||
|
||||
if (!fs.existsSync(SHANNON_HOME)) {
|
||||
p.log.info('Nothing to remove. Shannon is not configured on this machine.');
|
||||
p.outro('Done.');
|
||||
const message = 'Nothing to remove. Shannon is not configured on this machine.';
|
||||
if (interactive) {
|
||||
p.log.info(message);
|
||||
p.outro('Done.');
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await p.confirm({
|
||||
message: 'This will permanently remove all past scan data, saved configurations, and API keys. Continue?',
|
||||
});
|
||||
if (p.isCancel(confirmed) || !confirmed) {
|
||||
p.cancel('Aborted.');
|
||||
process.exit(0);
|
||||
if (interactive) {
|
||||
requireInteractive('uninstall', 'Re-run with --yes to skip this confirmation.');
|
||||
const confirmed = await p.confirm({
|
||||
message: 'This will permanently remove all past scan data, saved configurations, and API keys. Continue?',
|
||||
});
|
||||
if (p.isCancel(confirmed) || !confirmed) {
|
||||
p.cancel('Aborted.');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop any running containers first
|
||||
@@ -32,6 +42,14 @@ export async function uninstall(): Promise<void> {
|
||||
stopInfra(false);
|
||||
|
||||
fs.rmSync(SHANNON_HOME, { recursive: true, force: true });
|
||||
p.log.success('All Shannon data has been removed.');
|
||||
p.outro('Shannon has been uninstalled. Run `npx @keygraph/shannon setup` to start fresh.');
|
||||
|
||||
const done = 'All Shannon data has been removed.';
|
||||
const hint = 'Shannon has been uninstalled. Run `npx @keygraph/shannon setup` to start fresh.';
|
||||
if (interactive) {
|
||||
p.log.success(done);
|
||||
p.outro(hint);
|
||||
} else {
|
||||
console.log(done);
|
||||
console.log(hint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,9 @@ function loadTOML(): TOMLConfig | null {
|
||||
const mode = fs.statSync(configPath).mode;
|
||||
if (mode & 0o077) {
|
||||
const actual = (mode & 0o777).toString(8).padStart(3, '0');
|
||||
console.error(`\nInsecure permissions (${actual}) on ${configPath}. Run: chmod 600 ${configPath}\n`);
|
||||
console.error(
|
||||
`\nYour config file is readable by other users on this machine (${actual}). Lock it down: chmod 600 ${configPath}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-8
@@ -13,6 +13,7 @@ import path from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getMode } from './mode.js';
|
||||
import { INTERNAL_DIR } from './paths.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -116,7 +117,7 @@ export function ensureImage(version: string): void {
|
||||
if (exists) return;
|
||||
|
||||
if (getMode() === 'local') {
|
||||
console.log('Worker image not found, building...');
|
||||
console.log('Shannon image not found, building...');
|
||||
buildImage(false);
|
||||
} else {
|
||||
console.log(`Pulling ${image}...`);
|
||||
@@ -270,12 +271,13 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
args.push('-v', `${opts.workspacesDir}:/app/workspaces`);
|
||||
args.push('-v', `${opts.repo.hostPath}:${opts.repo.containerPath}:ro`);
|
||||
|
||||
// Writable overlays: shadow .shannon/ and .playwright/ inside the :ro repo with workspace-backed dirs
|
||||
const workspacePath = path.join(opts.workspacesDir, opts.workspace);
|
||||
args.push('-v', `${path.join(workspacePath, 'deliverables')}:${opts.repo.containerPath}/.shannon/deliverables`);
|
||||
args.push('-v', `${path.join(workspacePath, 'scratchpad')}:${opts.repo.containerPath}/.shannon/scratchpad`);
|
||||
args.push('-v', `${path.join(workspacePath, '.playwright-cli')}:${opts.repo.containerPath}/.shannon/.playwright-cli`);
|
||||
args.push('-v', `${path.join(workspacePath, '.playwright')}:${opts.repo.containerPath}/.playwright`);
|
||||
// Writable overlays: shadow .shannon/ and .playwright/ inside the :ro repo with workspace-backed
|
||||
// dirs, nested under the run's INTERNAL_DIR. Container paths are unchanged.
|
||||
const internalPath = path.join(opts.workspacesDir, opts.workspace, INTERNAL_DIR);
|
||||
args.push('-v', `${path.join(internalPath, 'deliverables')}:${opts.repo.containerPath}/.shannon/deliverables`);
|
||||
args.push('-v', `${path.join(internalPath, 'scratchpad')}:${opts.repo.containerPath}/.shannon/scratchpad`);
|
||||
args.push('-v', `${path.join(internalPath, '.playwright-cli')}:${opts.repo.containerPath}/.shannon/.playwright-cli`);
|
||||
args.push('-v', `${path.join(internalPath, '.playwright')}:${opts.repo.containerPath}/.playwright`);
|
||||
|
||||
// Local mode: mount prompts for live editing
|
||||
if (opts.promptsDir) {
|
||||
@@ -336,7 +338,7 @@ export function stopWorkers(): void {
|
||||
if (!workers) return;
|
||||
|
||||
const ids = workers.split('\n').filter(Boolean);
|
||||
console.log('Stopping worker containers...');
|
||||
console.log('Stopping running scans...');
|
||||
execFileSync('docker', ['stop', ...ids], { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
|
||||
+13
-26
@@ -9,9 +9,6 @@
|
||||
* in the current working directory.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { build } from './commands/build.js';
|
||||
import { logs } from './commands/logs.js';
|
||||
import { setup } from './commands/setup.js';
|
||||
@@ -21,9 +18,7 @@ import { stop } from './commands/stop.js';
|
||||
import { uninstall } from './commands/uninstall.js';
|
||||
import { workspaces } from './commands/workspaces.js';
|
||||
import { getMode } from './mode.js';
|
||||
import { displaySplash } from './splash.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
import { getVersion, getVersionLine } from './version.js';
|
||||
|
||||
function blockSudo(): void {
|
||||
const isSudo = !!process.env.SUDO_USER;
|
||||
@@ -44,16 +39,6 @@ function blockSudo(): void {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function getVersion(): string {
|
||||
try {
|
||||
const pkgPath = path.join(__dirname, '..', 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as { version?: string };
|
||||
return pkg.version || '1.0.0';
|
||||
} catch {
|
||||
return '1.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp(): void {
|
||||
const mode = getMode();
|
||||
const prefix = mode === 'local' ? './shannon' : 'npx @keygraph/shannon';
|
||||
@@ -68,17 +53,17 @@ Usage:${
|
||||
${prefix} setup Configure credentials`
|
||||
}
|
||||
${prefix} start --url <url> --repo <path> [options] Start a pentest scan
|
||||
${prefix} stop [--clean] Stop all containers
|
||||
${prefix} stop [--clean] [--yes] Stop all running scans
|
||||
${prefix} workspaces List all workspaces
|
||||
${prefix} logs <workspace> Tail workflow log
|
||||
${prefix} status Show running workers${
|
||||
${prefix} logs <workspace> Show a scan's live log
|
||||
${prefix} status Show running scans${
|
||||
mode === 'local'
|
||||
? `
|
||||
${prefix} build [--no-cache] Build worker image`
|
||||
: `
|
||||
${prefix} uninstall Remove ~/.shannon/ and all data`
|
||||
${prefix} uninstall [--yes] Remove ~/.shannon/ and all data`
|
||||
}
|
||||
${prefix} info Show splash screen
|
||||
${prefix} version Show version
|
||||
${prefix} help Show this help
|
||||
|
||||
Options for 'start':
|
||||
@@ -102,7 +87,7 @@ State directory: ./workspaces/`
|
||||
: `
|
||||
State directory: ~/.shannon/`
|
||||
}
|
||||
Monitor workflows at http://localhost:8233
|
||||
Monitor scans at http://localhost:8233
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -209,7 +194,7 @@ switch (command) {
|
||||
break;
|
||||
}
|
||||
case 'stop':
|
||||
stop(args.includes('--clean'));
|
||||
stop(args.includes('--clean'), args.includes('--yes') || args.includes('-y'));
|
||||
break;
|
||||
case 'logs': {
|
||||
const workspaceId = args[1];
|
||||
@@ -242,10 +227,12 @@ switch (command) {
|
||||
console.error('ERROR: uninstall is only available in npx mode.');
|
||||
process.exit(1);
|
||||
}
|
||||
uninstall();
|
||||
uninstall(args.includes('--yes') || args.includes('-y'));
|
||||
break;
|
||||
case 'info':
|
||||
displaySplash(getMode() === 'local' ? undefined : getVersion());
|
||||
case 'version':
|
||||
case '--version':
|
||||
case '-v':
|
||||
console.log(getVersionLine());
|
||||
break;
|
||||
case 'help':
|
||||
case '--help':
|
||||
|
||||
@@ -14,6 +14,38 @@ export interface MountPair {
|
||||
containerPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hidden subdirectory inside each run directory that holds all internals
|
||||
* (deliverables, logs, prompts, session state, browser artifacts). Keeps the
|
||||
* run folder's top level clean so only the final report is visible. Must match
|
||||
* INTERNAL_DIR in the worker package.
|
||||
*/
|
||||
export const INTERNAL_DIR = '.shannon';
|
||||
|
||||
/**
|
||||
* Filename of the human-facing final report surfaced at the run directory root.
|
||||
* Must match FINAL_REPORT_FILENAME in the worker package.
|
||||
*/
|
||||
export const FINAL_REPORT_FILENAME = 'Security-Assessment-Report.md';
|
||||
|
||||
/**
|
||||
* Resolve a run-directory file (e.g. session.json, workflow.log), preferring the
|
||||
* current INTERNAL_DIR location and falling back to the legacy run-root location
|
||||
* so pre-restructure workspaces keep working. Returns the INTERNAL_DIR path when
|
||||
* neither exists — the right default for new runs and error messages.
|
||||
*/
|
||||
export function resolveRunFile(runDir: string, filename: string): string {
|
||||
const current = path.join(runDir, INTERNAL_DIR, filename);
|
||||
if (fs.existsSync(current)) {
|
||||
return current;
|
||||
}
|
||||
const legacy = path.join(runDir, filename);
|
||||
if (fs.existsSync(legacy)) {
|
||||
return legacy;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve --repo to absolute path and container mount.
|
||||
* Dev mode: bare names (no / or . prefix) check ./repos/<name> first.
|
||||
|
||||
+10
-6
@@ -1,14 +1,18 @@
|
||||
/**
|
||||
* Splash screen display — pure terminal output, no npm dependencies.
|
||||
* Color escapes are gated on terminal support; the Unicode art is always kept.
|
||||
*/
|
||||
|
||||
import { supportsColor } from './tty.js';
|
||||
|
||||
export function displaySplash(version?: string): void {
|
||||
const GOLD = '\x1b[38;2;244;197;66m';
|
||||
const CYAN = '\x1b[36;1m';
|
||||
const WHITE = '\x1b[1;37m';
|
||||
const GRAY = '\x1b[0;37m';
|
||||
const YELLOW = '\x1b[1;33m';
|
||||
const RESET = '\x1b[0m';
|
||||
const color = supportsColor();
|
||||
const GOLD = color ? '\x1b[38;2;244;197;66m' : '';
|
||||
const CYAN = color ? '\x1b[36;1m' : '';
|
||||
const WHITE = color ? '\x1b[1;37m' : '';
|
||||
const GRAY = color ? '\x1b[0;37m' : '';
|
||||
const YELLOW = color ? '\x1b[1;33m' : '';
|
||||
const RESET = color ? '\x1b[0m' : '';
|
||||
|
||||
const B = `${CYAN}\u2551${RESET}`;
|
||||
const S67 = ' '.repeat(67);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Terminal capability detection — output coloring, cursor animation, and
|
||||
* whether the user can be prompted interactively.
|
||||
*/
|
||||
|
||||
/** True when stdout is a real terminal — safe for color, cursor moves, and spinners. */
|
||||
export function stdoutIsTerminal(): boolean {
|
||||
return !!process.stdout.isTTY;
|
||||
}
|
||||
|
||||
/** True when both stdin and stdout are terminals, so interactive prompts can run. */
|
||||
function isInteractive(): boolean {
|
||||
return !!process.stdin.isTTY && !!process.stdout.isTTY;
|
||||
}
|
||||
|
||||
/** True when color escapes should be emitted. NO_COLOR disables; FORCE_COLOR overrides (0/false/empty = off). */
|
||||
export function supportsColor(): boolean {
|
||||
if (process.env.NO_COLOR !== undefined) return false;
|
||||
|
||||
const force = process.env.FORCE_COLOR;
|
||||
if (force !== undefined) {
|
||||
return force !== '0' && force !== 'false' && force !== '';
|
||||
}
|
||||
|
||||
return stdoutIsTerminal();
|
||||
}
|
||||
|
||||
/** Exit with a clear error when an interactive-only command has no terminal, instead of hanging on a prompt. */
|
||||
export function requireInteractive(command: string, alternative: string): void {
|
||||
if (isInteractive()) return;
|
||||
console.error(`ERROR: '${command}' needs an interactive terminal.`);
|
||||
console.error(alternative);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Version reporting — mode-aware.
|
||||
*
|
||||
* NPX mode: the published package.json version (stamped by CI at release).
|
||||
* Local mode: the git commit SHA of the checked-out clone (`git-<full-sha>`).
|
||||
* A clone has no meaningful semver, so the commit is the honest identifier.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getMode } from './mode.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function readPackageVersion(): string {
|
||||
try {
|
||||
const pkgPath = path.join(__dirname, '..', 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as { version?: string };
|
||||
return pkg.version || '1.0.0';
|
||||
} catch {
|
||||
return '1.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a git command in the CLI's own repo; returns trimmed stdout or null on any failure. */
|
||||
function git(...args: string[]): string | null {
|
||||
try {
|
||||
return execFileSync('git', args, { cwd: __dirname, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readGitSha(): string | null {
|
||||
return git('rev-parse', 'HEAD');
|
||||
}
|
||||
|
||||
/**
|
||||
* Version identifier. NPX: package.json version. Local: `git-<full-sha>`,
|
||||
* falling back to the package version if git is unavailable.
|
||||
*/
|
||||
export function getVersion(): string {
|
||||
if (getMode() !== 'local') return readPackageVersion();
|
||||
|
||||
const sha = readGitSha();
|
||||
if (!sha) return readPackageVersion();
|
||||
|
||||
return `git-${sha}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-facing version line printed by `--version`.
|
||||
* NPX: `shannon <version>`. Local: `shannon git-<full-sha>`.
|
||||
*/
|
||||
export function getVersionLine(): string {
|
||||
return `shannon ${getVersion()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user