singularity-forge/src/env.ts
Mikael Hugo 6e6363da0d feat: migrate src/ core TS files to LogTape structured logging
Migrate 5 non-test TS files in src/ from console.* to LogTape:
- src/env.ts → getLogger('sf.core.env')
- src/resource-loader.ts → getLogger('sf.core.resource-loader')
- src/web/undo-service.ts → getLogger('sf.web.undo-service')
- src/web/cleanup-service.ts → getLogger('sf.web.cleanup-service')
- src/web/auto-dashboard-service.ts → getLogger('sf.web.auto-dashboard-service')

console.error(err) → log.error(msg, {error: err})
console.warn(msg) → log.warn(msg)

All CLI-facing output preserved. typecheck, lint pass.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-05-08 21:01:08 +02:00

290 lines
9.4 KiB
TypeScript

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<typeof sfEnvSchema>;
export type CompleteSfEnv = z.infer<typeof completeSfEnvSchema>;
/**
* 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,
};
}