import { homedir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; import { getLogger } from "./logger.js"; const log = getLogger("sf.core.env"); const optionalNonEmptyString = z.string().trim().min(1).optional(); const booleanOneZero = z .enum(["0", "1"]) .optional() .transform((value) => value === "1"); // Numeric values const positiveInteger = z .string() .transform((v) => parseInt(v, 10)) .pipe(z.number().int().positive()); const optionalPositiveInteger = positiveInteger.optional(); /** * Minimal schema (original, for backward compatibility). * These variables are the only ones originally validated. */ export const sfEnvSchema = z.object({ SF_HOME: optionalNonEmptyString, SF_AGENT_DIR: optionalNonEmptyString, SF_CODING_AGENT_DIR: optionalNonEmptyString, SF_STATE_DIR: optionalNonEmptyString, SF_PROJECT_ID: z .string() .trim() .regex(/^[A-Za-z0-9_-]+$/, { message: "SF_PROJECT_ID must contain only letters, numbers, hyphens, and underscores", }) .optional(), SF_BIN_PATH: optionalNonEmptyString, SF_VERSION: optionalNonEmptyString, SF_WEB_PROJECT_CWD: optionalNonEmptyString, SF_WEB_DAEMON_MODE: booleanOneZero, }); /** * Comprehensive schema: all SF_* environment variables. * * Purpose: Provide type-safe, runtime-validated access to all SF_* environment * variables with clear error messages for misconfiguration. Prevents silent failures * caused by missing or invalid config. * * Design: Partitions variables into logical groups (core, directories, performance, * debug, extensions, recovery, settings). Each variable is documented with its purpose, * valid values, and defaults. Optional variables have sensible defaults where applicable. * * Consumer: loader.ts validates this on startup; modules access config via getCompleteSfEnv(). */ export const completeSfEnvSchema = sfEnvSchema.extend({ // Core paths and metadata (set by loader.ts) SF_PKG_ROOT: optionalNonEmptyString, SF_WORKFLOW_PATH: optionalNonEmptyString, SF_BUNDLED_EXTENSION_PATHS: optionalNonEmptyString, // Directories with defaults SF_WORKSPACE_BASE: optionalNonEmptyString, SF_HISTORY_BASE: optionalNonEmptyString, SF_NOTIFICATIONS_BASE: optionalNonEmptyString, SF_SCHEDULE_FILE: optionalNonEmptyString, SF_RECOVERY_BASE: optionalNonEmptyString, SF_FORENSICS_BASE: optionalNonEmptyString, SF_CLEANUP_BASE: optionalNonEmptyString, SF_EXPORT_BASE: optionalNonEmptyString, SF_CAPTURES_BASE: optionalNonEmptyString, SF_UNDO_BASE: optionalNonEmptyString, SF_SKILL_HEALTH_BASE: optionalNonEmptyString, SF_DOCTOR_BASE: optionalNonEmptyString, SF_SETTINGS_BASE: optionalNonEmptyString, SF_PROJECT_ROOT: optionalNonEmptyString, SF_NODE_BIN: optionalNonEmptyString, // Performance tuning SF_RTK_DISABLED: booleanOneZero, SF_RTK_PATH: optionalNonEmptyString, SF_RTK_REWRITE_TIMEOUT_MS: optionalPositiveInteger, SF_CIRCUIT_BREAKER_OPEN_DURATION_MS: optionalPositiveInteger, SF_CIRCUIT_BREAKER_FAILURE_THRESHOLD: optionalPositiveInteger, SF_CIRCUIT_BREAKER_HALF_OPEN_MAX_ATTEMPTS: optionalPositiveInteger, SF_HEADLESS_PROMPT_TRACE_CHARS: optionalPositiveInteger, // Debug flags SF_QUIET: booleanOneZero, SF_DEBUG: booleanOneZero, SF_DEBUG_EXTENSIONS: booleanOneZero, SF_TRACE_ENABLED: booleanOneZero, SF_HEADLESS: booleanOneZero, SF_HEADLESS_PROMPT_TRACE: booleanOneZero, SF_STARTUP_TIMING: booleanOneZero, SF_SHOW_TOKEN_COST: booleanOneZero, SF_FIRST_RUN_BANNER: booleanOneZero, SF_DISABLE_STARTUP_DOCTOR: booleanOneZero, SF_ENGINE_BYPASS: booleanOneZero, SF_DISABLE_NATIVE_SF_PARSER: booleanOneZero, SF_DISABLE_NATIVE_SF_GIT: booleanOneZero, // Extensions SF_SKILL_MANIFEST_STRICT: booleanOneZero, SF_PERMISSION_LEVEL: z.enum(["full", "restricted", "sandbox"]).optional(), SF_GEMINI_PERMISSION_MODE: z.enum(["ask", "auto", "deny"]).optional(), SF_SESSION_BROWSER_DIR: optionalNonEmptyString, SF_SESSION_BROWSER_CWD: optionalNonEmptyString, SF_FETCH_ALLOWED_URLS: optionalNonEmptyString, SF_ALLOWED_COMMAND_PREFIXES: optionalNonEmptyString, // Recovery and dispatch SF_RECOVERY_DOCTOR_MODULE: optionalNonEmptyString, SF_RECOVERY_FORENSICS_MODULE: optionalNonEmptyString, SF_RECOVERY_SCOPE: z.enum(["unit", "milestone", "global"]).optional(), SF_RECOVERY_SESSION_FILE: optionalNonEmptyString, SF_RECOVERY_ACTIVITY_DIR: optionalNonEmptyString, SF_PARALLEL_WORKER: booleanOneZero, SF_WORKER_MODEL: optionalNonEmptyString, SF_MILESTONE_LOCK: optionalNonEmptyString, SF_SLICE_LOCK: optionalNonEmptyString, SF_WORKTREE: optionalNonEmptyString, SF_CLI_WORKTREE: optionalNonEmptyString, SF_CLI_WORKTREE_BASE: optionalNonEmptyString, SF_CLEANUP_BRANCHES: booleanOneZero, SF_CLEANUP_SNAPSHOTS: booleanOneZero, SF_AUTO_WORKTREE: optionalNonEmptyString, // Settings modules SF_SETTINGS_BUDGET_MODULE: optionalNonEmptyString, SF_SETTINGS_HISTORY_MODULE: optionalNonEmptyString, SF_SETTINGS_METRICS_MODULE: optionalNonEmptyString, SF_SETTINGS_PREFS_MODULE: optionalNonEmptyString, SF_SETTINGS_ROUTER_MODULE: optionalNonEmptyString, SF_WORKSPACE_MODULE: optionalNonEmptyString, SF_SESSION_MANAGER_MODULE: optionalNonEmptyString, // Miscellaneous SF_TRIAGE_SUFFIX: optionalNonEmptyString, SF_DOCTOR_SCOPE: z.enum(["fast", "normal", "deep"]).optional(), SF_EXPORT_FORMAT: z.enum(["json", "csv", "markdown"]).optional(), SF_TARGET_SESSION_NAME: optionalNonEmptyString, SF_TARGET_SESSION_PATH: optionalNonEmptyString, SF_VISUALIZER_BASE: optionalNonEmptyString, SF_RECOVERY_UNIT_ID: optionalNonEmptyString, SF_RECOVERY_UNIT_TYPE: optionalNonEmptyString, }); export type SfEnv = z.infer; export type CompleteSfEnv = z.infer; /** * Parse supported SF_* environment variables into a typed object. * * Purpose: give runtime code a shared contract for SF-specific environment * variables instead of scattering ad hoc `process.env` parsing across entry * points. * * Consumer: root CLI/headless modules and web bridge code that need stable SF * path and mode values. */ export function parseSfEnv(env: NodeJS.ProcessEnv = process.env): SfEnv { return sfEnvSchema.parse(env); } /** * Parse all known SF_* environment variables (comprehensive validation). * * Purpose: Validate complete environment configuration at startup, catching * misconfiguration early with clear error messages. * * Consumer: loader.ts calls this during initialization. * * Error handling: If validation fails, returns safe defaults rather than * throwing (graceful degradation). Critical errors logged to stderr. */ export function parseCompleteSfEnv( env: NodeJS.ProcessEnv = process.env, ): CompleteSfEnv { const result = completeSfEnvSchema.safeParse(env); if (!result.success) { // Log validation errors for debugging (but don't crash) const errors = result.error.issues.slice(0, 5); // First 5 errors const message = errors .map((e: z.ZodIssue) => ` ${e.path.join(".")}: ${e.message}`) .join("\n"); if (process.env.SF_DEBUG) { log.warn( `Environment validation issues (first 5):\n${message}\n` + `Set SF_QUIET=1 to suppress this check.\n`, ); } } return result.data || ({} as CompleteSfEnv); } /** * Return typed SF environment values with path defaults applied. * * Purpose: centralize default path behavior for SF_HOME and the managed agent * directory while still validating user-provided overrides. * * Consumer: app-paths.ts, cli-logs.ts, headless-query.ts, and future env readers. */ export function getSfEnv(env: NodeJS.ProcessEnv = process.env) { const parsed = parseSfEnv(env); const sfHome = parsed.SF_HOME ?? join(homedir(), ".sf"); const agentDir = parsed.SF_AGENT_DIR ?? parsed.SF_CODING_AGENT_DIR ?? join(sfHome, "agent"); return { ...parsed, sfHome, agentDir, }; } /** * Get all known SF_* environment values with defaults applied. * * Purpose: Single source of truth for complete environment configuration * throughout the application. All modules needing environment access should * use this function instead of direct process.env reads. * * Consumer: Extensions, dispatch loop, recovery modules. * * Design: Applies sensible defaults for optional variables. For example, * STATE_DIR defaults to SF_HOME if not set. Relative paths are NOT converted * to absolute (caller responsibility). */ export function getCompleteSfEnv( env: NodeJS.ProcessEnv = process.env, ): CompleteSfEnv & { sfHome: string; agentDir: string; stateDir: string; } { const parsed = parseCompleteSfEnv(env); const sfHome = parsed.SF_HOME ?? join(homedir(), ".sf"); const agentDir = parsed.SF_AGENT_DIR ?? parsed.SF_CODING_AGENT_DIR ?? join(sfHome, "agent"); const stateDir = parsed.SF_STATE_DIR ?? sfHome; return { ...parsed, sfHome, agentDir, stateDir, }; } /** * Get validation summary: which SF_* variables are set vs defaults applied. * * Purpose: For debugging and diagnostics. Shows which variables were explicitly * set vs using built-in defaults. * * Consumer: startup doctor, debug utilities. */ export function getEnvValidationSummary(env: NodeJS.ProcessEnv = process.env): { configured: string[]; defaults: string[]; total: number; } { const configured: string[] = []; const defaults: string[] = []; // List of all known SF_* variables const knownVars = Object.keys(completeSfEnvSchema.shape); for (const varName of knownVars) { if (env[varName] !== undefined) { configured.push(varName); } else { defaults.push(varName); } } return { configured, defaults, total: knownVars.length, }; }