feat: add npx CLI with monorepo, CI/CD, and ephemeral worker architecture (#256)
* feat: integrate npx CLI, CI/CD, and ephemeral worker architecture
Bring in changes from shannon-npx: npx-distributable CLI package (cli/),
semantic-release CI/CD workflows, ephemeral per-scan worker containers,
TOML config support, setup wizard, and workspace management.
Preserves all shannon-only changes: security hardening (localhost-bound
ports, MCP env allowlist, path traversal guard), updated benchmarks
(XBEN 19/31/35/44), README assets, and prompt injection disclaimer.
Applies security hardening to cli/infra/compose.yml as well.
* refactor: migrate to Turborepo + pnpm + Biome monorepo
Restructure into apps/worker, apps/cli, packages/mcp-server with
Turborepo task orchestration, pnpm workspaces, Biome linting/formatting,
and tsdown CLI bundling.
Key changes:
- src/ -> apps/worker/src/, cli/ -> apps/cli/, mcp-server/ -> packages/mcp-server/
- prompts/ and configs/ moved into apps/worker/
- npm replaced with pnpm, package-lock.json replaced with pnpm-lock.yaml
- Dockerfile updated for pnpm-based builds
- CLI logs command rewritten with chokidar for cross-platform reliability
- Router health checking added for auto-detected router mode
- Centralized path resolution via apps/worker/src/paths.ts
* fix: resolve all biome warnings and formatting issues
- Remove unnecessary non-null assertions where values are guaranteed
- Replace array index access with .at() for safer element retrieval
- Use local variables to avoid repeated process.env lookups
- Replace any types with unknown in functional utilities
- Use nullish coalescing for TOTP hash byte access
- Auto-format security patches to match biome config
* fix: pin pnpm to 10.12.1 in Dockerfile for catalog support
* fix: handle Esc cancellation in Bedrock setup flow
Replace p.group() with individual prompts and per-field cancel checks,
matching the pattern used by all other provider setup flows.
* feat: add optional model customization to Anthropic setup
* fix: resolve Docker bind mount permission errors on Linux
Use entrypoint-based UID remapping instead of --user flag so the
container's pentest user matches the host UID/GID, keeping bind-mounted
volumes writable. Git config moved to --system level to survive remapping.
* fix: show resumed workflow ID in splash screen URL
When resuming a workflow, the Temporal Web UI link pointed to the old
(terminated) workflow ID. Now extracts "New Workflow ID" from the resume
header in workflow.log, falling back to the original ID for fresh scans.
* style: fix biome formatting in docker.ts
* fix: align TypeScript config types with JSON Schema
- SuccessCondition.type: use schema values (url_contains,
element_present, url_equals_exactly, text_contains) instead of
stale values (url, cookie, element, redirect)
- Authentication.login_flow: mark optional to match schema which
does not require it
* feat: mark GitHub release as latest during rollback
* fix: use native ARM64 runners for Docker multi-platform builds
Replace QEMU emulation with parallel native builds using a matrix
strategy (ubuntu-latest for amd64, ubuntu-24.04-arm for arm64).
Each platform pushes by digest, then a merge job creates the
multi-arch manifest list before signing with cosign.
* fix: resolve SessionMutex race condition with 3+ concurrent waiters
* fix: skip POSIX permission check on Windows
writeFileSync mode option is ignored on Windows, so config.toml
gets 0o666 and the guard rejects it.
* fix: resolve unsubstituted placeholders in report prompt
Remove unused {{GITHUB_URL}} placeholder and wire up {{AUTH_CONTEXT}}
with structured auth context (login type, username, URL, MFA status).
* fix: remove duplicate environment gate from merge-docker job
Move DOCKERHUB_USERNAME from vars to secrets so merge-docker can access
credentials without its own environment scope. This eliminates the
redundant double approval since build-docker already gates on
release-publish.
* fix: replace POSIX sleep binary with cross-platform async sleep
execFileSync('sleep') is unavailable on Windows. Use node:timers/promises
setTimeout instead, making ensureInfra async.
* fix: use session.json for workflow ID on resume instead of parsing workflow.log
On resume, workflow.log already exists with stale headers from the
previous run. The CLI poll found '====' immediately and extracted the
old workflow ID, producing a wrong Temporal Web UI URL.
Read the workflow ID from session.json instead — the worker writes
resume attempts there atomically. For fresh runs, poll until
originalWorkflowId appears. For resumes, poll until a new
resumeAttempts entry is appended.
* feat: add custom base URL support for Anthropic-compatible proxies
Support ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN to route SDK requests
through LiteLLM or any Anthropic-compatible proxy. Adds TUI wizard
option, TOML config mapping, credential validation, and preflight
endpoint reachability check via SDK query.
* fix: remove environment gates and add NPM_TOKEN to publish step
* feat: add beta release and rollback workflows with cosign signing
* fix: remove redundant checkout and pnpm steps from beta release workflow
* docs: normalize README commands to mode-neutral shorthand
Add a substitution note after Quick Start sections so all subsequent
examples use bare `shannon` instead of mixing `./shannon` and
`npx @keygraph/shannon`. Mode-specific commands (build, update,
uninstall) get inline annotations. Also fixes a broken command in the
Custom Base URL section.
* fix: remove redundant `update` command
Image is already auto-pulled by `ensureImage()` during `start` when the
pinned version tag is missing locally. Manual `update` was unnecessary.
* docs: add CLI package README stub
* docs: update README setup instructions for dual CLI modes
* docs: update announcement banner to npx availability
* feat: migrate from MCP tools to CLI based tools (#252)
* feat: migrate from MCP tools to CLI tools
* fix: restore browser action emoji formatters for CLI output
Adapt formatBrowserAction for playwright-cli commands, replacing the old
mcp__playwright__browser_* tool name matching removed during migration.
* fix: mount credential file to fixed container path for Vertex AI
GOOGLE_APPLICATION_CREDENTIALS was forwarded as-is to the container,
causing the relative host path to resolve against the repo mount
instead of the credentials mount. Now both local and npx modes mount
the resolved file to /app/credentials/google-sa-key.json and rewrite
the env var to match.
* feat: add git awareness and optional description field to config
* fix: drop redundant --ipc host flag from worker container
* fix: align announcement banner URL with main branch
* feat: add target URL reachability preflight check (#254)
* Moving asset benchmark graph image to this folder
* Move benchmark results to benchmark repo
Windows Defender flags exploit code in the pentest reports as false positives, forcing every Windows user to add a Defender exclusion just to clone Shannon.
* Updated README
* fix: case-insensitive grep for semantic-release version probe
* fix: harden supply chain security (#255)
* fix: patch smol-toml and tsdown vulnerabilities
Update smol-toml 1.6.0→1.6.1 (DoS via recursive comment parsing) and
tsdown 0.21.2→0.21.5 (picomatch ReDoS + method injection).
* fix: pin all unpinned dependency versions in Dockerfile
Pins subfinder v2.13.0, WhatWeb v0.6.3 (switched from git clone to
release tarball), schemathesis 4.13.0, addressable 2.8.9,
claude-code 2.1.84, and playwright-cli 0.1.1 for reproducible builds.
* fix: pin GitHub Actions to commit SHAs for supply chain security
* fix: pin GitHub Actions to commit SHAs in beta and rollback workflows
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* `shannon build` command — build the worker Docker image locally.
|
||||
* Only available in local mode (running from cloned repository).
|
||||
*/
|
||||
|
||||
import { buildImage } from '../docker.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
|
||||
export function build(noCache: boolean): void {
|
||||
if (!isLocal()) {
|
||||
console.error('ERROR: Build is only available when running from the Shannon repository');
|
||||
console.error(' (Dockerfile not found in current directory)');
|
||||
console.error('');
|
||||
console.error('For npx usage, run: shannon update');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
buildImage(noCache);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* `shannon logs` command — tail a workspace's workflow 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';
|
||||
|
||||
// Match the exact line the worker writes — anchored to prevent false positives from agent output
|
||||
const COMPLETION_PATTERN = /^Workflow (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 = 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');
|
||||
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');
|
||||
if (fs.existsSync(namedPath)) return namedPath;
|
||||
}
|
||||
|
||||
console.error(`ERROR: Workflow log not found for: ${workspaceId}`);
|
||||
console.error('');
|
||||
console.error('Possible causes:');
|
||||
console.error(" - Workflow hasn't started yet");
|
||||
console.error(' - Workspace ID is incorrect');
|
||||
console.error('');
|
||||
console.error('Check the Temporal Web UI at http://localhost:8233 for workflow 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 workflow 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);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* `shn setup` — interactive TUI wizard for one-time credential configuration.
|
||||
*
|
||||
* Walks the user through selecting a provider and entering credentials,
|
||||
* then persists everything to ~/.shannon/config.toml with 0o600 permissions.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as p from '@clack/prompts';
|
||||
import { type ShannonConfig, saveConfig } from '../config/writer.js';
|
||||
|
||||
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
|
||||
|
||||
type Provider = 'anthropic' | 'custom_base_url' | 'bedrock' | 'vertex' | 'router';
|
||||
|
||||
export async function setup(): Promise<void> {
|
||||
p.intro('Shannon Setup');
|
||||
|
||||
// 1. Select provider
|
||||
const provider = await p.select({
|
||||
message: 'Select your AI provider',
|
||||
options: [
|
||||
{ value: 'anthropic' as const, label: 'Claude Direct', hint: 'recommended' },
|
||||
{ value: 'custom_base_url' as const, label: 'Custom Base URL', hint: 'proxies, gateways' },
|
||||
{ value: 'bedrock' as const, label: 'Claude via AWS Bedrock' },
|
||||
{ value: 'vertex' as const, label: 'Claude via Google Vertex AI' },
|
||||
{ value: 'router' as const, label: 'Router', hint: 'experimental' },
|
||||
],
|
||||
});
|
||||
if (p.isCancel(provider)) return cancelAndExit();
|
||||
|
||||
const config = await setupProvider(provider as Provider);
|
||||
|
||||
// 2. Save config
|
||||
saveConfig(config);
|
||||
|
||||
const configPath = path.join(SHANNON_HOME, 'config.toml');
|
||||
p.log.success(`Configuration saved to ${configPath}`);
|
||||
p.outro('Run `npx @keygraph/shannon start` to begin a scan.');
|
||||
}
|
||||
|
||||
async function setupProvider(provider: Provider): Promise<ShannonConfig> {
|
||||
switch (provider) {
|
||||
case 'anthropic':
|
||||
return setupAnthropic();
|
||||
case 'custom_base_url':
|
||||
return setupCustomBaseUrl();
|
||||
case 'bedrock':
|
||||
return setupBedrock();
|
||||
case 'vertex':
|
||||
return setupVertex();
|
||||
case 'router':
|
||||
return setupRouter();
|
||||
}
|
||||
}
|
||||
|
||||
// === Provider Setup Flows ===
|
||||
|
||||
async function setupAnthropic(): Promise<ShannonConfig> {
|
||||
const authMethod = await p.select({
|
||||
message: 'Authentication method',
|
||||
options: [
|
||||
{ value: 'api_key' as const, label: 'API Key' },
|
||||
{ value: 'oauth' as const, label: 'OAuth Token' },
|
||||
],
|
||||
});
|
||||
if (p.isCancel(authMethod)) return cancelAndExit();
|
||||
|
||||
const config: ShannonConfig = {};
|
||||
|
||||
if (authMethod === 'oauth') {
|
||||
const token = await promptSecret('Enter your OAuth token');
|
||||
config.anthropic = { oauth_token: token };
|
||||
} else {
|
||||
const apiKey = await promptSecret('Enter your Anthropic API key');
|
||||
config.anthropic = { api_key: apiKey };
|
||||
}
|
||||
|
||||
const customizeModels = await p.confirm({
|
||||
message:
|
||||
'Do you want to change the default models?\n' +
|
||||
' Small - claude-haiku-4-5-20251001\n' +
|
||||
' Medium - claude-sonnet-4-6\n' +
|
||||
' Large - claude-opus-4-6',
|
||||
initialValue: false,
|
||||
});
|
||||
if (p.isCancel(customizeModels)) return cancelAndExit();
|
||||
|
||||
if (customizeModels) {
|
||||
const small = await p.text({
|
||||
message: 'Small model ID',
|
||||
initialValue: 'claude-haiku-4-5-20251001',
|
||||
validate: required('Small model ID is required'),
|
||||
});
|
||||
if (p.isCancel(small)) return cancelAndExit();
|
||||
|
||||
const medium = await p.text({
|
||||
message: 'Medium model ID',
|
||||
initialValue: 'claude-sonnet-4-6',
|
||||
validate: required('Medium model ID is required'),
|
||||
});
|
||||
if (p.isCancel(medium)) return cancelAndExit();
|
||||
|
||||
const large = await p.text({
|
||||
message: 'Large model ID',
|
||||
initialValue: 'claude-opus-4-6',
|
||||
validate: required('Large model ID is required'),
|
||||
});
|
||||
if (p.isCancel(large)) return cancelAndExit();
|
||||
|
||||
config.models = { small, medium, large };
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function setupCustomBaseUrl(): Promise<ShannonConfig> {
|
||||
const baseUrl = await p.text({
|
||||
message: 'Endpoint URL',
|
||||
placeholder: 'https://your-proxy.example.com',
|
||||
validate: (value) => {
|
||||
if (!value) return 'Endpoint URL is required';
|
||||
try {
|
||||
new URL(value);
|
||||
} catch {
|
||||
return 'Must be a valid URL';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (p.isCancel(baseUrl)) return cancelAndExit();
|
||||
|
||||
const authToken = await promptSecret('Enter the auth token for the custom endpoint');
|
||||
|
||||
const config: ShannonConfig = {
|
||||
custom_base_url: { base_url: baseUrl, auth_token: authToken },
|
||||
};
|
||||
|
||||
const customizeModels = await p.confirm({
|
||||
message:
|
||||
'Do you want to change the default models?\n' +
|
||||
' Small - claude-haiku-4-5-20251001\n' +
|
||||
' Medium - claude-sonnet-4-6\n' +
|
||||
' Large - claude-opus-4-6',
|
||||
initialValue: false,
|
||||
});
|
||||
if (p.isCancel(customizeModels)) return cancelAndExit();
|
||||
|
||||
if (customizeModels) {
|
||||
const small = await p.text({
|
||||
message: 'Small model ID',
|
||||
initialValue: 'claude-haiku-4-5-20251001',
|
||||
validate: required('Small model ID is required'),
|
||||
});
|
||||
if (p.isCancel(small)) return cancelAndExit();
|
||||
|
||||
const medium = await p.text({
|
||||
message: 'Medium model ID',
|
||||
initialValue: 'claude-sonnet-4-6',
|
||||
validate: required('Medium model ID is required'),
|
||||
});
|
||||
if (p.isCancel(medium)) return cancelAndExit();
|
||||
|
||||
const large = await p.text({
|
||||
message: 'Large model ID',
|
||||
initialValue: 'claude-opus-4-6',
|
||||
validate: required('Large model ID is required'),
|
||||
});
|
||||
if (p.isCancel(large)) return cancelAndExit();
|
||||
|
||||
config.models = { small, medium, large };
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function setupBedrock(): Promise<ShannonConfig> {
|
||||
const region = await p.text({
|
||||
message: 'AWS Region',
|
||||
placeholder: 'us-east-1',
|
||||
validate: required('AWS Region is required'),
|
||||
});
|
||||
if (p.isCancel(region)) return cancelAndExit();
|
||||
|
||||
const token = await promptSecret('Enter your AWS Bearer Token');
|
||||
|
||||
const small = await p.text({
|
||||
message: 'Small model ID',
|
||||
placeholder: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||
validate: required('Small model ID is required'),
|
||||
});
|
||||
if (p.isCancel(small)) return cancelAndExit();
|
||||
|
||||
const medium = await p.text({
|
||||
message: 'Medium model ID',
|
||||
placeholder: 'us.anthropic.claude-sonnet-4-6',
|
||||
validate: required('Medium model ID is required'),
|
||||
});
|
||||
if (p.isCancel(medium)) return cancelAndExit();
|
||||
|
||||
const large = await p.text({
|
||||
message: 'Large model ID',
|
||||
placeholder: 'us.anthropic.claude-opus-4-6',
|
||||
validate: required('Large model ID is required'),
|
||||
});
|
||||
if (p.isCancel(large)) return cancelAndExit();
|
||||
|
||||
return {
|
||||
bedrock: { use: true, region, token },
|
||||
models: { small, medium, large },
|
||||
};
|
||||
}
|
||||
|
||||
async function setupVertex(): Promise<ShannonConfig> {
|
||||
// 1. Collect region and project ID
|
||||
const region = await p.text({
|
||||
message: 'Google Cloud region',
|
||||
placeholder: 'us-east5',
|
||||
validate: required('Region is required'),
|
||||
});
|
||||
if (p.isCancel(region)) return cancelAndExit();
|
||||
|
||||
const projectId = await p.text({
|
||||
message: 'GCP Project ID',
|
||||
validate: required('Project ID is required'),
|
||||
});
|
||||
if (p.isCancel(projectId)) return cancelAndExit();
|
||||
|
||||
// 2. File picker for service account key
|
||||
p.log.info('Select the path to your GCP Service Account JSON key file.');
|
||||
const keySourcePath = await p.path({
|
||||
message: 'Service Account JSON key file',
|
||||
validate: (value) => {
|
||||
if (!value) return 'Path is required';
|
||||
if (!fs.existsSync(value)) return 'File not found';
|
||||
if (!value.endsWith('.json')) return 'Must be a .json file';
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (p.isCancel(keySourcePath)) return cancelAndExit();
|
||||
|
||||
// 3. Copy key to ~/.shannon/ and lock permissions
|
||||
const destPath = path.join(SHANNON_HOME, 'google-sa-key.json');
|
||||
fs.mkdirSync(SHANNON_HOME, { recursive: true });
|
||||
fs.copyFileSync(keySourcePath, destPath);
|
||||
fs.chmodSync(destPath, 0o600);
|
||||
p.log.success(`Key copied to ${destPath} (permissions: 0600)`);
|
||||
|
||||
// 4. Model tiers
|
||||
const models = await p.group({
|
||||
small: () =>
|
||||
p.text({
|
||||
message: 'Small model ID',
|
||||
placeholder: 'claude-haiku-4-5@20251001',
|
||||
validate: required('Small model ID is required'),
|
||||
}),
|
||||
medium: () =>
|
||||
p.text({
|
||||
message: 'Medium model ID',
|
||||
placeholder: 'claude-sonnet-4-6',
|
||||
validate: required('Medium model ID is required'),
|
||||
}),
|
||||
large: () =>
|
||||
p.text({
|
||||
message: 'Large model ID',
|
||||
placeholder: 'claude-opus-4-6',
|
||||
validate: required('Large model ID is required'),
|
||||
}),
|
||||
});
|
||||
if (p.isCancel(models)) return cancelAndExit();
|
||||
|
||||
return {
|
||||
vertex: {
|
||||
use: true,
|
||||
region,
|
||||
project_id: projectId,
|
||||
key_path: destPath,
|
||||
},
|
||||
models: { small: models.small, medium: models.medium, large: models.large },
|
||||
};
|
||||
}
|
||||
|
||||
async function setupRouter(): Promise<ShannonConfig> {
|
||||
const routerProvider = await p.select({
|
||||
message: 'Router provider',
|
||||
options: [
|
||||
{ value: 'openai' as const, label: 'OpenAI' },
|
||||
{ value: 'openrouter' as const, label: 'OpenRouter' },
|
||||
],
|
||||
});
|
||||
if (p.isCancel(routerProvider)) return cancelAndExit();
|
||||
|
||||
const apiKey = await promptSecret(
|
||||
routerProvider === 'openai' ? 'Enter your OpenAI API key' : 'Enter your OpenRouter API key',
|
||||
);
|
||||
|
||||
let defaultModel: string;
|
||||
if (routerProvider === 'openai') {
|
||||
const model = await p.select({
|
||||
message: 'Default model',
|
||||
options: [
|
||||
{ value: 'gpt-5.2' as const, label: 'GPT-5.2' },
|
||||
{ value: 'gpt-5-mini' as const, label: 'GPT-5 Mini' },
|
||||
],
|
||||
});
|
||||
if (p.isCancel(model)) return cancelAndExit();
|
||||
defaultModel = `openai,${model}`;
|
||||
} else {
|
||||
const model = await p.select({
|
||||
message: 'Default model',
|
||||
options: [{ value: 'google/gemini-3-flash-preview' as const, label: 'Google Gemini 3 Flash Preview' }],
|
||||
});
|
||||
if (p.isCancel(model)) return cancelAndExit();
|
||||
defaultModel = `openrouter,${model}`;
|
||||
}
|
||||
|
||||
const router: ShannonConfig['router'] = { default: defaultModel };
|
||||
if (routerProvider === 'openai') {
|
||||
router.openai_key = apiKey;
|
||||
} else {
|
||||
router.openrouter_key = apiKey;
|
||||
}
|
||||
|
||||
return { router };
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
async function promptSecret(message: string): Promise<string> {
|
||||
const value = await p.password({
|
||||
message,
|
||||
validate: required(`${message.replace(/^Enter /, '')} is required`),
|
||||
});
|
||||
if (p.isCancel(value)) return cancelAndExit();
|
||||
return value;
|
||||
}
|
||||
|
||||
function required(errorMessage: string): (value: string | undefined) => string | undefined {
|
||||
return (value) => {
|
||||
if (!value) return errorMessage;
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
function cancelAndExit(): never {
|
||||
p.cancel('Setup cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* `shannon start` command — launch a pentest scan.
|
||||
*
|
||||
* Handles both local mode (local build, ./workspaces/, mounted prompts)
|
||||
* and npx mode (Docker Hub pull, ~/.shannon/).
|
||||
*/
|
||||
|
||||
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 { buildEnvFlags, isRouterConfigured, loadEnv, validateCredentials } from '../env.js';
|
||||
import { getCredentialsPath, getWorkspacesDir, initHome } from '../home.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
import { ensureDeliverables, resolveConfig, resolveRepo } from '../paths.js';
|
||||
import { displaySplash } from '../splash.js';
|
||||
|
||||
export interface StartArgs {
|
||||
url: string;
|
||||
repo: string;
|
||||
config?: string;
|
||||
workspace?: string;
|
||||
output?: string;
|
||||
pipelineTesting: boolean;
|
||||
router: boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export async function start(args: StartArgs): Promise<void> {
|
||||
// 1. Initialize state directories and load env
|
||||
initHome();
|
||||
loadEnv();
|
||||
|
||||
// 2. Validate credentials and auto-detect router mode
|
||||
const creds = validateCredentials();
|
||||
if (!creds.valid) {
|
||||
console.error(`ERROR: ${creds.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const useRouter = args.router || isRouterConfigured();
|
||||
|
||||
// 3. Resolve paths
|
||||
const repo = resolveRepo(args.repo);
|
||||
const config = args.config ? resolveConfig(args.config) : undefined;
|
||||
ensureDeliverables(repo.hostPath);
|
||||
|
||||
// 4. Ensure workspaces dir is writable by container user (UID 1001)
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
fs.mkdirSync(workspacesDir, { recursive: true });
|
||||
fs.chmodSync(workspacesDir, 0o777);
|
||||
|
||||
// 5. Handle router env
|
||||
if (useRouter) {
|
||||
process.env.ANTHROPIC_BASE_URL = 'http://shannon-router:3456';
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'shannon-router-key';
|
||||
}
|
||||
|
||||
// 6. Ensure image (auto-build in dev, pull in npx) and start infra
|
||||
ensureImage(args.version);
|
||||
await ensureInfra(useRouter);
|
||||
|
||||
// 7. Generate unique task queue and container name
|
||||
const suffix = randomSuffix();
|
||||
const taskQueue = `shannon-${suffix}`;
|
||||
const containerName = `shannon-worker-${suffix}`;
|
||||
|
||||
// 8. Generate workspace name if not provided
|
||||
const workspace =
|
||||
args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
|
||||
|
||||
// 9. Resolve credentials — mount single file to fixed container path
|
||||
const credentialsPath = getCredentialsPath();
|
||||
const hasCredentials = fs.existsSync(credentialsPath);
|
||||
|
||||
if (hasCredentials) {
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS = '/app/credentials/google-sa-key.json';
|
||||
}
|
||||
|
||||
// 10. Resolve output directory
|
||||
const outputDir = args.output ? path.resolve(args.output) : undefined;
|
||||
if (outputDir) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 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
|
||||
const proc = spawnWorker({
|
||||
version: args.version,
|
||||
url: args.url,
|
||||
repo,
|
||||
workspacesDir,
|
||||
taskQueue,
|
||||
containerName,
|
||||
envFlags: buildEnvFlags(),
|
||||
...(config && { config }),
|
||||
...(hasCredentials && { credentials: credentialsPath }),
|
||||
...(promptsDir && { promptsDir }),
|
||||
...(outputDir && { outputDir }),
|
||||
...(workspace && { workspace }),
|
||||
...(args.pipelineTesting && { pipelineTesting: true }),
|
||||
});
|
||||
|
||||
// 14. Wait for workflow to register, then display info
|
||||
proc.on('error', (err) => {
|
||||
console.error(`Failed to start worker: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Detect whether this is a fresh workspace or a resume by checking session.json existence
|
||||
const sessionJson = path.join(workspacesDir, workspace, 'session.json');
|
||||
const isResume = fs.existsSync(sessionJson);
|
||||
let initialResumeCount = 0;
|
||||
if (isResume) {
|
||||
try {
|
||||
const session = JSON.parse(fs.readFileSync(sessionJson, 'utf-8'));
|
||||
initialResumeCount = session.session?.resumeAttempts?.length ?? 0;
|
||||
} catch {
|
||||
// Corrupted file — worker will handle validation
|
||||
}
|
||||
}
|
||||
|
||||
// Poll for workflow to register in session.json
|
||||
process.stdout.write('Waiting for workflow 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('Timeout waiting for workflow 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 waiting line and show info
|
||||
process.stdout.write('\r\x1b[K');
|
||||
printInfo(args, useRouter, workspace, workflowId, repo.hostPath, workspacesDir);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist yet
|
||||
}
|
||||
process.stdout.write('.');
|
||||
}, 2000);
|
||||
|
||||
// Stop the worker container only if it hasn't started yet
|
||||
let cleaned = false;
|
||||
const cleanup = (): void => {
|
||||
if (cleaned || started) return;
|
||||
cleaned = true;
|
||||
clearInterval(pollInterval);
|
||||
console.log(`\nStopping worker ${containerName}...`);
|
||||
try {
|
||||
execFileSync('docker', ['stop', containerName], { stdio: 'pipe' });
|
||||
} catch {
|
||||
// Container may have already exited
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('exit', cleanup);
|
||||
}
|
||||
|
||||
function printInfo(
|
||||
args: StartArgs,
|
||||
routerActive: boolean,
|
||||
workspace: string,
|
||||
workflowId: string,
|
||||
repoPath: string,
|
||||
workspacesDir: string,
|
||||
): void {
|
||||
const logsCmd = isLocal() ? `./shannon logs ${workspace}` : `npx @keygraph/shannon logs ${workspace}`;
|
||||
const reportsPath = path.join(workspacesDir, workspace);
|
||||
|
||||
console.log(` Target: ${args.url}`);
|
||||
console.log(` Repository: ${repoPath}`);
|
||||
console.log(` Workspace: ${workspace}`);
|
||||
if (args.config) {
|
||||
console.log(` Config: ${path.resolve(args.config)}`);
|
||||
}
|
||||
if (args.pipelineTesting) {
|
||||
console.log(' Mode: Pipeline Testing');
|
||||
}
|
||||
if (routerActive) {
|
||||
console.log(' Router: Enabled');
|
||||
}
|
||||
console.log('');
|
||||
console.log(' Monitor:');
|
||||
if (workflowId) {
|
||||
console.log(` Web UI: http://localhost:8233/namespaces/default/workflows/${workflowId}`);
|
||||
} else {
|
||||
console.log(' Web UI: http://localhost:8233');
|
||||
}
|
||||
console.log(` Logs: ${logsCmd}`);
|
||||
console.log('');
|
||||
console.log(' Output:');
|
||||
console.log(` Reports: ${reportsPath}/`);
|
||||
console.log('');
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* `shannon status` command — show running workers and Temporal health.
|
||||
*/
|
||||
|
||||
import { isTemporalReady, listRunningWorkers } from '../docker.js';
|
||||
|
||||
export function status(): void {
|
||||
// 1. Temporal health
|
||||
const temporalUp = isTemporalReady();
|
||||
console.log(`Temporal: ${temporalUp ? 'running' : 'not running'}`);
|
||||
if (temporalUp) {
|
||||
console.log(' Web UI: http://localhost:8233');
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// 2. Running workers
|
||||
const workers = listRunningWorkers();
|
||||
if (workers) {
|
||||
console.log('Workers:');
|
||||
console.log(workers);
|
||||
} else {
|
||||
console.log('Workers: none running');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* `shannon stop` command — stop workers and infrastructure.
|
||||
*/
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import { stopInfra, stopWorkers } from '../docker.js';
|
||||
|
||||
export async function stop(clean: boolean): Promise<void> {
|
||||
if (clean) {
|
||||
const confirmed = await p.confirm({
|
||||
message: 'This will stop all running scans and remove the Temporal data. Continue?',
|
||||
});
|
||||
if (p.isCancel(confirmed) || !confirmed) {
|
||||
p.cancel('Aborted.');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
stopWorkers();
|
||||
stopInfra(clean);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* `shn uninstall` command — remove ~/.shannon/ after confirmation (npx only).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as p from '@clack/prompts';
|
||||
import { stopInfra, stopWorkers } from '../docker.js';
|
||||
|
||||
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
|
||||
|
||||
export async function uninstall(): Promise<void> {
|
||||
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.');
|
||||
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);
|
||||
}
|
||||
|
||||
// Stop any running containers first
|
||||
stopWorkers();
|
||||
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.');
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* `shannon workspaces` command — list all workspaces.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import { getWorkerImage } from '../docker.js';
|
||||
import { getWorkspacesDir } from '../home.js';
|
||||
|
||||
export function workspaces(version: string): void {
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
const image = getWorkerImage(version);
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'-v',
|
||||
`${workspacesDir}:/app/workspaces`,
|
||||
'-e',
|
||||
'WORKSPACES_DIR=/app/workspaces',
|
||||
image,
|
||||
'node',
|
||||
'apps/worker/dist/temporal/workspaces.js',
|
||||
],
|
||||
{ stdio: 'inherit', ...(os.platform() === 'win32' && { env: { ...process.env, MSYS_NO_PATHCONV: '1' } }) },
|
||||
);
|
||||
} catch {
|
||||
console.error('ERROR: Failed to list workspaces. Is the Docker image available?');
|
||||
console.error(` Run: docker pull ${image}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Configuration resolver with environment-first, TOML-fallback precedence.
|
||||
*
|
||||
* Priority: process.env > ~/.shannon/config.toml
|
||||
* Env var names match .env.example exactly; TOML uses nested sections.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { parse as parseTOML } from 'smol-toml';
|
||||
import { getConfigFile } from '../home.js';
|
||||
import { getMode } from '../mode.js';
|
||||
|
||||
// === TOML ↔ Env Mapping ===
|
||||
|
||||
type TOMLType = 'string' | 'number' | 'boolean';
|
||||
|
||||
interface ConfigMapping {
|
||||
readonly env: string;
|
||||
readonly toml: string;
|
||||
readonly type: TOMLType;
|
||||
}
|
||||
|
||||
/** Maps every supported env var to its TOML path (section.key) and expected type. */
|
||||
const CONFIG_MAP: readonly ConfigMapping[] = [
|
||||
// Core
|
||||
{ env: 'CLAUDE_CODE_MAX_OUTPUT_TOKENS', toml: 'core.max_tokens', type: 'number' },
|
||||
|
||||
// Anthropic
|
||||
{ env: 'ANTHROPIC_API_KEY', toml: 'anthropic.api_key', type: 'string' },
|
||||
{ env: 'CLAUDE_CODE_OAUTH_TOKEN', toml: 'anthropic.oauth_token', type: 'string' },
|
||||
|
||||
// Bedrock
|
||||
{ env: 'CLAUDE_CODE_USE_BEDROCK', toml: 'bedrock.use', type: 'boolean' },
|
||||
{ env: 'AWS_REGION', toml: 'bedrock.region', type: 'string' },
|
||||
{ env: 'AWS_BEARER_TOKEN_BEDROCK', toml: 'bedrock.token', type: 'string' },
|
||||
|
||||
// Vertex
|
||||
{ env: 'CLAUDE_CODE_USE_VERTEX', toml: 'vertex.use', type: 'boolean' },
|
||||
{ env: 'CLOUD_ML_REGION', toml: 'vertex.region', type: 'string' },
|
||||
{ env: 'ANTHROPIC_VERTEX_PROJECT_ID', toml: 'vertex.project_id', type: 'string' },
|
||||
{ env: 'GOOGLE_APPLICATION_CREDENTIALS', toml: 'vertex.key_path', type: 'string' },
|
||||
|
||||
// Custom Base URL
|
||||
{ env: 'ANTHROPIC_BASE_URL', toml: 'custom_base_url.base_url', type: 'string' },
|
||||
{ env: 'ANTHROPIC_AUTH_TOKEN', toml: 'custom_base_url.auth_token', type: 'string' },
|
||||
|
||||
// Router
|
||||
{ env: 'ROUTER_DEFAULT', toml: 'router.default', type: 'string' },
|
||||
{ env: 'OPENAI_API_KEY', toml: 'router.openai_key', type: 'string' },
|
||||
{ env: 'OPENROUTER_API_KEY', toml: 'router.openrouter_key', type: 'string' },
|
||||
|
||||
// Model tiers
|
||||
{ env: 'ANTHROPIC_SMALL_MODEL', toml: 'models.small', type: 'string' },
|
||||
{ env: 'ANTHROPIC_MEDIUM_MODEL', toml: 'models.medium', type: 'string' },
|
||||
{ env: 'ANTHROPIC_LARGE_MODEL', toml: 'models.large', type: 'string' },
|
||||
] as const;
|
||||
|
||||
// === TOML Parsing ===
|
||||
|
||||
type TOMLValue = string | number | boolean;
|
||||
type TOMLSection = Record<string, TOMLValue>;
|
||||
type TOMLConfig = Record<string, TOMLSection>;
|
||||
|
||||
/** Read a nested TOML value by dotted path (e.g. "anthropic.api_key"). */
|
||||
function getTomlValue(config: TOMLConfig, path: string): string | undefined {
|
||||
const [section, key] = path.split('.');
|
||||
if (!section || !key) return undefined;
|
||||
|
||||
const sectionObj = config[section];
|
||||
if (!sectionObj || typeof sectionObj !== 'object') return undefined;
|
||||
|
||||
const value = sectionObj[key];
|
||||
if (value === undefined || value === null) return undefined;
|
||||
|
||||
// NOTE: env.ts checks bedrock/vertex via `=== '1'`, so booleans must map to "1"/"0"
|
||||
if (typeof value === 'boolean') return value ? '1' : '0';
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** Parse the global TOML config file, returning null if it doesn't exist. */
|
||||
function loadTOML(): TOMLConfig | null {
|
||||
const configPath = getConfigFile();
|
||||
if (!fs.existsSync(configPath)) return null;
|
||||
|
||||
// Config contains secrets — refuse to read if group or others have any access.
|
||||
// Skip on Windows where POSIX permissions are not supported.
|
||||
if (process.platform !== 'win32') {
|
||||
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`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
return parseTOML(content) as TOMLConfig;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`\nFailed to parse ${configPath}: ${message}`);
|
||||
console.error(`\nRun 'npx @keygraph/shannon setup' to reconfigure.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// === Validation ===
|
||||
|
||||
/** Build a lookup of allowed keys per section from CONFIG_MAP. */
|
||||
function buildSchema(): Map<string, Map<string, TOMLType>> {
|
||||
const schema = new Map<string, Map<string, TOMLType>>();
|
||||
for (const mapping of CONFIG_MAP) {
|
||||
const [section, key] = mapping.toml.split('.');
|
||||
if (!section || !key) continue;
|
||||
|
||||
let keys = schema.get(section);
|
||||
if (!keys) {
|
||||
keys = new Map();
|
||||
schema.set(section, keys);
|
||||
}
|
||||
keys.set(key, mapping.type);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
/** Check that a provider section has all required fields and dependencies. */
|
||||
function validateProviderFields(config: TOMLConfig, provider: string, errors: string[]): void {
|
||||
const section = config[provider] as Record<string, unknown> | undefined;
|
||||
if (!section) return;
|
||||
const keys = Object.keys(section);
|
||||
|
||||
switch (provider) {
|
||||
case 'anthropic':
|
||||
if (!keys.includes('api_key') && !keys.includes('oauth_token')) {
|
||||
errors.push('[anthropic] requires either api_key or oauth_token');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'custom_base_url': {
|
||||
const required = ['base_url', 'auth_token'];
|
||||
const missing = required.filter((k) => !keys.includes(k));
|
||||
if (missing.length > 0) {
|
||||
errors.push(`[custom_base_url] missing required keys: ${missing.join(', ')}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'bedrock': {
|
||||
const required = ['use', 'region', 'token'];
|
||||
const missing = required.filter((k) => !keys.includes(k));
|
||||
if (missing.length > 0) {
|
||||
errors.push(`[bedrock] missing required keys: ${missing.join(', ')}`);
|
||||
}
|
||||
validateModelTiers(config, 'bedrock', errors);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'vertex': {
|
||||
const required = ['use', 'region', 'project_id', 'key_path'];
|
||||
const missing = required.filter((k) => !keys.includes(k));
|
||||
if (missing.length > 0) {
|
||||
errors.push(`[vertex] missing required keys: ${missing.join(', ')}`);
|
||||
}
|
||||
validateModelTiers(config, 'vertex', errors);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'router': {
|
||||
if (!keys.includes('default')) {
|
||||
errors.push('[router] missing required key: default');
|
||||
}
|
||||
if (!keys.includes('openai_key') && !keys.includes('openrouter_key')) {
|
||||
errors.push('[router] requires either openai_key or openrouter_key');
|
||||
}
|
||||
const models = config.models as Record<string, unknown> | undefined;
|
||||
if (models && typeof models === 'object' && Object.keys(models).length > 0) {
|
||||
errors.push('[models] is not supported with [router]');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bedrock and Vertex require a [models] section with all three tiers. */
|
||||
function validateModelTiers(config: TOMLConfig, provider: string, errors: string[]): void {
|
||||
const models = config.models as Record<string, unknown> | undefined;
|
||||
if (!models || typeof models !== 'object') {
|
||||
errors.push(`[${provider}] requires a [models] section with small, medium, and large`);
|
||||
return;
|
||||
}
|
||||
|
||||
const required = ['small', 'medium', 'large'];
|
||||
const missing = required.filter((k) => !Object.keys(models).includes(k));
|
||||
if (missing.length > 0) {
|
||||
errors.push(`[models] missing required keys for ${provider}: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a parsed TOML config against the known schema.
|
||||
* Returns an array of human-readable error messages (empty = valid).
|
||||
*/
|
||||
function validateConfig(config: TOMLConfig): string[] {
|
||||
const schema = buildSchema();
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [section, sectionObj] of Object.entries(config)) {
|
||||
// 1. Reject unknown sections
|
||||
const allowedKeys = schema.get(section);
|
||||
if (!allowedKeys) {
|
||||
const known = [...schema.keys()].join(', ');
|
||||
errors.push(`Unknown section [${section}]. Valid sections: ${known}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Section value must be a table
|
||||
if (!sectionObj || typeof sectionObj !== 'object') {
|
||||
errors.push(`[${section}] must be a table, got ${typeof sectionObj}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Validate each key in the section
|
||||
for (const [key, value] of Object.entries(sectionObj as Record<string, unknown>)) {
|
||||
const expectedType = allowedKeys.get(key);
|
||||
if (!expectedType) {
|
||||
const known = [...allowedKeys.keys()].join(', ');
|
||||
errors.push(`Unknown key "${key}" in [${section}]. Valid keys: ${known}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value !== expectedType) {
|
||||
errors.push(`[${section}].${key} must be ${expectedType}, got ${typeof value}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reject empty strings — they pass type checks but are never useful
|
||||
if (typeof value === 'string' && value.trim() === '') {
|
||||
errors.push(`[${section}].${key} must not be empty`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Only one provider section allowed (ignore empty sections)
|
||||
const PROVIDER_SECTIONS = ['anthropic', 'custom_base_url', 'bedrock', 'vertex', 'router'] as const;
|
||||
const present = PROVIDER_SECTIONS.filter((s) => {
|
||||
const section = config[s];
|
||||
return section && typeof section === 'object' && Object.keys(section).length > 0;
|
||||
});
|
||||
if (present.length > 1) {
|
||||
errors.push(
|
||||
`Multiple providers configured: [${present.join('], [')}]. Only one provider section is allowed at a time`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Required fields per provider
|
||||
const singleProvider = present.length === 1 ? present[0] : undefined;
|
||||
if (singleProvider) {
|
||||
validateProviderFields(config, singleProvider, errors);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// === Public API ===
|
||||
|
||||
/**
|
||||
* Resolve all config values into process.env (npx mode only).
|
||||
*
|
||||
* For each mapped variable: if not already set in the environment,
|
||||
* look it up in ~/.shannon/config.toml and inject it into process.env.
|
||||
* Local mode uses .env exclusively — TOML is skipped.
|
||||
* Exits with an error if the TOML contains unknown or invalid keys.
|
||||
*/
|
||||
export function resolveConfig(): void {
|
||||
if (getMode() === 'local') return;
|
||||
|
||||
const toml = loadTOML();
|
||||
if (!toml) return;
|
||||
|
||||
// Validate before injecting
|
||||
const errors = validateConfig(toml);
|
||||
if (errors.length > 0) {
|
||||
console.error('\nInvalid configuration:');
|
||||
for (const err of errors) {
|
||||
console.error(` - ${err}`);
|
||||
}
|
||||
console.error(`\nRun 'shn setup' to reconfigure.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const mapping of CONFIG_MAP) {
|
||||
if (process.env[mapping.env]) continue;
|
||||
|
||||
const value = getTomlValue(toml, mapping.toml);
|
||||
if (value) {
|
||||
process.env[mapping.env] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** TOML config writer for ~/.shannon/config.toml. */
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { stringify } from 'smol-toml';
|
||||
import { getConfigFile } from '../home.js';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface ShannonConfig {
|
||||
core?: { max_tokens?: number };
|
||||
anthropic?: { api_key?: string; oauth_token?: string };
|
||||
custom_base_url?: { base_url?: string; auth_token?: string };
|
||||
bedrock?: { use?: boolean; region?: string; token?: string };
|
||||
vertex?: { use?: boolean; region?: string; project_id?: string; key_path?: string };
|
||||
router?: { default?: string; openai_key?: string; openrouter_key?: string };
|
||||
models?: { small?: string; medium?: string; large?: string };
|
||||
}
|
||||
|
||||
// === File Operations ===
|
||||
|
||||
/** Write the config to ~/.shannon/config.toml with 0o600 permissions. */
|
||||
export function saveConfig(config: ShannonConfig): void {
|
||||
const configPath = getConfigFile();
|
||||
const dir = path.dirname(configPath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const content = stringify(config);
|
||||
fs.writeFileSync(configPath, content, { mode: 0o600 });
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Docker orchestration — compose lifecycle, network, image pull/build, worker spawning.
|
||||
*
|
||||
* Local mode: builds locally, uses docker-compose.yml from repo root, mounts prompts.
|
||||
* NPX mode: pulls from Docker Hub, uses bundled compose.yml.
|
||||
*/
|
||||
|
||||
import { type ChildProcess, execFileSync, spawn } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getMode } from './mode.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const NPX_IMAGE_REPO = 'keygraph/shannon';
|
||||
const DEV_IMAGE = 'shannon-worker';
|
||||
|
||||
export function getWorkerImage(version: string): string {
|
||||
return getMode() === 'local' ? DEV_IMAGE : `${NPX_IMAGE_REPO}:${version}`;
|
||||
}
|
||||
|
||||
function getComposeFile(): string {
|
||||
return getMode() === 'local'
|
||||
? path.resolve('docker-compose.yml')
|
||||
: path.resolve(__dirname, '..', 'infra', 'compose.yml');
|
||||
}
|
||||
|
||||
/** Generate an 8-char random hex suffix for container/queue names. */
|
||||
export function randomSuffix(): string {
|
||||
return crypto.randomBytes(4).toString('hex');
|
||||
}
|
||||
|
||||
/** Run a command silently, return true if it succeeds. */
|
||||
function runQuiet(cmd: string, args: string[]): boolean {
|
||||
try {
|
||||
execFileSync(cmd, args, { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a command and return stdout, or empty string on failure. */
|
||||
function runOutput(cmd: string, args: string[]): string {
|
||||
try {
|
||||
return execFileSync(cmd, args, { stdio: 'pipe', encoding: 'utf-8' }).trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Temporal is running and healthy.
|
||||
*/
|
||||
export function isTemporalReady(): boolean {
|
||||
const output = runOutput('docker', [
|
||||
'exec',
|
||||
'shannon-temporal',
|
||||
'temporal',
|
||||
'operator',
|
||||
'cluster',
|
||||
'health',
|
||||
'--address',
|
||||
'localhost:7233',
|
||||
]);
|
||||
return output.includes('SERVING');
|
||||
}
|
||||
|
||||
/** Check if the router container is running and healthy. */
|
||||
function isRouterReady(): boolean {
|
||||
const status = runOutput('docker', ['inspect', '--format', '{{.State.Health.Status}}', 'shannon-router']);
|
||||
return status === 'healthy';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Temporal (and optionally router) are running via compose.
|
||||
* If Temporal is already up but router is needed and missing, starts router only.
|
||||
*/
|
||||
export async function ensureInfra(useRouter: boolean): Promise<void> {
|
||||
const temporalReady = isTemporalReady();
|
||||
const routerNeeded = useRouter && !isRouterReady();
|
||||
|
||||
if (temporalReady && !routerNeeded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const composeFile = getComposeFile();
|
||||
const composeArgs = ['compose', '-f', composeFile];
|
||||
if (useRouter) composeArgs.push('--profile', 'router');
|
||||
composeArgs.push('up', '-d');
|
||||
|
||||
if (temporalReady && routerNeeded) {
|
||||
console.log('Starting router...');
|
||||
} else {
|
||||
console.log('Starting Shannon infrastructure...');
|
||||
}
|
||||
execFileSync('docker', composeArgs, { stdio: 'inherit' });
|
||||
|
||||
// Wait for Temporal if it wasn't already running
|
||||
if (!temporalReady) {
|
||||
console.log('Waiting for Temporal to be ready...');
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (isTemporalReady()) {
|
||||
console.log('Temporal is ready!');
|
||||
break;
|
||||
}
|
||||
if (i === 29) {
|
||||
console.error('Timeout waiting for Temporal');
|
||||
process.exit(1);
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for router if needed
|
||||
if (routerNeeded) {
|
||||
console.log('Waiting for router to be ready...');
|
||||
for (let i = 0; i < 15; i++) {
|
||||
if (isRouterReady()) {
|
||||
console.log('Router is ready!');
|
||||
return;
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
console.error('Timeout waiting for router');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the worker image locally (local mode only).
|
||||
*/
|
||||
export function buildImage(noCache: boolean): void {
|
||||
console.log(`Building ${DEV_IMAGE}...`);
|
||||
const args = ['build'];
|
||||
if (noCache) args.push('--no-cache');
|
||||
args.push('-t', DEV_IMAGE, '.');
|
||||
execFileSync('docker', args, { stdio: 'inherit' });
|
||||
console.log(`Build complete: ${DEV_IMAGE}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the worker image is available.
|
||||
* Local mode: auto-builds if missing. NPX mode: pulls from Docker Hub.
|
||||
*/
|
||||
export function ensureImage(version: string): void {
|
||||
const image = getWorkerImage(version);
|
||||
const exists = runQuiet('docker', ['image', 'inspect', image]);
|
||||
if (exists) return;
|
||||
|
||||
if (getMode() === 'local') {
|
||||
console.log('Worker image not found, building...');
|
||||
buildImage(false);
|
||||
} else {
|
||||
console.log(`Pulling ${image}...`);
|
||||
try {
|
||||
execFileSync('docker', ['pull', image], { stdio: 'inherit' });
|
||||
} catch {
|
||||
console.error(`\nERROR: Failed to pull ${image}`);
|
||||
console.error('The image may not be available for your platform yet.');
|
||||
console.error('Check https://hub.docker.com/r/keygraph/shannon for available tags.');
|
||||
process.exit(1);
|
||||
}
|
||||
pruneOldImages(version);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if --add-host is needed (Linux without Podman).
|
||||
* macOS has host.docker.internal built in.
|
||||
*/
|
||||
function addHostFlag(): string[] {
|
||||
if (os.platform() === 'linux') {
|
||||
const hasPodman = runQuiet('which', ['podman']);
|
||||
if (!hasPodman) {
|
||||
return ['--add-host', 'host.docker.internal:host-gateway'];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export interface WorkerOptions {
|
||||
version: string;
|
||||
url: string;
|
||||
repo: { hostPath: string; containerPath: string };
|
||||
workspacesDir: string;
|
||||
taskQueue: string;
|
||||
containerName: string;
|
||||
envFlags: string[];
|
||||
config?: { hostPath: string; containerPath: string };
|
||||
credentials?: string;
|
||||
promptsDir?: string;
|
||||
outputDir?: string;
|
||||
workspace?: string;
|
||||
pipelineTesting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the worker container in detached mode and return the process.
|
||||
*/
|
||||
export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
const args = ['run', '-d', '--rm', '--name', opts.containerName, '--network', 'shannon-net'];
|
||||
|
||||
// Add host flag for Linux
|
||||
args.push(...addHostFlag());
|
||||
|
||||
// UID remapping for Linux bind mounts
|
||||
if (os.platform() === 'linux' && process.getuid && process.getgid) {
|
||||
args.push('-e', `SHANNON_HOST_UID=${process.getuid()}`, '-e', `SHANNON_HOST_GID=${process.getgid()}`);
|
||||
}
|
||||
|
||||
// Volume mounts
|
||||
args.push('-v', `${opts.workspacesDir}:/app/workspaces`);
|
||||
args.push('-v', `${opts.repo.hostPath}:${opts.repo.containerPath}`);
|
||||
|
||||
// Local mode: mount prompts for live editing
|
||||
if (opts.promptsDir) {
|
||||
args.push('-v', `${opts.promptsDir}:/app/apps/worker/prompts:ro`);
|
||||
}
|
||||
|
||||
if (opts.config) {
|
||||
args.push('-v', `${opts.config.hostPath}:${opts.config.containerPath}:ro`);
|
||||
}
|
||||
|
||||
// Output directory for deliverables copy
|
||||
if (opts.outputDir) {
|
||||
args.push('-v', `${opts.outputDir}:/app/output`);
|
||||
}
|
||||
|
||||
// Mount credentials file to fixed container path
|
||||
if (opts.credentials) {
|
||||
args.push('-v', `${opts.credentials}:/app/credentials/google-sa-key.json:ro`);
|
||||
}
|
||||
|
||||
// Environment
|
||||
args.push(...opts.envFlags);
|
||||
|
||||
// Container settings
|
||||
args.push('--shm-size', '2gb', '--security-opt', 'seccomp=unconfined');
|
||||
|
||||
// Image
|
||||
args.push(getWorkerImage(opts.version));
|
||||
|
||||
// Worker command
|
||||
args.push('node', 'apps/worker/dist/temporal/worker.js', opts.url, opts.repo.containerPath);
|
||||
args.push('--task-queue', opts.taskQueue);
|
||||
if (opts.config) {
|
||||
args.push('--config', opts.config.containerPath);
|
||||
}
|
||||
if (opts.outputDir) {
|
||||
args.push('--output', '/app/output');
|
||||
}
|
||||
if (opts.workspace) {
|
||||
args.push('--workspace', opts.workspace);
|
||||
}
|
||||
if (opts.pipelineTesting) {
|
||||
args.push('--pipeline-testing');
|
||||
}
|
||||
|
||||
// Prevent MSYS/Git Bash from converting Unix paths (e.g. /repos/my-repo) to Windows paths
|
||||
return spawn('docker', args, {
|
||||
stdio: 'pipe',
|
||||
...(os.platform() === 'win32' && { env: { ...process.env, MSYS_NO_PATHCONV: '1' } }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all running shannon-worker-* containers.
|
||||
*/
|
||||
export function stopWorkers(): void {
|
||||
const workers = runOutput('docker', ['ps', '-q', '--filter', 'name=shannon-worker-']);
|
||||
if (!workers) return;
|
||||
|
||||
const ids = workers.split('\n').filter(Boolean);
|
||||
console.log('Stopping worker containers...');
|
||||
execFileSync('docker', ['stop', ...ids], { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the compose stack.
|
||||
*/
|
||||
export function stopInfra(clean: boolean): void {
|
||||
const composeFile = getComposeFile();
|
||||
const args = ['compose', '-f', composeFile, '--profile', 'router', 'down'];
|
||||
if (clean) args.push('-v');
|
||||
execFileSync('docker', args, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove old keygraph/shannon images that don't match the current version.
|
||||
*/
|
||||
function pruneOldImages(currentVersion: string): void {
|
||||
const output = runOutput('docker', ['images', NPX_IMAGE_REPO, '--format', '{{.Tag}}']);
|
||||
if (!output) return;
|
||||
|
||||
const currentTag = currentVersion;
|
||||
const stale = output.split('\n').filter((tag) => tag && tag !== currentTag);
|
||||
for (const tag of stale) {
|
||||
runQuiet('docker', ['rmi', `${NPX_IMAGE_REPO}:${tag}`]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List running worker containers.
|
||||
*/
|
||||
export function listRunningWorkers(): string {
|
||||
return runOutput('docker', [
|
||||
'ps',
|
||||
'--filter',
|
||||
'name=shannon-worker-',
|
||||
'--format',
|
||||
'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}',
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Environment variable loading and credential validation.
|
||||
*
|
||||
* Local mode: loads ./.env via dotenv.
|
||||
* NPX mode: fills gaps from ~/.shannon/config.toml (no .env).
|
||||
*/
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
import { resolveConfig } from './config/resolver.js';
|
||||
import { getMode } from './mode.js';
|
||||
|
||||
/** Environment variables forwarded to worker containers. */
|
||||
const FORWARD_VARS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ROUTER_DEFAULT',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'AWS_REGION',
|
||||
'AWS_BEARER_TOKEN_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLOUD_ML_REGION',
|
||||
'ANTHROPIC_VERTEX_PROJECT_ID',
|
||||
'GOOGLE_APPLICATION_CREDENTIALS',
|
||||
'ANTHROPIC_SMALL_MODEL',
|
||||
'ANTHROPIC_MEDIUM_MODEL',
|
||||
'ANTHROPIC_LARGE_MODEL',
|
||||
'CLAUDE_CODE_MAX_OUTPUT_TOKENS',
|
||||
'OPENAI_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Load credentials into process.env.
|
||||
* Local mode: loads ./.env via dotenv.
|
||||
* NPX mode: fills gaps from ~/.shannon/config.toml.
|
||||
* Exported env vars always take precedence in both modes.
|
||||
*/
|
||||
export function loadEnv(): void {
|
||||
if (getMode() === 'local') {
|
||||
dotenv.config({ path: '.env', quiet: true });
|
||||
} else {
|
||||
resolveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build `-e KEY=VALUE` flags for docker run, only for set variables.
|
||||
*/
|
||||
export function buildEnvFlags(): string[] {
|
||||
const flags: string[] = ['-e', 'TEMPORAL_ADDRESS=shannon-temporal:7233'];
|
||||
|
||||
for (const key of FORWARD_VARS) {
|
||||
const value = process.env[key];
|
||||
if (value) {
|
||||
flags.push('-e', `${key}=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
interface CredentialValidation {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
mode: 'api-key' | 'oauth' | 'custom-base-url' | 'bedrock' | 'vertex' | 'router';
|
||||
}
|
||||
|
||||
/** Check if router credentials are present in the environment. */
|
||||
export function isRouterConfigured(): boolean {
|
||||
return !!(process.env.ROUTER_DEFAULT && (process.env.OPENAI_API_KEY || process.env.OPENROUTER_API_KEY));
|
||||
}
|
||||
|
||||
/** Check if a custom Anthropic-compatible base URL is configured. */
|
||||
function isCustomBaseUrlConfigured(): boolean {
|
||||
return !!(process.env.ANTHROPIC_BASE_URL && process.env.ANTHROPIC_AUTH_TOKEN);
|
||||
}
|
||||
|
||||
/** Detect which providers are configured via environment variables. */
|
||||
function detectProviders(): string[] {
|
||||
const providers: string[] = [];
|
||||
if (process.env.ANTHROPIC_API_KEY) providers.push('Anthropic API key');
|
||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) providers.push('Anthropic OAuth');
|
||||
if (isCustomBaseUrlConfigured()) providers.push('Custom Base URL');
|
||||
if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') providers.push('AWS Bedrock');
|
||||
if (process.env.CLAUDE_CODE_USE_VERTEX === '1') providers.push('Google Vertex');
|
||||
if (isRouterConfigured()) providers.push('Router');
|
||||
return providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that exactly one authentication method is configured.
|
||||
*/
|
||||
export function validateCredentials(): CredentialValidation {
|
||||
// Reject multiple providers
|
||||
const providers = detectProviders();
|
||||
if (providers.length > 1) {
|
||||
return {
|
||||
valid: false,
|
||||
mode: 'api-key',
|
||||
error: `Multiple providers detected: ${providers.join(', ')}. Only one provider can be active at a time.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.ANTHROPIC_API_KEY) {
|
||||
return { valid: true, mode: 'api-key' };
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
return { valid: true, mode: 'oauth' };
|
||||
}
|
||||
if (isCustomBaseUrlConfigured()) {
|
||||
// Set auth token as API key so the SDK can initialize
|
||||
process.env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_AUTH_TOKEN;
|
||||
return { valid: true, mode: 'custom-base-url' };
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') {
|
||||
const missing: string[] = [];
|
||||
if (!process.env.AWS_REGION) missing.push('AWS_REGION');
|
||||
if (!process.env.AWS_BEARER_TOKEN_BEDROCK) missing.push('AWS_BEARER_TOKEN_BEDROCK');
|
||||
if (!process.env.ANTHROPIC_SMALL_MODEL) missing.push('ANTHROPIC_SMALL_MODEL');
|
||||
if (!process.env.ANTHROPIC_MEDIUM_MODEL) missing.push('ANTHROPIC_MEDIUM_MODEL');
|
||||
if (!process.env.ANTHROPIC_LARGE_MODEL) missing.push('ANTHROPIC_LARGE_MODEL');
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
valid: false,
|
||||
mode: 'bedrock',
|
||||
error: `Bedrock mode requires: ${missing.join(', ')}`,
|
||||
};
|
||||
}
|
||||
return { valid: true, mode: 'bedrock' };
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_USE_VERTEX === '1') {
|
||||
const missing: string[] = [];
|
||||
if (!process.env.CLOUD_ML_REGION) missing.push('CLOUD_ML_REGION');
|
||||
if (!process.env.ANTHROPIC_VERTEX_PROJECT_ID) missing.push('ANTHROPIC_VERTEX_PROJECT_ID');
|
||||
if (!process.env.ANTHROPIC_SMALL_MODEL) missing.push('ANTHROPIC_SMALL_MODEL');
|
||||
if (!process.env.ANTHROPIC_MEDIUM_MODEL) missing.push('ANTHROPIC_MEDIUM_MODEL');
|
||||
if (!process.env.ANTHROPIC_LARGE_MODEL) missing.push('ANTHROPIC_LARGE_MODEL');
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
valid: false,
|
||||
mode: 'vertex',
|
||||
error: `Vertex AI mode requires: ${missing.join(', ')}`,
|
||||
};
|
||||
}
|
||||
if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
||||
return {
|
||||
valid: false,
|
||||
mode: 'vertex',
|
||||
error: 'Vertex AI mode requires GOOGLE_APPLICATION_CREDENTIALS',
|
||||
};
|
||||
}
|
||||
return { valid: true, mode: 'vertex' };
|
||||
}
|
||||
if (isRouterConfigured()) {
|
||||
// Set a placeholder so the worker doesn't reject the missing key
|
||||
process.env.ANTHROPIC_API_KEY = 'router-mode';
|
||||
return { valid: true, mode: 'router' };
|
||||
}
|
||||
|
||||
const hint =
|
||||
getMode() === 'local'
|
||||
? `No credentials found. Set ANTHROPIC_API_KEY in .env or export it.`
|
||||
: `Authentication not configured. Export variables or run 'npx @keygraph/shannon setup'.`;
|
||||
return {
|
||||
valid: false,
|
||||
mode: 'api-key',
|
||||
error: hint,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Shannon state directory management.
|
||||
*
|
||||
* Local mode (cloned repo): uses ./workspaces/, ./credentials/
|
||||
* NPX mode: uses ~/.shannon/workspaces/, ~/.shannon/
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { getMode } from './mode.js';
|
||||
|
||||
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
|
||||
|
||||
export function getConfigFile(): string {
|
||||
return path.join(SHANNON_HOME, 'config.toml');
|
||||
}
|
||||
|
||||
export function getWorkspacesDir(): string {
|
||||
return getMode() === 'local' ? path.resolve('workspaces') : path.join(SHANNON_HOME, 'workspaces');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Vertex credentials file path.
|
||||
*
|
||||
* Checks GOOGLE_APPLICATION_CREDENTIALS env var first (may be set by TOML resolver),
|
||||
* then falls back to mode-appropriate default location.
|
||||
*/
|
||||
export function getCredentialsPath(): string {
|
||||
const envPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
if (envPath && fs.existsSync(envPath)) return path.resolve(envPath);
|
||||
|
||||
if (getMode() === 'local') {
|
||||
return path.resolve('credentials', 'google-sa-key.json');
|
||||
}
|
||||
|
||||
return path.join(SHANNON_HOME, 'google-sa-key.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize state directories.
|
||||
* Local mode: creates ./workspaces/ and ./credentials/
|
||||
* NPX mode: creates ~/.shannon/workspaces/
|
||||
*/
|
||||
export function initHome(): void {
|
||||
if (getMode() === 'local') {
|
||||
fs.mkdirSync(path.resolve('workspaces'), { recursive: true });
|
||||
fs.mkdirSync(path.resolve('credentials'), { recursive: true });
|
||||
} else {
|
||||
fs.mkdirSync(path.join(SHANNON_HOME, 'workspaces'), { recursive: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Shannon CLI — AI Penetration Testing Framework
|
||||
*
|
||||
* Unified CLI supporting two modes:
|
||||
* Local mode: Run from cloned repo — builds locally, mounts prompts, uses ./workspaces/
|
||||
* NPX mode: Run via npx — pulls from Docker Hub, uses ~/.shannon/
|
||||
*
|
||||
* Mode is auto-detected based on presence of Dockerfile + docker-compose.yml + prompts/
|
||||
* 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';
|
||||
import { start } from './commands/start.js';
|
||||
import { status } from './commands/status.js';
|
||||
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));
|
||||
|
||||
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';
|
||||
|
||||
console.log(`
|
||||
Shannon - AI Penetration Testing Framework
|
||||
|
||||
Usage:${
|
||||
mode === 'local'
|
||||
? ''
|
||||
: `
|
||||
${prefix} setup Configure credentials`
|
||||
}
|
||||
${prefix} start --url <url> --repo <path> [options] Start a pentest scan
|
||||
${prefix} stop [--clean] Stop all containers
|
||||
${prefix} workspaces List all workspaces
|
||||
${prefix} logs <workspace> Tail workflow log
|
||||
${prefix} status Show running workers${
|
||||
mode === 'local'
|
||||
? `
|
||||
${prefix} build [--no-cache] Build worker image`
|
||||
: `
|
||||
${prefix} uninstall Remove ~/.shannon/ and all data`
|
||||
}
|
||||
${prefix} info Show splash screen
|
||||
${prefix} help Show this help
|
||||
|
||||
Options for 'start':
|
||||
-u, --url <url> Target URL (required)
|
||||
-r, --repo <path> Repository path${mode === 'local' ? ' or bare name' : ''} (required)
|
||||
-c, --config <path> Configuration file (YAML)
|
||||
-o, --output <path> Copy deliverables to this directory after run
|
||||
-w, --workspace <name> Named workspace (auto-resumes if exists)
|
||||
--pipeline-testing Use minimal prompts for fast testing
|
||||
--router Route requests through claude-code-router
|
||||
|
||||
Examples:
|
||||
${prefix} start -u https://example.com -r ${mode === 'local' ? 'my-repo' : './my-repo'}
|
||||
${prefix} start -u https://example.com -r /path/to/repo -c config.yaml -w q1-audit
|
||||
${prefix} logs q1-audit
|
||||
${prefix} stop --clean
|
||||
${
|
||||
mode === 'local'
|
||||
? `
|
||||
State directory: ./workspaces/`
|
||||
: `
|
||||
State directory: ~/.shannon/`
|
||||
}
|
||||
Monitor workflows at http://localhost:8233
|
||||
`);
|
||||
}
|
||||
|
||||
interface ParsedStartArgs {
|
||||
url: string;
|
||||
repo: string;
|
||||
config?: string;
|
||||
workspace?: string;
|
||||
output?: string;
|
||||
pipelineTesting: boolean;
|
||||
router: boolean;
|
||||
}
|
||||
|
||||
function parseStartArgs(argv: string[]): ParsedStartArgs {
|
||||
let url = '';
|
||||
let repo = '';
|
||||
let config: string | undefined;
|
||||
let workspace: string | undefined;
|
||||
let output: string | undefined;
|
||||
let pipelineTesting = false;
|
||||
let router = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
const next = argv[i + 1];
|
||||
|
||||
switch (arg) {
|
||||
case '-u':
|
||||
case '--url':
|
||||
if (next && !next.startsWith('-')) {
|
||||
url = next;
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case '-r':
|
||||
case '--repo':
|
||||
if (next && !next.startsWith('-')) {
|
||||
repo = next;
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case '-c':
|
||||
case '--config':
|
||||
if (next && !next.startsWith('-')) {
|
||||
config = next;
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case '-w':
|
||||
case '--workspace':
|
||||
if (next && !next.startsWith('-')) {
|
||||
workspace = next;
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case '-o':
|
||||
case '--output':
|
||||
if (next && !next.startsWith('-')) {
|
||||
output = next;
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case '--pipeline-testing':
|
||||
pipelineTesting = true;
|
||||
break;
|
||||
case '--router':
|
||||
router = true;
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown option: ${arg}`);
|
||||
console.error(`Run "${getMode() === 'local' ? './shannon' : 'npx @keygraph/shannon'} help" for usage`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!url || !repo) {
|
||||
console.error('ERROR: --url and --repo are required');
|
||||
console.error(`Usage: ${getMode() === 'local' ? './shannon' : 'npx @keygraph/shannon'} start -u <url> -r <path>`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
repo,
|
||||
pipelineTesting,
|
||||
router,
|
||||
...(config && { config }),
|
||||
...(workspace && { workspace }),
|
||||
...(output && { output }),
|
||||
};
|
||||
}
|
||||
|
||||
// === Main Dispatch ===
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
switch (command) {
|
||||
case 'start': {
|
||||
const parsed = parseStartArgs(args.slice(1));
|
||||
await start({ ...parsed, version: getVersion() });
|
||||
break;
|
||||
}
|
||||
case 'stop':
|
||||
stop(args.includes('--clean'));
|
||||
break;
|
||||
case 'logs': {
|
||||
const workspaceId = args[1];
|
||||
if (!workspaceId) {
|
||||
console.error('ERROR: Workspace ID is required');
|
||||
console.error(`Usage: ${getMode() === 'local' ? './shannon' : 'npx @keygraph/shannon'} logs <workspace>`);
|
||||
process.exit(1);
|
||||
}
|
||||
logs(workspaceId);
|
||||
break;
|
||||
}
|
||||
case 'workspaces':
|
||||
workspaces(getVersion());
|
||||
break;
|
||||
case 'status':
|
||||
status();
|
||||
break;
|
||||
case 'setup':
|
||||
if (getMode() === 'local') {
|
||||
console.error('ERROR: setup is only available in npx mode. In local mode, use .env');
|
||||
process.exit(1);
|
||||
}
|
||||
setup();
|
||||
break;
|
||||
case 'build':
|
||||
build(args.includes('--no-cache'));
|
||||
break;
|
||||
case 'uninstall':
|
||||
if (getMode() === 'local') {
|
||||
console.error('ERROR: uninstall is only available in npx mode.');
|
||||
process.exit(1);
|
||||
}
|
||||
uninstall();
|
||||
break;
|
||||
case 'info':
|
||||
displaySplash(getMode() === 'local' ? undefined : getVersion());
|
||||
break;
|
||||
case 'help':
|
||||
case '--help':
|
||||
case '-h':
|
||||
case undefined:
|
||||
showHelp();
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown command: ${command}`);
|
||||
showHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Runtime mode detection — local (build from source) vs npx (Docker Hub).
|
||||
*
|
||||
* The root `./shannon` entry point sets SHANNON_LOCAL=1 before importing.
|
||||
* When run via npx, `cli/dist/index.js` is executed directly without it.
|
||||
*/
|
||||
|
||||
export type Mode = 'local' | 'npx';
|
||||
|
||||
let cachedMode: Mode | undefined;
|
||||
|
||||
export function getMode(): Mode {
|
||||
if (cachedMode !== undefined) return cachedMode;
|
||||
|
||||
cachedMode = process.env.SHANNON_LOCAL === '1' ? 'local' : 'npx';
|
||||
return cachedMode;
|
||||
}
|
||||
|
||||
export function setMode(mode: Mode): void {
|
||||
cachedMode = mode;
|
||||
}
|
||||
|
||||
export function isLocal(): boolean {
|
||||
return getMode() === 'local';
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Path resolution for --repo and --config arguments.
|
||||
*
|
||||
* Local mode supports bare repo names (e.g. "my-repo" → ./repos/my-repo).
|
||||
* Both modes resolve relative paths against CWD.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isLocal } from './mode.js';
|
||||
|
||||
export interface MountPair {
|
||||
hostPath: string;
|
||||
containerPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve --repo to absolute path and container mount.
|
||||
* Dev mode: bare names (no / or . prefix) check ./repos/<name> first.
|
||||
*/
|
||||
export function resolveRepo(repoArg: string): MountPair {
|
||||
let hostPath: string;
|
||||
|
||||
if (isLocal() && !repoArg.startsWith('/') && !repoArg.startsWith('.')) {
|
||||
// Bare name — check ./repos/<name> for backward compatibility
|
||||
const barePath = path.resolve('repos', repoArg);
|
||||
if (fs.existsSync(barePath)) {
|
||||
hostPath = barePath;
|
||||
} else {
|
||||
console.error(`ERROR: Repository not found at ./repos/${repoArg}`);
|
||||
console.error('');
|
||||
console.error('Place your target repository under the ./repos/ directory,');
|
||||
console.error('or pass an absolute/relative path: -r /path/to/repo');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
hostPath = path.resolve(repoArg);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(hostPath)) {
|
||||
console.error(`ERROR: Repository not found: ${hostPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.statSync(hostPath).isDirectory()) {
|
||||
console.error(`ERROR: Not a directory: ${hostPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const basename = path.basename(hostPath);
|
||||
return {
|
||||
hostPath,
|
||||
containerPath: `/repos/${basename}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve --config to absolute path and container mount.
|
||||
*/
|
||||
export function resolveConfig(configArg: string): MountPair {
|
||||
const hostPath = path.resolve(configArg);
|
||||
|
||||
if (!fs.existsSync(hostPath)) {
|
||||
console.error(`ERROR: Config file not found: ${hostPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.statSync(hostPath).isFile()) {
|
||||
console.error(`ERROR: Not a file: ${hostPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const basename = path.basename(hostPath);
|
||||
return {
|
||||
hostPath,
|
||||
containerPath: `/app/configs/${basename}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the deliverables directory exists and is writable by the container user.
|
||||
*/
|
||||
export function ensureDeliverables(repoHostPath: string): void {
|
||||
const deliverables = path.join(repoHostPath, 'deliverables');
|
||||
fs.mkdirSync(deliverables, { recursive: true });
|
||||
fs.chmodSync(deliverables, 0o777);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Splash screen display — pure terminal output, no npm dependencies.
|
||||
*/
|
||||
|
||||
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 B = `${CYAN}\u2551${RESET}`;
|
||||
const S67 = ' '.repeat(67);
|
||||
const HR = '\u2550'.repeat(67);
|
||||
|
||||
const lines = [
|
||||
'',
|
||||
` ${CYAN}\u2554${HR}\u2557${RESET}`,
|
||||
` ${B}${S67}${B}`,
|
||||
` ${B} ${GOLD}\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2557${RESET} ${B}`,
|
||||
` ${B} ${GOLD}\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551${RESET} ${B}`,
|
||||
` ${B} ${GOLD}\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551${RESET} ${B}`,
|
||||
` ${B} ${GOLD}\u255A\u2550\u2550\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551${RESET} ${B}`,
|
||||
` ${B} ${GOLD}\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551${RESET} ${B}`,
|
||||
` ${B} ${GOLD}\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D${RESET} ${B}`,
|
||||
` ${B}${S67}${B}`,
|
||||
` ${B} ${CYAN}\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557${RESET} ${B}`,
|
||||
` ${B} ${CYAN}\u2551${RESET} ${WHITE}AI Penetration Testing Framework${RESET} ${CYAN}\u2551${RESET} ${B}`,
|
||||
` ${B} ${CYAN}\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D${RESET} ${B}`,
|
||||
` ${B}${S67}${B}`,
|
||||
];
|
||||
|
||||
if (version) {
|
||||
const verStr = `v${version}`;
|
||||
const verPadLeft = Math.floor((67 - verStr.length) / 2);
|
||||
const verPadRight = 67 - verStr.length - verPadLeft;
|
||||
lines.push(` ${B}${' '.repeat(verPadLeft)}${GRAY}${verStr}${RESET}${' '.repeat(verPadRight)}${B}`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
` ${B}${S67}${B}`,
|
||||
` ${B} ${YELLOW}\uD83D\uDD10 DEFENSIVE SECURITY ONLY \uD83D\uDD10${RESET} ${B}`,
|
||||
` ${B}${S67}${B}`,
|
||||
` ${CYAN}\u255A${HR}\u255D${RESET}`,
|
||||
'',
|
||||
);
|
||||
|
||||
console.log(lines.join('\n'));
|
||||
}
|
||||
Reference in New Issue
Block a user