import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { isValidTemplateName } from "../templates/load-template.ts"; import { CHANGE_SOURCES, COMMIT_LANGUAGES, type ChangeSource, type CommitConfig, type CommitLanguage, } from "../types.ts"; import { getGlobalExtensionDir } from "./paths.ts"; const INTERNAL_CONFIG_PATH = fileURLToPath(new URL("../../config/default.json", import.meta.url)); const CONFIG_FILE_NAME = "ai-git-commit.json"; const MAX_INSTRUCTIONS_CHARS = 16_000; /** 判断未知值是否为普通对象。 */ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** 判断错误是否包含 Node.js 文件系统错误码。 */ function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; } /** 读取可选 JSON 配置文件,不存在时返回 undefined。 */ async function readOptionalJsonFile(path: string): Promise { let content: string; try { content = await readFile(path, "utf8"); } catch (error) { if (isNodeError(error) && error.code === "ENOENT") { return undefined; } const detail = error instanceof Error ? error.message : String(error); throw new Error(`Failed to read configuration file ${path}: ${detail}`); } try { return JSON.parse(content) as unknown; } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new Error(`Configuration file ${path} is not valid JSON: ${detail}`); } } /** 读取并解析一个必需的 JSON 配置文件。 */ async function readJsonFile(path: string): Promise { const value = await readOptionalJsonFile(path); if (value === undefined) { throw new Error(`Failed to read configuration file ${path}: file not found.`); } return value; } /** 校验并解析一层配置覆盖。 */ function parseConfigLayer(value: unknown, sourcePath: string): Partial { if (!isRecord(value)) { throw new Error(`The root value in configuration file ${sourcePath} must be a JSON object.`); } const parsed: Partial = {}; if ("prompt" in value) { throw new Error( `Configuration ${sourcePath}: the prompt field has been removed. Move its content into a template file under ~/.pi/agent/ai-git-commit/templates/ and set the template field to that template name.`, ); } if ("template" in value) { if (typeof value.template !== "string" || !isValidTemplateName(value.template)) { throw new Error( `Configuration ${sourcePath}: template must be a template name using letters, digits, dots, hyphens, or underscores, starting with a letter or digit.`, ); } parsed.template = value.template; } if ("instructions" in value) { if ( value.instructions !== null && (typeof value.instructions !== "string" || !value.instructions.trim()) ) { throw new Error( `Configuration ${sourcePath}: instructions must be a non-empty string or null.`, ); } if ( typeof value.instructions === "string" && Array.from(value.instructions).length > MAX_INSTRUCTIONS_CHARS ) { throw new Error( `Configuration ${sourcePath}: instructions must not exceed ${MAX_INSTRUCTIONS_CHARS} characters.`, ); } parsed.instructions = value.instructions as string | null; } if ("changeSource" in value) { if ( typeof value.changeSource !== "string" || !CHANGE_SOURCES.includes(value.changeSource as ChangeSource) ) { throw new Error(`Configuration ${sourcePath}: changeSource must be staged, all, or auto.`); } parsed.changeSource = value.changeSource as ChangeSource; } if ("language" in value) { if ( typeof value.language !== "string" || !COMMIT_LANGUAGES.includes(value.language as CommitLanguage) ) { throw new Error( `Configuration ${sourcePath}: language must be one of ${COMMIT_LANGUAGES.join(", ")}.`, ); } parsed.language = value.language as CommitLanguage; } if ("provider" in value) { if (value.provider !== null && (typeof value.provider !== "string" || !value.provider.trim())) { throw new Error(`Configuration ${sourcePath}: provider must be a non-empty string or null.`); } parsed.provider = value.provider as string | null; } if ("model" in value) { if (value.model !== null && (typeof value.model !== "string" || !value.model.trim())) { throw new Error(`Configuration ${sourcePath}: model must be a non-empty string or null.`); } parsed.model = value.model as string | null; } if ("maxDiffChars" in value) { if ( typeof value.maxDiffChars !== "number" || !Number.isInteger(value.maxDiffChars) || value.maxDiffChars < 1_000 || value.maxDiffChars > 500_000 ) { throw new Error( `Configuration ${sourcePath}: maxDiffChars must be an integer from 1000 to 500000.`, ); } parsed.maxDiffChars = value.maxDiffChars; } if ("maxOutputTokens" in value) { if ( typeof value.maxOutputTokens !== "number" || !Number.isInteger(value.maxOutputTokens) || value.maxOutputTokens < 1 || value.maxOutputTokens > 4_096 ) { throw new Error( `Configuration ${sourcePath}: maxOutputTokens must be an integer from 1 to 4096.`, ); } parsed.maxOutputTokens = value.maxOutputTokens; } if ("commitHistoryCount" in value) { if ( typeof value.commitHistoryCount !== "number" || !Number.isInteger(value.commitHistoryCount) || value.commitHistoryCount < 0 || value.commitHistoryCount > 50 ) { throw new Error( `Configuration ${sourcePath}: commitHistoryCount must be an integer from 0 to 50.`, ); } parsed.commitHistoryCount = value.commitHistoryCount; } if ("signoff" in value) { if (typeof value.signoff !== "boolean") { throw new Error(`Configuration ${sourcePath}: signoff must be a boolean.`); } parsed.signoff = value.signoff; } if ("timeoutMs" in value) { if ( value.timeoutMs !== null && (typeof value.timeoutMs !== "number" || !Number.isInteger(value.timeoutMs) || value.timeoutMs < 1_000 || value.timeoutMs > 600_000) ) { throw new Error( `Configuration ${sourcePath}: timeoutMs must be an integer from 1000 to 600000, or null.`, ); } parsed.timeoutMs = value.timeoutMs as number | null; } return parsed; } /** 将默认配置层校验为完整配置。 */ function requireCompleteConfig(value: Partial, sourcePath: string): CommitConfig { const requiredKeys: Array = [ "template", "instructions", "changeSource", "language", "provider", "model", "maxDiffChars", "maxOutputTokens", "commitHistoryCount", "signoff", "timeoutMs", ]; const missing = requiredKeys.filter((key) => !(key in value)); if (missing.length > 0) { throw new Error( `Internal default configuration ${sourcePath} is missing required fields: ${missing.join(", ")}.`, ); } return value as CommitConfig; } /** 校验 provider 与 model 是否成对配置。 */ function validateModelSelection(config: CommitConfig): void { if ((config.provider === null) !== (config.model === null)) { throw new Error( "provider and model must be configured together; set both to null to use the current session model.", ); } } /** * 读取包内完整默认值,随后依次用全局 ~/.pi/agent/ai-git-commit/ai-git-commit.json 与 * 项目级 /.pi/ai-git-commit.json 覆盖同名字段。 */ export async function loadCommitConfig(projectDir?: string): Promise { const defaultLayer = parseConfigLayer( await readJsonFile(INTERNAL_CONFIG_PATH), INTERNAL_CONFIG_PATH, ); let config = requireCompleteConfig(defaultLayer, INTERNAL_CONFIG_PATH); const globalConfigPath = join(getGlobalExtensionDir(), CONFIG_FILE_NAME); const globalConfig = await readOptionalJsonFile(globalConfigPath); if (globalConfig !== undefined) { config = { ...config, ...parseConfigLayer(globalConfig, globalConfigPath), }; } if (projectDir !== undefined) { const projectConfigPath = join(projectDir, ".pi", CONFIG_FILE_NAME); const projectConfig = await readOptionalJsonFile(projectConfigPath); if (projectConfig !== undefined) { config = { ...config, ...parseConfigLayer(projectConfig, projectConfigPath), }; } } validateModelSelection(config); return config; }