mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-06-15 07:26:44 +00:00
feat(config): migrate all runtime settings from env vars to SQLite DB
Replace env-var based config with DB-first approach (Portainer model). Only PORT, DATABASE_PATH, and MASTER_KEY_PATH remain as env vars. All other settings (Gitea, Feishu, security, review engine, memory) are managed through the Admin Dashboard Web UI backed by system_settings table. - ConfigManager rewrites getRawValue() to read from settingsRepo with fallback to compiled-in defaults (no more process.env reads) - seedDefaults() auto-generates JWT_SECRET and WEBHOOK_SECRET on first boot - getSource() returns 'db' | 'default' (removed 'env' source type) - Merged 'app'+'admin' config groups into 'security' group - Removed PORT from CONFIG_FIELDS (env-var only) - Removed readonly/readonlyWarning from all field definitions
This commit is contained in:
@@ -1,101 +1,6 @@
|
||||
/**
|
||||
* Three-layer configuration manager.
|
||||
* Priority: Zod defaults → process.env → JSON overrides
|
||||
*
|
||||
* Override file format:
|
||||
* { version: 1, updatedAt: string, overrides: Record<string, string> }
|
||||
*
|
||||
* Bun-friendly IO: reads via readFile, writes atomically via temp+rename.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { config as dotenvConfig } from 'dotenv';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Load .env before any process.env access (must precede singleton construction)
|
||||
dotenvConfig();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Override file types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface OverridesFile {
|
||||
version: 1;
|
||||
updatedAt: string;
|
||||
overrides: Record<string, string>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zod schema (identical to src/config/index.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const defaultAllowedReviewCommands = ['git', 'rg', 'cat', 'sed', 'wc'];
|
||||
|
||||
const envSchema = z.object({
|
||||
// Gitea
|
||||
GITEA_API_URL: z.string().url().default('http://localhost:5174/api/v1'),
|
||||
GITEA_ACCESS_TOKEN: z.string().default('test_token'),
|
||||
GITEA_ADMIN_TOKEN: z.string().optional(),
|
||||
|
||||
CUSTOM_SUMMARY_PROMPT: z.string().optional(),
|
||||
CUSTOM_LINE_COMMENT_PROMPT: z.string().optional(),
|
||||
GLOBAL_PROMPT: z.string().optional(),
|
||||
|
||||
// Feishu
|
||||
FEISHU_WEBHOOK_URL: z.preprocess(
|
||||
(val) => (typeof val === 'string' && val.trim() === '' ? undefined : val),
|
||||
z.string().url().optional()
|
||||
),
|
||||
FEISHU_WEBHOOK_SECRET: z.string().optional(),
|
||||
|
||||
// App
|
||||
PORT: z.string().transform(Number).default('5174'),
|
||||
WEBHOOK_SECRET: z.string().default('test_webhook_secret'),
|
||||
|
||||
// Admin
|
||||
ADMIN_PASSWORD: z.string().default('password'),
|
||||
JWT_SECRET: z.string().default('a-secure-secret-for-jwt'),
|
||||
|
||||
// Review engine
|
||||
REVIEW_ENGINE: z.enum(['legacy', 'agent']).default('legacy'),
|
||||
REVIEW_WORKDIR: z.string().default('/tmp/gitea-assistant'),
|
||||
REVIEW_MAX_PARALLEL_RUNS: z.coerce.number().int().min(1).max(8).default(2),
|
||||
REVIEW_MAX_FILES_PER_RUN: z.coerce.number().int().min(1).max(1000).default(200),
|
||||
REVIEW_MAX_FILE_CONTENT_CHARS: z.coerce.number().int().min(1000).max(1_000_000).default(40_000),
|
||||
REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8),
|
||||
REVIEW_ENABLE_HUMAN_GATE: z
|
||||
.enum(['true', 'false'])
|
||||
.default('true')
|
||||
.transform((value) => value === 'true'),
|
||||
REVIEW_ALLOWED_COMMANDS: z.string().default(defaultAllowedReviewCommands.join(',')),
|
||||
REVIEW_COMMAND_TIMEOUT_MS: z.coerce.number().int().min(1000).max(300000).default(10000),
|
||||
|
||||
// Memory & learning
|
||||
QDRANT_URL: z.preprocess(
|
||||
(val) => (typeof val === 'string' && val.trim() === '' ? undefined : val),
|
||||
z.string().url().optional()
|
||||
),
|
||||
ENABLE_MEMORY: z
|
||||
.enum(['true', 'false'])
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
FEW_SHOT_EXAMPLES_COUNT: z.coerce.number().int().min(0).max(20).default(10),
|
||||
|
||||
// Reflection & debate
|
||||
ENABLE_REFLECTION: z
|
||||
.enum(['true', 'false'])
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
MAX_REFLECTION_ROUNDS: z.coerce.number().int().min(1).max(5).default(2),
|
||||
ENABLE_DEBATE: z
|
||||
.enum(['true', 'false'])
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
DEBATE_THRESHOLD: z.enum(['high', 'medium']).default('high'),
|
||||
});
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { settingsRepo } from '../db/repositories/settings-repo';
|
||||
import { CONFIG_FIELDS, type ConfigFieldMeta } from './config-schema';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config shape (matches default export of src/config/index.ts)
|
||||
@@ -142,196 +47,172 @@ export interface AppConfig {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dev fallback (matches src/config/index.ts behavior when validation fails)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfigManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ConfigManager {
|
||||
private readonly overridesPath: string;
|
||||
private overrides: Record<string, string> = {};
|
||||
private readonly fieldsMap = new Map<string, ConfigFieldMeta>(
|
||||
CONFIG_FIELDS.map((field) => [field.envKey, field])
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.overridesPath = resolve(process.env.CONFIG_OVERRIDES_PATH || './config-overrides.json');
|
||||
this.loadOverridesSync();
|
||||
private defaultToString(value: string | number | boolean): string {
|
||||
if (typeof value === 'string') return value;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** Synchronously load overrides at construction time (file is tiny). */
|
||||
private loadOverridesSync(): void {
|
||||
private getRawValue(key: string): string | undefined {
|
||||
try {
|
||||
const text = readFileSync(this.overridesPath, 'utf-8');
|
||||
const data: OverridesFile = JSON.parse(text);
|
||||
if (data && typeof data.overrides === 'object' && data.overrides !== null) {
|
||||
this.overrides = { ...data.overrides };
|
||||
const fromDb = settingsRepo.get(key);
|
||||
if (fromDb !== null) {
|
||||
return fromDb;
|
||||
}
|
||||
} catch {
|
||||
// File missing or invalid JSON — start with empty overrides
|
||||
// DB not initialized yet (e.g. during tests that don't init DB)
|
||||
// Fall through to return the default value below
|
||||
}
|
||||
|
||||
const field = this.fieldsMap.get(key);
|
||||
if (!field || field.defaultValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.defaultToString(field.defaultValue);
|
||||
}
|
||||
|
||||
// ── Override file I/O ────────────────────────────────────────────────────
|
||||
|
||||
/** Load overrides from disk. If file is missing or malformed, treat as empty. */
|
||||
async loadOverrides(): Promise<void> {
|
||||
try {
|
||||
const text = await readFile(this.overridesPath, 'utf-8');
|
||||
const data: OverridesFile = JSON.parse(text);
|
||||
if (data && typeof data.overrides === 'object' && data.overrides !== null) {
|
||||
this.overrides = { ...data.overrides };
|
||||
} else {
|
||||
this.overrides = {};
|
||||
}
|
||||
} catch {
|
||||
// File missing or invalid JSON — start with empty overrides
|
||||
this.overrides = {};
|
||||
getAllRawValues(): Record<string, string | undefined> {
|
||||
const values: Record<string, string | undefined> = {};
|
||||
for (const field of CONFIG_FIELDS) {
|
||||
values[field.envKey] = this.getRawValue(field.envKey);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** Persist current overrides to disk. Tries atomic rename; falls back to direct write. */
|
||||
private async persistOverrides(): Promise<void> {
|
||||
const dir = dirname(this.overridesPath);
|
||||
await mkdir(dir, { recursive: true });
|
||||
getCurrent(): AppConfig {
|
||||
const values = this.getAllRawValues();
|
||||
const portValue = process.env.PORT;
|
||||
const parsedPort = portValue !== undefined && portValue !== '' ? Number(portValue) : 5174;
|
||||
const port = Number.isFinite(parsedPort) ? parsedPort : 5174;
|
||||
|
||||
const payload: OverridesFile = {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
overrides: { ...this.overrides },
|
||||
const toNumber = (key: string, fallback: number): number => {
|
||||
const raw = values[key];
|
||||
if (raw === undefined) return fallback;
|
||||
const num = Number(raw);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
};
|
||||
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
const toBoolean = (key: string, fallback: boolean): boolean => {
|
||||
const raw = values[key];
|
||||
if (raw === undefined) return fallback;
|
||||
return raw === 'true';
|
||||
};
|
||||
|
||||
// Atomic rename may fail on K8s volumes (EBUSY/EXDEV); fall back to direct write.
|
||||
const tmpPath = `${this.overridesPath}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await writeFile(tmpPath, json, 'utf-8');
|
||||
await rename(tmpPath, this.overridesPath);
|
||||
} catch {
|
||||
await writeFile(this.overridesPath, json, 'utf-8');
|
||||
// Clean up orphaned tmp file (best effort)
|
||||
try { await unlink(tmpPath); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the fully resolved config object with the same shape as the
|
||||
* default export of `src/config/index.ts`.
|
||||
*
|
||||
* Layering: Zod defaults → process.env → overrides JSON
|
||||
*/
|
||||
getCurrent(): AppConfig {
|
||||
// Build a merged env-like record: process.env overlaid with overrides
|
||||
const merged: Record<string, string | undefined> = {};
|
||||
for (const key of Object.keys(envSchema.shape)) {
|
||||
const envVal = process.env[key];
|
||||
if (envVal !== undefined && envVal !== '') {
|
||||
merged[key] = envVal;
|
||||
}
|
||||
// Override wins if present and non-empty
|
||||
const ov = this.overrides[key];
|
||||
if (ov !== undefined && ov !== '') {
|
||||
merged[key] = ov;
|
||||
}
|
||||
}
|
||||
|
||||
const parseResult = envSchema.safeParse(merged);
|
||||
|
||||
if (!parseResult.success) {
|
||||
throw new Error('Configuration validation error');
|
||||
}
|
||||
|
||||
const env = parseResult.data;
|
||||
const toStringArray = (key: string, fallback: string[]): string[] => {
|
||||
const raw = values[key];
|
||||
if (raw === undefined) return fallback;
|
||||
return raw
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
return {
|
||||
gitea: {
|
||||
apiUrl: env.GITEA_API_URL,
|
||||
accessToken: env.GITEA_ACCESS_TOKEN,
|
||||
apiUrl: values.GITEA_API_URL ?? 'http://localhost:5174/api/v1',
|
||||
accessToken: values.GITEA_ACCESS_TOKEN ?? 'test_token',
|
||||
},
|
||||
feishu: {
|
||||
webhookUrl: env.FEISHU_WEBHOOK_URL,
|
||||
webhookSecret: env.FEISHU_WEBHOOK_SECRET,
|
||||
webhookUrl: values.FEISHU_WEBHOOK_URL,
|
||||
webhookSecret: values.FEISHU_WEBHOOK_SECRET,
|
||||
},
|
||||
app: {
|
||||
port: env.PORT,
|
||||
webhookSecret: env.WEBHOOK_SECRET,
|
||||
port,
|
||||
webhookSecret: values.WEBHOOK_SECRET ?? 'test_webhook_secret',
|
||||
},
|
||||
admin: {
|
||||
password: env.ADMIN_PASSWORD,
|
||||
jwtSecret: env.JWT_SECRET,
|
||||
giteaAdminToken: env.GITEA_ADMIN_TOKEN,
|
||||
password: values.ADMIN_PASSWORD ?? 'password',
|
||||
jwtSecret: values.JWT_SECRET ?? 'a-secure-secret-for-jwt',
|
||||
giteaAdminToken: values.GITEA_ADMIN_TOKEN,
|
||||
},
|
||||
review: {
|
||||
engine: env.REVIEW_ENGINE,
|
||||
workdir: env.REVIEW_WORKDIR,
|
||||
customSummaryPrompt: env.CUSTOM_SUMMARY_PROMPT,
|
||||
customLineCommentPrompt: env.CUSTOM_LINE_COMMENT_PROMPT,
|
||||
globalPrompt: env.GLOBAL_PROMPT,
|
||||
maxParallelRuns: env.REVIEW_MAX_PARALLEL_RUNS,
|
||||
maxFilesPerRun: env.REVIEW_MAX_FILES_PER_RUN,
|
||||
maxFileContentChars: env.REVIEW_MAX_FILE_CONTENT_CHARS,
|
||||
autoPublishMinConfidence: env.REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE,
|
||||
enableHumanGate: env.REVIEW_ENABLE_HUMAN_GATE,
|
||||
allowedCommands: env.REVIEW_ALLOWED_COMMANDS.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
commandTimeoutMs: env.REVIEW_COMMAND_TIMEOUT_MS,
|
||||
qdrantUrl: env.QDRANT_URL,
|
||||
enableMemory: env.ENABLE_MEMORY,
|
||||
fewShotExamplesCount: env.FEW_SHOT_EXAMPLES_COUNT,
|
||||
enableReflection: env.ENABLE_REFLECTION,
|
||||
maxReflectionRounds: env.MAX_REFLECTION_ROUNDS,
|
||||
enableDebate: env.ENABLE_DEBATE,
|
||||
debateThreshold: env.DEBATE_THRESHOLD,
|
||||
engine: values.REVIEW_ENGINE ?? 'legacy',
|
||||
workdir: values.REVIEW_WORKDIR ?? '/tmp/gitea-assistant',
|
||||
customSummaryPrompt: values.CUSTOM_SUMMARY_PROMPT,
|
||||
customLineCommentPrompt: values.CUSTOM_LINE_COMMENT_PROMPT,
|
||||
globalPrompt: values.GLOBAL_PROMPT,
|
||||
maxParallelRuns: toNumber('REVIEW_MAX_PARALLEL_RUNS', 2),
|
||||
maxFilesPerRun: toNumber('REVIEW_MAX_FILES_PER_RUN', 200),
|
||||
maxFileContentChars: toNumber('REVIEW_MAX_FILE_CONTENT_CHARS', 40000),
|
||||
autoPublishMinConfidence: toNumber('REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE', 0.8),
|
||||
enableHumanGate: toBoolean('REVIEW_ENABLE_HUMAN_GATE', true),
|
||||
allowedCommands: toStringArray('REVIEW_ALLOWED_COMMANDS', [
|
||||
'git',
|
||||
'rg',
|
||||
'cat',
|
||||
'sed',
|
||||
'wc',
|
||||
]),
|
||||
commandTimeoutMs: toNumber('REVIEW_COMMAND_TIMEOUT_MS', 10000),
|
||||
qdrantUrl: values.QDRANT_URL,
|
||||
enableMemory: toBoolean('ENABLE_MEMORY', false),
|
||||
fewShotExamplesCount: toNumber('FEW_SHOT_EXAMPLES_COUNT', 10),
|
||||
enableReflection: toBoolean('ENABLE_REFLECTION', false),
|
||||
maxReflectionRounds: toNumber('MAX_REFLECTION_ROUNDS', 2),
|
||||
enableDebate: toBoolean('ENABLE_DEBATE', false),
|
||||
debateThreshold: values.DEBATE_THRESHOLD ?? 'high',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Return raw overrides record. */
|
||||
getOverrides(): Record<string, string> {
|
||||
return { ...this.overrides };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge updates into overrides and persist.
|
||||
* If a value is empty string `''`, that key is deleted (reset to lower layer).
|
||||
*/
|
||||
async setOverrides(updates: Record<string, string>): Promise<void> {
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
const field = this.fieldsMap.get(key);
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === '') {
|
||||
delete this.overrides[key];
|
||||
settingsRepo.delete(key);
|
||||
} else {
|
||||
this.overrides[key] = value;
|
||||
settingsRepo.set(key, value, field.sensitive);
|
||||
}
|
||||
}
|
||||
await this.persistOverrides();
|
||||
}
|
||||
|
||||
/** Remove specified keys from overrides and persist. */
|
||||
async resetKeys(keys: string[]): Promise<void> {
|
||||
for (const key of keys) {
|
||||
delete this.overrides[key];
|
||||
settingsRepo.delete(key);
|
||||
}
|
||||
await this.persistOverrides();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine where the effective value for a given env key comes from.
|
||||
*/
|
||||
getSource(envKey: string): 'default' | 'env' | 'override' {
|
||||
const ov = this.overrides[envKey];
|
||||
if (ov !== undefined && ov !== '') {
|
||||
return 'override';
|
||||
getSource(envKey: string): 'default' | 'db' {
|
||||
try {
|
||||
return settingsRepo.get(envKey) !== null ? 'db' : 'default';
|
||||
} catch {
|
||||
return 'default';
|
||||
}
|
||||
const envVal = process.env[envKey];
|
||||
if (envVal !== undefined && envVal !== '') {
|
||||
return 'env';
|
||||
}
|
||||
|
||||
seedDefaults(): void {
|
||||
if (settingsRepo.listAll().length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const field of CONFIG_FIELDS) {
|
||||
let value: string | undefined;
|
||||
|
||||
if (field.envKey === 'JWT_SECRET' || field.envKey === 'WEBHOOK_SECRET') {
|
||||
value = randomBytes(32).toString('hex');
|
||||
} else if (field.envKey === 'ADMIN_PASSWORD') {
|
||||
value = 'password';
|
||||
} else if (field.defaultValue !== undefined) {
|
||||
value = this.defaultToString(field.defaultValue);
|
||||
}
|
||||
|
||||
if (value !== undefined) {
|
||||
settingsRepo.set(field.envKey, value, field.sensitive);
|
||||
}
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConfigGroup = 'gitea' | 'feishu' | 'app' | 'admin' | 'review' | 'memory';
|
||||
export type ConfigGroup = 'gitea' | 'feishu' | 'security' | 'review' | 'memory';
|
||||
|
||||
export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'url' | 'text' | 'enum';
|
||||
|
||||
@@ -18,8 +18,6 @@ export interface ConfigFieldMeta {
|
||||
description: string;
|
||||
type: ConfigFieldType;
|
||||
sensitive: boolean;
|
||||
readonly?: boolean;
|
||||
readonlyWarning?: string;
|
||||
enumValues?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
@@ -51,15 +49,9 @@ export const CONFIG_GROUPS: ConfigGroupMeta[] = [
|
||||
icon: 'bell',
|
||||
},
|
||||
{
|
||||
key: 'app',
|
||||
label: '应用',
|
||||
description: '服务端口与 Webhook 安全',
|
||||
icon: 'settings',
|
||||
},
|
||||
{
|
||||
key: 'admin',
|
||||
label: '管理后台',
|
||||
description: '后台登录密码与 JWT 密钥',
|
||||
key: 'security',
|
||||
label: '安全设置',
|
||||
description: 'Webhook、后台密码与 JWT 密钥',
|
||||
icon: 'shield',
|
||||
},
|
||||
{
|
||||
@@ -152,48 +144,32 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
sensitive: true,
|
||||
},
|
||||
|
||||
// ── 应用 ────────────────────────────────────────────────────────────────
|
||||
{
|
||||
envKey: 'PORT',
|
||||
group: 'app',
|
||||
label: '监听端口',
|
||||
description: '服务监听的 HTTP 端口号,修改需通过 .env 配置并重启服务',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
readonly: true,
|
||||
defaultValue: 5174,
|
||||
},
|
||||
{
|
||||
envKey: 'WEBHOOK_SECRET',
|
||||
group: 'app',
|
||||
group: 'security',
|
||||
label: 'Webhook 密钥',
|
||||
description:
|
||||
'用于验证 Gitea Webhook 请求来源的 HMAC 密钥,修改需通过 .env 配置并同步更新 Gitea',
|
||||
description: '用于验证 Gitea Webhook 请求来源的 HMAC 密钥',
|
||||
type: 'string',
|
||||
sensitive: true,
|
||||
readonly: true,
|
||||
defaultValue: 'test_webhook_secret',
|
||||
},
|
||||
|
||||
// ── 管理后台 ────────────────────────────────────────────────────────────
|
||||
{
|
||||
envKey: 'ADMIN_PASSWORD',
|
||||
group: 'admin',
|
||||
group: 'security',
|
||||
label: '管理员密码',
|
||||
description: '后台管理界面的登录密码',
|
||||
type: 'string',
|
||||
sensitive: true,
|
||||
readonlyWarning: '修改后当前登录会话可能失效',
|
||||
defaultValue: 'password',
|
||||
},
|
||||
{
|
||||
envKey: 'JWT_SECRET',
|
||||
group: 'admin',
|
||||
group: 'security',
|
||||
label: 'JWT 密钥',
|
||||
description: '用于签发后台登录 Token 的密钥,修改需通过 .env 配置',
|
||||
description: '用于签发后台登录 Token 的密钥',
|
||||
type: 'string',
|
||||
sensitive: true,
|
||||
readonly: true,
|
||||
defaultValue: 'a-secure-secret-for-jwt',
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from 'hono';
|
||||
import { type AppConfig, configManager } from '../config/config-manager';
|
||||
import { configManager } from '../config/config-manager';
|
||||
import { CONFIG_FIELDS, CONFIG_GROUPS, type ConfigFieldMeta } from '../config/config-schema';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
@@ -9,7 +9,6 @@ const MASKED_VALUE = '••••••••';
|
||||
|
||||
/** Number fields that must be integers (decimal not allowed). */
|
||||
const INTEGER_FIELDS = new Set([
|
||||
'PORT',
|
||||
'REVIEW_MAX_PARALLEL_RUNS',
|
||||
'REVIEW_MAX_FILES_PER_RUN',
|
||||
'REVIEW_MAX_FILE_CONTENT_CHARS',
|
||||
@@ -23,83 +22,6 @@ const FIELDS_MAP = new Map<string, ConfigFieldMeta>(CONFIG_FIELDS.map((f) => [f.
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map an envKey to its effective value from the resolved AppConfig.
|
||||
* Explicit switch — no dynamic property access.
|
||||
*/
|
||||
function getEffectiveValue(
|
||||
envKey: string,
|
||||
current: AppConfig
|
||||
): string | number | boolean | undefined {
|
||||
switch (envKey) {
|
||||
// Gitea
|
||||
case 'GITEA_API_URL':
|
||||
return current.gitea.apiUrl;
|
||||
case 'GITEA_ACCESS_TOKEN':
|
||||
return current.gitea.accessToken;
|
||||
case 'GITEA_ADMIN_TOKEN':
|
||||
return current.admin.giteaAdminToken;
|
||||
// Review prompts (moved from OpenAI group)
|
||||
case 'CUSTOM_SUMMARY_PROMPT':
|
||||
return current.review.customSummaryPrompt;
|
||||
case 'CUSTOM_LINE_COMMENT_PROMPT':
|
||||
return current.review.customLineCommentPrompt;
|
||||
case 'GLOBAL_PROMPT':
|
||||
return current.review.globalPrompt;
|
||||
// Feishu
|
||||
case 'FEISHU_WEBHOOK_URL':
|
||||
return current.feishu.webhookUrl;
|
||||
case 'FEISHU_WEBHOOK_SECRET':
|
||||
return current.feishu.webhookSecret;
|
||||
// App
|
||||
case 'PORT':
|
||||
return current.app.port;
|
||||
case 'WEBHOOK_SECRET':
|
||||
return current.app.webhookSecret;
|
||||
// Admin
|
||||
case 'ADMIN_PASSWORD':
|
||||
return current.admin.password;
|
||||
case 'JWT_SECRET':
|
||||
return current.admin.jwtSecret;
|
||||
// Review
|
||||
case 'REVIEW_ENGINE':
|
||||
return current.review.engine;
|
||||
case 'REVIEW_WORKDIR':
|
||||
return current.review.workdir;
|
||||
case 'REVIEW_MAX_PARALLEL_RUNS':
|
||||
return current.review.maxParallelRuns;
|
||||
case 'REVIEW_MAX_FILES_PER_RUN':
|
||||
return current.review.maxFilesPerRun;
|
||||
case 'REVIEW_MAX_FILE_CONTENT_CHARS':
|
||||
return current.review.maxFileContentChars;
|
||||
case 'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE':
|
||||
return current.review.autoPublishMinConfidence;
|
||||
case 'REVIEW_ENABLE_HUMAN_GATE':
|
||||
return current.review.enableHumanGate;
|
||||
case 'REVIEW_ALLOWED_COMMANDS':
|
||||
return current.review.allowedCommands.join(',');
|
||||
case 'REVIEW_COMMAND_TIMEOUT_MS':
|
||||
return current.review.commandTimeoutMs;
|
||||
// Memory
|
||||
case 'QDRANT_URL':
|
||||
return current.review.qdrantUrl;
|
||||
case 'ENABLE_MEMORY':
|
||||
return current.review.enableMemory;
|
||||
case 'FEW_SHOT_EXAMPLES_COUNT':
|
||||
return current.review.fewShotExamplesCount;
|
||||
case 'ENABLE_REFLECTION':
|
||||
return current.review.enableReflection;
|
||||
case 'MAX_REFLECTION_ROUNDS':
|
||||
return current.review.maxReflectionRounds;
|
||||
case 'ENABLE_DEBATE':
|
||||
return current.review.enableDebate;
|
||||
case 'DEBATE_THRESHOLD':
|
||||
return current.review.debateThreshold;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single field value against its metadata.
|
||||
* Returns an error message string, or `null` if valid.
|
||||
@@ -157,13 +79,13 @@ export const configRouter = new Hono();
|
||||
* effective values, and source. Sensitive fields are masked.
|
||||
*/
|
||||
configRouter.get('/', (c) => {
|
||||
const current = configManager.getCurrent();
|
||||
const rawValues = configManager.getAllRawValues();
|
||||
|
||||
const groups = CONFIG_GROUPS.map((group) => {
|
||||
const groupFields = CONFIG_FIELDS.filter((f) => f.group === group.key);
|
||||
|
||||
const fields = groupFields.map((field) => {
|
||||
const rawValue = getEffectiveValue(field.envKey, current);
|
||||
const rawValue = rawValues[field.envKey];
|
||||
const hasValue = rawValue !== undefined && rawValue !== '';
|
||||
const source = configManager.getSource(field.envKey);
|
||||
const value = field.sensitive && hasValue ? MASKED_VALUE : rawValue;
|
||||
@@ -174,8 +96,6 @@ configRouter.get('/', (c) => {
|
||||
description: field.description,
|
||||
type: field.type,
|
||||
sensitive: field.sensitive,
|
||||
...(field.readonly && { readonly: true }),
|
||||
...(field.readonlyWarning !== undefined && { readonlyWarning: field.readonlyWarning }),
|
||||
...(field.enumValues !== undefined && { enumValues: field.enumValues }),
|
||||
...(field.min !== undefined && { min: field.min }),
|
||||
...(field.max !== undefined && { max: field.max }),
|
||||
@@ -199,9 +119,9 @@ configRouter.get('/', (c) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT / — Validate and persist override updates.
|
||||
* PUT / — Validate and persist configuration updates.
|
||||
* Masked sentinel ('••••••••') for sensitive fields is silently skipped.
|
||||
* Empty string '' causes the key to be reset (deleted from overrides).
|
||||
* Empty string '' causes the key to be reset (deleted from DB).
|
||||
*/
|
||||
configRouter.put('/', async (c) => {
|
||||
try {
|
||||
@@ -221,11 +141,6 @@ configRouter.put('/', async (c) => {
|
||||
errors.push(`未知配置项: ${key}`);
|
||||
continue;
|
||||
}
|
||||
// Silently skip readonly fields — frontend sends entire form state
|
||||
if (field.readonly) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = String(rawValue ?? '');
|
||||
|
||||
// Skip masked sentinel for sensitive fields — do not overwrite with mask
|
||||
@@ -262,7 +177,7 @@ configRouter.put('/', async (c) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /reset — Remove specified keys from overrides (revert to env / default).
|
||||
* POST /reset — Remove specified keys from DB (revert to default).
|
||||
*/
|
||||
configRouter.post('/reset', async (c) => {
|
||||
try {
|
||||
|
||||
11
src/index.ts
11
src/index.ts
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { jwt } from 'hono/jwt';
|
||||
import config from './config';
|
||||
import config, { configManager } from './config';
|
||||
import { adminController } from './controllers/admin';
|
||||
import { configRouter } from './controllers/config';
|
||||
import { feedbackRouter, initializeFeedbackSystem } from './controllers/feedback';
|
||||
@@ -11,6 +11,10 @@ import { initMasterKey } from './crypto/secrets';
|
||||
import { initDatabase } from './db/database';
|
||||
import { reviewEngine } from './review/engine';
|
||||
|
||||
initMasterKey();
|
||||
initDatabase();
|
||||
configManager.seedDefaults();
|
||||
|
||||
// 创建Hono应用实例
|
||||
const app = new Hono();
|
||||
|
||||
@@ -31,7 +35,7 @@ app.get('/', (c) => {
|
||||
},
|
||||
signature: webhookSecretConfigured
|
||||
? '签名验证已启用 (使用X-Gitea-Signature头)'
|
||||
: '警告: 签名验证未配置,建议设置WEBHOOK_SECRET环境变量',
|
||||
: '警告: 签名验证未配置,建议在管理后台设置 Webhook 密钥',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -64,9 +68,6 @@ app.get('*', serveStatic({ path: './public/index.html' }));
|
||||
const port = config.app.port;
|
||||
console.log(`⚡️ 服务启动在 http://localhost:${port}`);
|
||||
|
||||
initMasterKey();
|
||||
initDatabase();
|
||||
|
||||
reviewEngine.start().catch((error) => {
|
||||
console.error('❌ 启动Agent Review Engine失败', error);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user