mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-08-25 04:32:35 +02:00
* refactor(cli): list workspaces natively instead of via the worker image * feat(cli): preflight that Docker is installed and running * feat(cli): stop scans by workspace or --all, terminating their Temporal workflows * fix(worker): abort the running agent on cancellation so Temporal cancel takes effect * refactor(cli): split destructive teardown out of stop into a reset command * refactor(cli): centralise flag parsing and confirmation across commands * fix(cli): pass provider credentials to docker by name to keep secrets out of argv * feat(cli): add per-command help via <command> --help/-h and help <command> * feat(cli): replace raw docker output with clack spinners for infra and scan teardown * fix(cli): verify scan stop by re-querying container and workflow state instead of assuming success * fix(cli): resolve running state before prompting on stop and report no-op stops honestly * refactor(cli): show splash first and drive start with one spinner resolving to a clean line * fix(cli): validate --url up front so a bad value fails cleanly instead of a late crash * refactor(cli): centralize error reporting with fail() for expected errors and a crash handler that logs the stack and links the issue tracker * feat(cli): add --json/--plain machine-readable output to workspaces and status * refactor(cli): remove the workspaces command * refactor(cli): remove the status command * feat(cli): add 'progress <workspace>' — live scan progress from Temporal * fix(cli): mark metric-less agents as skipped in progress, not done * feat(cli): animate running agents in progress with a clack-style spinner * feat(cli): rename progress->status, reveal agents as they run, show live per-agent elapsed * fix(cli): mark passed-over phases as skipped live, not pending * style(cli): rename status footer 'Wall-clock' to 'Time Taken', drop the parenthetical * style(cli): drop '(sum of agents)' from status total cost line * style(cli): green filled circle for completed, Shannon gold for running * style(cli): use Shannon gold in place of green in status * feat(cli): suggest closest command or flag on typo * refactor(cli): single-source start help and drop ./repos bare-name shortcut * feat(cli): name providers and fix in multi-provider credential error * feat(cli): support --flag=value syntax and expand leading ~ in paths * refactor(cli): centralize ANSI color codes in colors.ts * feat(cli): add scans command listing completed scans with cost and duration * fix(cli): keep stdout clean off-TTY for logs and start * feat(cli): add repo link to top-level help * feat(worker): record auth-validation metrics and register resume attempts early * refactor(cli): share resume-aware workflow-id resolution and surface root-cause failures * feat(cli): add status --json, auth phase, dashboard link, and stable live redraw * refactor(cli): drop cost from status and scans output * feat(worker): surface both PDF and markdown report at run root * refactor(cli): normalize error/warning prefixing through fail and warn * feat(cli): add version --json for machine-readable output * refactor(cli): rename start --debug to --keep-container * refactor(cli): point start's progress hint at status instead of the Temporal dashboard * refactor(cli): centralize the mode-aware command prefix * refactor(cli): trim start and logs output to durable facts off-TTY * feat(cli): require typed confirmation for reset instead of --yes reset permanently wipes all Temporal data and volumes — a severe, irreversible action. Replace its default y/N confirm (bypassable with --yes) with a typed-word confirmation that has no bypass, so the wipe can only be triggered by a deliberate interactive answer. * feat(cli): surface logs and status hints after start on a TTY * feat(cli): exit 2 on usage errors, distinct from operational failures * feat(cli): add start --follow to stream logs and exit on scan outcome * refactor(cli): redesign splash with sunset-gradient wordmark and truecolor * refactor(cli): remove the uninstall command * docs: sync CLI docs with removed uninstall/workspaces, new scans and --follow * docs: fix reset confirmation — typed confirm, not --yes/-y * style(cli): restructure status footer with divider, aligned Logs/Temporal rows * feat(cli): show splash in the status command * fix(worker): validate auth-state shape, not entry count * docs: correct reset confirmation and add markdown report to run-root docs
200 lines
7.1 KiB
TypeScript
200 lines
7.1 KiB
TypeScript
/**
|
|
* Environment variable loading and credential validation.
|
|
*
|
|
* Local mode: loads ./.env via dotenv.
|
|
* NPX mode: fills gaps from ~/.shannon/config.toml (no .env).
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import dotenv from 'dotenv';
|
|
import { resolveConfig } from './config/resolver.js';
|
|
import { getMode } from './mode.js';
|
|
import {
|
|
CURATED_PROVIDERS,
|
|
type CuratedProviderId,
|
|
GENERIC_API_KEY_ENV,
|
|
isCuratedProvider,
|
|
PROVIDER_API_KEY_ENV,
|
|
PROVIDER_CREDENTIAL_HINT,
|
|
PROVIDER_EXTRA_ENV,
|
|
resolveModelSpec,
|
|
} from './model-spec.js';
|
|
|
|
/**
|
|
* Variables forwarded to every worker container regardless of provider. Each is
|
|
* forwarded only when set, so an unused one never appears in the container.
|
|
* SHANNON_AI_API_KEY rides along because it is provider-neutral.
|
|
*/
|
|
const COMMON_FORWARD_VARS = [
|
|
'SHANNON_AI_MODEL',
|
|
'SHANNON_AI_BASE_URL',
|
|
'SHANNON_AI_OPENAI_FORMAT',
|
|
GENERIC_API_KEY_ENV,
|
|
] as const;
|
|
|
|
/**
|
|
* Credential variables for one provider. Only the selected provider's entries are
|
|
* forwarded, so a key for an unused provider never enters the scan container. An
|
|
* uncurated provider has none — it relies on the common SHANNON_AI_API_KEY.
|
|
*/
|
|
function providerForwardVars(providerId: string): readonly string[] {
|
|
if (!isCuratedProvider(providerId)) return [];
|
|
return [...PROVIDER_API_KEY_ENV[providerId], ...PROVIDER_EXTRA_ENV[providerId]];
|
|
}
|
|
|
|
/** Parse a user-facing boolean env var: `1`/`true` (any case) true, `0`/`false`/empty false, else the default. */
|
|
export function envBool(name: string, defaultValue: boolean): boolean {
|
|
const raw = process.env[name]?.trim().toLowerCase();
|
|
if (raw === undefined || raw === '') return defaultValue;
|
|
if (raw === '1' || raw === 'true') return true;
|
|
if (raw === '0' || raw === 'false') return false;
|
|
return defaultValue;
|
|
}
|
|
|
|
const USE_PI_AUTH_ENV = 'SHANNON_USE_PI_AUTH';
|
|
|
|
/** Where the host's auth.json is mounted: pi's standard location (worker HOME is /tmp), read natively. */
|
|
export const PI_AUTH_CONTAINER_PATH = '/tmp/.pi/agent/auth.json';
|
|
|
|
/** Host path to pi's credential file. */
|
|
export function resolveHostPiAuthPath(): string {
|
|
return path.join(os.homedir(), '.pi', 'agent', 'auth.json');
|
|
}
|
|
|
|
export function piAuthFlagEnabled(): boolean {
|
|
return envBool(USE_PI_AUTH_ENV, false);
|
|
}
|
|
|
|
/** Opted into pi auth via the flag, and the auth file exists to mount. */
|
|
export function shouldUsePiAuth(): boolean {
|
|
return piAuthFlagEnabled() && fs.existsSync(resolveHostPiAuthPath());
|
|
}
|
|
|
|
/**
|
|
* 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` flags for docker run. Forwards the common vars plus only the
|
|
* selected provider's credentials, passed by name (`-e KEY`) so secret values
|
|
* stay out of the `docker run` argv; docker inherits them from this process's env.
|
|
*/
|
|
export function buildEnvFlags(): string[] {
|
|
const flags: string[] = ['-e', 'TEMPORAL_ADDRESS=shannon-temporal:7233'];
|
|
|
|
const spec = resolveModelSpec();
|
|
const providerVars = typeof spec === 'string' ? [] : providerForwardVars(spec.providerId);
|
|
|
|
for (const key of [...COMMON_FORWARD_VARS, ...providerVars]) {
|
|
if (process.env[key]) {
|
|
flags.push('-e', key);
|
|
}
|
|
}
|
|
|
|
return flags;
|
|
}
|
|
|
|
interface CredentialValidation {
|
|
valid: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
/** Whether a curated provider has its own named credential set (API key plus any extra var). */
|
|
function hasNamedCredential(providerId: CuratedProviderId): boolean {
|
|
const apiKeys = PROVIDER_API_KEY_ENV[providerId];
|
|
if (!apiKeys.some((name) => Boolean(process.env[name]))) return false;
|
|
return PROVIDER_EXTRA_ENV[providerId].every((name) => Boolean(process.env[name]));
|
|
}
|
|
|
|
/** Whether the selected provider has a credential. Bedrock needs its AWS_ vars; the generic key never stands in for it. */
|
|
function hasCredential(providerId: string): boolean {
|
|
if (providerId === 'amazon-bedrock') return hasNamedCredential('amazon-bedrock');
|
|
if (isCuratedProvider(providerId) && hasNamedCredential(providerId)) return true;
|
|
return Boolean(process.env[GENERIC_API_KEY_ENV]);
|
|
}
|
|
|
|
/** Curated providers with a named credential. The generic key is neutral, so it never counts toward ambiguity. */
|
|
function configuredProviders(): CuratedProviderId[] {
|
|
return CURATED_PROVIDERS.filter((providerId) => hasNamedCredential(providerId));
|
|
}
|
|
|
|
/**
|
|
* Validate that the model selection parses and its provider has a credential.
|
|
* Runs before any Docker work so mistakes fail immediately.
|
|
*/
|
|
export function validateCredentials(): CredentialValidation {
|
|
// 1. Model selection must parse into a provider and model id
|
|
const spec = resolveModelSpec();
|
|
if (typeof spec === 'string') {
|
|
return { valid: false, error: spec };
|
|
}
|
|
|
|
// Pi-auth: skip the API-key checks, but the host auth file must exist to mount.
|
|
if (piAuthFlagEnabled()) {
|
|
const authPath = resolveHostPiAuthPath();
|
|
if (!fs.existsSync(authPath)) {
|
|
return {
|
|
valid: false,
|
|
error: `${USE_PI_AUTH_ENV} is set but no pi credentials were found at ${authPath}. Authenticate with pi first.`,
|
|
};
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
// 2. The selected provider must have a credential
|
|
if (!hasCredential(spec.providerId)) {
|
|
const requirement = isCuratedProvider(spec.providerId)
|
|
? PROVIDER_CREDENTIAL_HINT[spec.providerId]
|
|
: GENERIC_API_KEY_ENV;
|
|
const hint =
|
|
getMode() === 'local'
|
|
? `Set ${requirement} in .env or export it.`
|
|
: `Export the variables or run 'npx @keygraph/shannon setup'.`;
|
|
return {
|
|
valid: false,
|
|
error: `No credentials found for provider "${spec.providerId}". ${hint}`,
|
|
};
|
|
}
|
|
|
|
// 3. Exactly one provider may be configured. Several complete credentials make
|
|
// the scan's provider depend on SHANNON_AI_MODEL alone, which is too easy to
|
|
// misread as "both are in play" and too easy to redirect by editing one line.
|
|
const configured = configuredProviders();
|
|
if (configured.length > 1) {
|
|
const setKeys = (id: CuratedProviderId): string[] =>
|
|
PROVIDER_API_KEY_ENV[id].filter((name) => Boolean(process.env[name]));
|
|
const list = configured.map((id) => `${id} (${setKeys(id).join(', ')})`).join(' and ');
|
|
const others = configured.filter((id) => id !== spec.providerId);
|
|
const extraVars = others.flatMap(setKeys);
|
|
|
|
const dropHint =
|
|
getMode() === 'local'
|
|
? 'remove them from .env or unset them in your shell:'
|
|
: "unset them in your shell, or reconfigure with 'npx @keygraph/shannon setup':";
|
|
|
|
const lines = [`Credentials for more than one provider are set: ${list}.`];
|
|
if (extraVars.length > 0) {
|
|
lines.push(
|
|
`Shannon runs one provider per scan, selected by SHANNON_AI_MODEL ("${spec.providerId}:...").`,
|
|
`Keep ${spec.providerId} and drop the rest — ${dropHint}`,
|
|
` unset ${extraVars.join(' ')}`,
|
|
);
|
|
}
|
|
return { valid: false, error: lines.join('\n') };
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|