import { readFileSync } from "node:fs"; import { join, win32 } from "node:path"; export const CONFIG_FILE_NAME = "pwsh-native.json"; const EXECUTION_POLICIES = [ "AllSigned", "Bypass", "Default", "RemoteSigned", "Restricted", "Undefined", "Unrestricted", ] as const; export type ExecutionPolicy = (typeof EXECUTION_POLICIES)[number]; export interface PwshNativeConfig { executable: "auto" | string; loadProfile: boolean; executionPolicy: ExecutionPolicy | null; replaceUserBash: boolean; preserveDiscoveryTools: boolean; strictMode: boolean; pythonUtf8: boolean; pythonUnbuffered: boolean; elevationGuidance: boolean; } export const DEFAULT_CONFIG: Readonly = Object.freeze({ executable: "auto", loadProfile: false, executionPolicy: null, replaceUserBash: true, preserveDiscoveryTools: true, strictMode: false, pythonUtf8: true, pythonUnbuffered: true, elevationGuidance: false, }); const CONFIG_KEYS = new Set(Object.keys(DEFAULT_CONFIG) as Array); const ENV_FIELDS = { PI_PWSH_NATIVE_EXECUTABLE: "executable", PI_PWSH_NATIVE_LOAD_PROFILE: "loadProfile", PI_PWSH_NATIVE_EXECUTION_POLICY: "executionPolicy", PI_PWSH_NATIVE_REPLACE_USER_BASH: "replaceUserBash", PI_PWSH_NATIVE_PRESERVE_DISCOVERY_TOOLS: "preserveDiscoveryTools", PI_PWSH_NATIVE_STRICT: "strictMode", PI_PWSH_NATIVE_PYTHON_UTF8: "pythonUtf8", PI_PWSH_NATIVE_PYTHON_UNBUFFERED: "pythonUnbuffered", PI_PWSH_NATIVE_ELEVATION_GUIDANCE: "elevationGuidance", } as const; export class ConfigError extends Error { constructor(message: string) { super(message); this.name = "ConfigError"; } } function parseBoolean(value: unknown, field: string, source: string): boolean { if (typeof value === "boolean") return value; if (typeof value === "string") { switch (value.trim().toLowerCase()) { case "1": case "true": case "yes": case "on": return true; case "0": case "false": case "no": case "off": return false; } } throw new ConfigError(`${source}: ${field} must be a boolean (true/false, 1/0, yes/no, or on/off)`); } function parseExecutable(value: unknown, source: string): "auto" | string { if (typeof value !== "string" || !value.trim()) { throw new ConfigError(`${source}: executable must be \"auto\" or an absolute path to pwsh.exe`); } const executable = value.trim(); if (executable.toLowerCase() === "auto") return "auto"; if (!win32.isAbsolute(executable)) { throw new ConfigError(`${source}: executable must be an absolute Windows path, received ${JSON.stringify(executable)}`); } return executable; } function parseExecutionPolicy(value: unknown, source: string): ExecutionPolicy | null { if (value === null || value === undefined || value === "") return null; if (typeof value !== "string") { throw new ConfigError(`${source}: executionPolicy must be null or a PowerShell execution policy name`); } const policy = EXECUTION_POLICIES.find((candidate) => candidate.toLowerCase() === value.trim().toLowerCase()); if (!policy) { throw new ConfigError( `${source}: unsupported executionPolicy ${JSON.stringify(value)}; expected one of ${EXECUTION_POLICIES.join(", ")}`, ); } return policy; } function parseConfigObject(value: unknown, source: string): Partial { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new ConfigError(`${source}: configuration must be a JSON object`); } const input = value as Record; const unknown = Object.keys(input).filter((key) => !CONFIG_KEYS.has(key as keyof PwshNativeConfig)); if (unknown.length > 0) { throw new ConfigError(`${source}: unknown configuration field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}`); } const parsed: Partial = {}; if ("executable" in input) parsed.executable = parseExecutable(input.executable, source); if ("executionPolicy" in input) parsed.executionPolicy = parseExecutionPolicy(input.executionPolicy, source); for (const key of [ "loadProfile", "replaceUserBash", "preserveDiscoveryTools", "strictMode", "pythonUtf8", "pythonUnbuffered", "elevationGuidance", ] as const) { if (key in input) parsed[key] = parseBoolean(input[key], key, source); } return parsed; } function parseEnvironment(env: NodeJS.ProcessEnv): Partial { const parsed: Partial = {}; for (const [environmentName, field] of Object.entries(ENV_FIELDS) as Array< [keyof typeof ENV_FIELDS, (typeof ENV_FIELDS)[keyof typeof ENV_FIELDS]] >) { const value = env[environmentName]; if (value === undefined) continue; const source = `environment variable ${environmentName}`; if (field === "executable") parsed.executable = parseExecutable(value, source); else if (field === "executionPolicy") parsed.executionPolicy = parseExecutionPolicy(value, source); else parsed[field] = parseBoolean(value, field, source); } return parsed; } export interface LoadConfigOptions { agentDir: string; env?: NodeJS.ProcessEnv; readFile?: typeof readFileSync; } export interface LoadedConfig { config: PwshNativeConfig; path: string; } export function loadConfig({ agentDir, env = process.env, readFile = readFileSync }: LoadConfigOptions): LoadedConfig { const path = env.PI_PWSH_NATIVE_CONFIG?.trim() || join(agentDir, CONFIG_FILE_NAME); let fileConfig: Partial = {}; try { const text = readFile(path, "utf8"); let json: unknown; try { json = JSON.parse(text); } catch (error) { throw new ConfigError(`${path}: invalid JSON: ${error instanceof Error ? error.message : String(error)}`); } fileConfig = parseConfigObject(json, path); } catch (error) { if (error instanceof ConfigError) throw error; if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw new ConfigError(`${path}: unable to read configuration: ${error instanceof Error ? error.message : String(error)}`); } } return { path, config: { ...DEFAULT_CONFIG, ...fileConfig, ...parseEnvironment(env), }, }; }