[codex] add locked file updates for config writes (#785)
* add locked file updates for config writes * add glm vision turbo preset
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import { readFile, writeFile, copyFile } from 'fs/promises'
|
import { readFile } from 'fs/promises'
|
||||||
import YAML from 'js-yaml'
|
|
||||||
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
|
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
|
||||||
import { getActiveConfigPath, getActiveEnvPath } from '../../services/hermes/hermes-profile'
|
import { getActiveConfigPath, getActiveEnvPath } from '../../services/hermes/hermes-profile'
|
||||||
import { saveEnvValue } from '../../services/config-helpers'
|
import { saveEnvValue } from '../../services/config-helpers'
|
||||||
import { logger } from '../../services/logger'
|
import { logger } from '../../services/logger'
|
||||||
|
import { safeFileStore } from '../../services/safe-file-store'
|
||||||
|
|
||||||
const PLATFORM_SECTIONS = new Set([
|
const PLATFORM_SECTIONS = new Set([
|
||||||
'telegram', 'discord', 'slack', 'whatsapp', 'matrix',
|
'telegram', 'discord', 'slack', 'whatsapp', 'matrix',
|
||||||
@@ -90,20 +90,7 @@ async function readEnvPlatforms(): Promise<Record<string, any>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readConfig(): Promise<Record<string, any>> {
|
async function readConfig(): Promise<Record<string, any>> {
|
||||||
const raw = await readFile(configPath(), 'utf-8')
|
return safeFileStore.readYaml(configPath())
|
||||||
return (YAML.load(raw, { json: true }) as Record<string, any>) || {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeConfig(data: Record<string, any>): Promise<void> {
|
|
||||||
const cp = configPath()
|
|
||||||
await copyFile(cp, cp + '.bak')
|
|
||||||
const yamlStr = YAML.dump(data, {
|
|
||||||
lineWidth: -1,
|
|
||||||
noRefs: true,
|
|
||||||
quotingType: '"',
|
|
||||||
forceQuotes: true, // Force quotes on all string values
|
|
||||||
})
|
|
||||||
await writeFile(cp, yamlStr, 'utf-8')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getConfig(ctx: any) {
|
export async function getConfig(ctx: any) {
|
||||||
@@ -139,9 +126,15 @@ export async function updateConfig(ctx: any) {
|
|||||||
ctx.status = 400; ctx.body = { error: 'Missing section or values' }; return
|
ctx.status = 400; ctx.body = { error: 'Missing section or values' }; return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const config = await readConfig()
|
await safeFileStore.updateYaml(configPath(), (config) => {
|
||||||
config[section] = deepMerge(config[section] || {}, values)
|
config[section] = deepMerge(config[section] || {}, values)
|
||||||
await writeConfig(config)
|
return config
|
||||||
|
}, {
|
||||||
|
backup: true,
|
||||||
|
dumpOptions: {
|
||||||
|
forceQuotes: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// 使用 GatewayManager 重启平台网关
|
// 使用 GatewayManager 重启平台网关
|
||||||
if (PLATFORM_SECTIONS.has(section)) {
|
if (PLATFORM_SECTIONS.has(section)) {
|
||||||
@@ -173,14 +166,13 @@ export async function updateCredentials(ctx: any) {
|
|||||||
if (!envMap) {
|
if (!envMap) {
|
||||||
ctx.status = 400; ctx.body = { error: `Unknown platform: ${platform}` }; return
|
ctx.status = 400; ctx.body = { error: `Unknown platform: ${platform}` }; return
|
||||||
}
|
}
|
||||||
const config = await readConfig()
|
|
||||||
let configChanged = false
|
|
||||||
const flatValues: Record<string, any> = {}
|
const flatValues: Record<string, any> = {}
|
||||||
for (const [key, val] of Object.entries(values)) {
|
for (const [key, val] of Object.entries(values)) {
|
||||||
if (key === 'extra' && val && typeof val === 'object') {
|
if (key === 'extra' && val && typeof val === 'object') {
|
||||||
for (const [subKey, subVal] of Object.entries(val as Record<string, any>)) { flatValues[`extra.${subKey}`] = subVal }
|
for (const [subKey, subVal] of Object.entries(val as Record<string, any>)) { flatValues[`extra.${subKey}`] = subVal }
|
||||||
} else { flatValues[key] = val }
|
} else { flatValues[key] = val }
|
||||||
}
|
}
|
||||||
|
await safeFileStore.updateYaml(configPath(), async (config) => {
|
||||||
for (const [cfgPath, val] of Object.entries(flatValues)) {
|
for (const [cfgPath, val] of Object.entries(flatValues)) {
|
||||||
const envVar = envMap[cfgPath]
|
const envVar = envMap[cfgPath]
|
||||||
if (!envVar) continue
|
if (!envVar) continue
|
||||||
@@ -197,13 +189,18 @@ export async function updateCredentials(ctx: any) {
|
|||||||
if (obj.extra && Object.keys(obj.extra).length === 0) delete obj.extra
|
if (obj.extra && Object.keys(obj.extra).length === 0) delete obj.extra
|
||||||
}
|
}
|
||||||
if (Object.keys(obj).length === 0) { if (!config.platforms) config.platforms = {}; delete config.platforms[platform] }
|
if (Object.keys(obj).length === 0) { if (!config.platforms) config.platforms = {}; delete config.platforms[platform] }
|
||||||
configChanged = true
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await saveEnvValue(envVar, String(val))
|
await saveEnvValue(envVar, String(val))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (configChanged) { await writeConfig(config) }
|
return config
|
||||||
|
}, {
|
||||||
|
backup: true,
|
||||||
|
dumpOptions: {
|
||||||
|
forceQuotes: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// 使用 GatewayManager 重启平台网关
|
// 使用 GatewayManager 重启平台网关
|
||||||
const mgr = getGatewayManagerInstance()
|
const mgr = getGatewayManagerInstance()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import { startDeviceFlow, pollDeviceFlow } from '../../services/hermes/copilot-device-flow'
|
import { startDeviceFlow, pollDeviceFlow } from '../../services/hermes/copilot-device-flow'
|
||||||
import { saveEnvValue, readConfigYaml, writeConfigYaml } from '../../services/config-helpers'
|
import { saveEnvValue, updateConfigYaml } from '../../services/config-helpers'
|
||||||
import {
|
import {
|
||||||
invalidateAllCaches,
|
invalidateAllCaches,
|
||||||
resolveCopilotOAuthTokenWithSource,
|
resolveCopilotOAuthTokenWithSource,
|
||||||
@@ -192,16 +192,17 @@ export async function disable(ctx: any): Promise<void> {
|
|||||||
// 不能 swallow —— 否则会出现 "list 已隐藏 copilot 但 default 仍是 copilot" 的中间态。
|
// 不能 swallow —— 否则会出现 "list 已隐藏 copilot 但 default 仍是 copilot" 的中间态。
|
||||||
let clearedDefault = false
|
let clearedDefault = false
|
||||||
try {
|
try {
|
||||||
const cfg = await readConfigYaml()
|
clearedDefault = await updateConfigYaml((cfg) => {
|
||||||
const modelSection = cfg.model
|
const modelSection = cfg.model
|
||||||
if (typeof modelSection === 'object' && modelSection !== null) {
|
if (typeof modelSection === 'object' && modelSection !== null) {
|
||||||
const provider = String(modelSection.provider || '').trim().toLowerCase()
|
const provider = String(modelSection.provider || '').trim().toLowerCase()
|
||||||
if (provider === 'copilot') {
|
if (provider === 'copilot') {
|
||||||
cfg.model = {}
|
cfg.model = {}
|
||||||
await writeConfigYaml(cfg)
|
return { data: cfg, result: true }
|
||||||
clearedDefault = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { data: cfg, result: false, write: false }
|
||||||
|
}) || false
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error(err, 'Copilot disable failed: cannot clear default model')
|
logger.error(err, 'Copilot disable failed: cannot clear default model')
|
||||||
ctx.status = 500
|
ctx.status = 500
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { readFile } from 'fs/promises'
|
import { readFile } from 'fs/promises'
|
||||||
import { existsSync, readFileSync } from 'fs'
|
import { existsSync, readFileSync } from 'fs'
|
||||||
import { getActiveEnvPath, getActiveAuthPath } from '../../services/hermes/hermes-profile'
|
import { getActiveEnvPath, getActiveAuthPath } from '../../services/hermes/hermes-profile'
|
||||||
import { readConfigYaml, writeConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
|
import { readConfigYaml, updateConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
|
||||||
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
|
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
|
||||||
import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models'
|
import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models'
|
||||||
import { readAppConfig, writeAppConfig, type ModelVisibilityRule } from '../../services/app-config'
|
import { readAppConfig, writeAppConfig, type ModelVisibilityRule } from '../../services/app-config'
|
||||||
@@ -483,11 +483,12 @@ export async function setConfigModel(ctx: any) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const config = await readConfigYaml()
|
await updateConfigYaml((config) => {
|
||||||
config.model = {}
|
config.model = {}
|
||||||
config.model.default = defaultModel
|
config.model.default = defaultModel
|
||||||
if (reqProvider) { config.model.provider = reqProvider }
|
if (reqProvider) { config.model.provider = reqProvider }
|
||||||
await writeConfigYaml(config)
|
return config
|
||||||
|
})
|
||||||
ctx.body = { success: true }
|
ctx.body = { success: true }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.status = 500
|
ctx.status = 500
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'fs'
|
|||||||
import { writeFile } from 'fs/promises'
|
import { writeFile } from 'fs/promises'
|
||||||
import { getActiveAuthPath } from '../../services/hermes/hermes-profile'
|
import { getActiveAuthPath } from '../../services/hermes/hermes-profile'
|
||||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||||
import { readConfigYaml, writeConfigYaml, saveEnvValue, PROVIDER_ENV_MAP } from '../../services/config-helpers'
|
import { updateConfigYaml, saveEnvValue, PROVIDER_ENV_MAP } from '../../services/config-helpers'
|
||||||
import { PROVIDER_PRESETS } from '../../shared/providers'
|
import { PROVIDER_PRESETS } from '../../shared/providers'
|
||||||
import { logger } from '../../services/logger'
|
import { logger } from '../../services/logger'
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export async function create(ctx: any) {
|
|||||||
try {
|
try {
|
||||||
const poolKey = providerKey || `custom:${name.trim().toLowerCase().replace(/ /g, '-')}`
|
const poolKey = providerKey || `custom:${name.trim().toLowerCase().replace(/ /g, '-')}`
|
||||||
const isBuiltin = poolKey in PROVIDER_ENV_MAP
|
const isBuiltin = poolKey in PROVIDER_ENV_MAP
|
||||||
const config = await readConfigYaml()
|
await updateConfigYaml(async (config) => {
|
||||||
if (typeof config.model !== 'object' || config.model === null) { config.model = {} }
|
if (typeof config.model !== 'object' || config.model === null) { config.model = {} }
|
||||||
if (!isBuiltin) {
|
if (!isBuiltin) {
|
||||||
if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] }
|
if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] }
|
||||||
@@ -110,7 +110,8 @@ export async function create(ctx: any) {
|
|||||||
}
|
}
|
||||||
delete config.model.base_url
|
delete config.model.base_url
|
||||||
delete config.model.api_key
|
delete config.model.api_key
|
||||||
await writeConfigYaml(config)
|
return config
|
||||||
|
})
|
||||||
// TODO: Test if provider works without gateway restart
|
// TODO: Test if provider works without gateway restart
|
||||||
// try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
|
// try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
|
||||||
ctx.body = { success: true }
|
ctx.body = { success: true }
|
||||||
@@ -127,21 +128,21 @@ export async function update(ctx: any) {
|
|||||||
try {
|
try {
|
||||||
const isCustom = poolKey.startsWith('custom:')
|
const isCustom = poolKey.startsWith('custom:')
|
||||||
if (isCustom) {
|
if (isCustom) {
|
||||||
const config = await readConfigYaml()
|
const found = await updateConfigYaml((config) => {
|
||||||
if (!Array.isArray(config.custom_providers)) {
|
if (!Array.isArray(config.custom_providers)) return { data: config, result: false, write: false }
|
||||||
ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return
|
|
||||||
}
|
|
||||||
const entry = (config.custom_providers as any[]).find((e: any) => {
|
const entry = (config.custom_providers as any[]).find((e: any) => {
|
||||||
return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey
|
return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey
|
||||||
})
|
})
|
||||||
if (!entry) {
|
if (!entry) return { data: config, result: false, write: false }
|
||||||
ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return
|
|
||||||
}
|
|
||||||
if (name !== undefined) entry.name = name
|
if (name !== undefined) entry.name = name
|
||||||
if (base_url !== undefined) entry.base_url = base_url
|
if (base_url !== undefined) entry.base_url = base_url
|
||||||
if (api_key !== undefined) entry.api_key = api_key
|
if (api_key !== undefined) entry.api_key = api_key
|
||||||
if (model !== undefined) entry.model = model
|
if (model !== undefined) entry.model = model
|
||||||
await writeConfigYaml(config)
|
return { data: config, result: true }
|
||||||
|
})
|
||||||
|
if (!found) {
|
||||||
|
ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const envMapping = PROVIDER_ENV_MAP[poolKey]
|
const envMapping = PROVIDER_ENV_MAP[poolKey]
|
||||||
if (!envMapping?.api_key_env) {
|
if (!envMapping?.api_key_env) {
|
||||||
@@ -160,46 +161,49 @@ export async function update(ctx: any) {
|
|||||||
export async function remove(ctx: any) {
|
export async function remove(ctx: any) {
|
||||||
const poolKey = decodeURIComponent(ctx.params.poolKey)
|
const poolKey = decodeURIComponent(ctx.params.poolKey)
|
||||||
try {
|
try {
|
||||||
const config = await readConfigYaml()
|
|
||||||
const isCustom = poolKey.startsWith('custom:')
|
const isCustom = poolKey.startsWith('custom:')
|
||||||
|
const removed = await updateConfigYaml(async (config) => {
|
||||||
if (isCustom) {
|
if (isCustom) {
|
||||||
const idx = Array.isArray(config.custom_providers)
|
const idx = Array.isArray(config.custom_providers)
|
||||||
? (config.custom_providers as any[]).findIndex((e: any) => {
|
? (config.custom_providers as any[]).findIndex((e: any) => {
|
||||||
return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey
|
return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey
|
||||||
})
|
})
|
||||||
: -1
|
: -1
|
||||||
if (idx === -1) {
|
if (idx === -1) return { data: config, result: false, write: false }
|
||||||
ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return
|
|
||||||
}
|
|
||||||
;(config.custom_providers as any[]).splice(idx, 1)
|
;(config.custom_providers as any[]).splice(idx, 1)
|
||||||
await writeConfigYaml(config)
|
|
||||||
await clearStoredAuthProvider(poolKey)
|
|
||||||
} else {
|
} else {
|
||||||
const envMapping = PROVIDER_ENV_MAP[poolKey]
|
const envMapping = PROVIDER_ENV_MAP[poolKey]
|
||||||
if (envMapping?.api_key_env) {
|
if (envMapping?.api_key_env) {
|
||||||
await saveEnvValue(envMapping.api_key_env, '')
|
await saveEnvValue(envMapping.api_key_env, '')
|
||||||
if (envMapping.base_url_env) { await saveEnvValue(envMapping.base_url_env, '') }
|
if (envMapping.base_url_env) { await saveEnvValue(envMapping.base_url_env, '') }
|
||||||
}
|
}
|
||||||
await clearStoredAuthProvider(poolKey)
|
|
||||||
}
|
}
|
||||||
const currentProvider = config.model?.provider
|
if (config.model?.provider === poolKey) {
|
||||||
if (currentProvider === poolKey) {
|
const remaining = Array.isArray(config.custom_providers) ? config.custom_providers as any[] : []
|
||||||
const freshConfig = await readConfigYaml()
|
|
||||||
const remaining = Array.isArray(freshConfig.custom_providers) ? freshConfig.custom_providers as any[] : []
|
|
||||||
if (remaining.length > 0) {
|
if (remaining.length > 0) {
|
||||||
const fallbackCp = remaining[0]
|
const fallbackCp = remaining[0]
|
||||||
const fallbackKey = `custom:${fallbackCp.name.trim().toLowerCase().replace(/ /g, '-')}`
|
const fallbackKey = `custom:${fallbackCp.name.trim().toLowerCase().replace(/ /g, '-')}`
|
||||||
if (typeof freshConfig.model !== 'object' || freshConfig.model === null) { freshConfig.model = {} }
|
if (typeof config.model !== 'object' || config.model === null) { config.model = {} }
|
||||||
freshConfig.model.default = fallbackCp.model
|
config.model.default = fallbackCp.model
|
||||||
freshConfig.model.provider = fallbackKey
|
config.model.provider = fallbackKey
|
||||||
delete freshConfig.model.base_url
|
delete config.model.base_url
|
||||||
delete freshConfig.model.api_key
|
delete config.model.api_key
|
||||||
await writeConfigYaml(freshConfig)
|
|
||||||
} else {
|
} else {
|
||||||
freshConfig.model = {}
|
config.model = {}
|
||||||
await writeConfigYaml(freshConfig)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { data: config, result: true }
|
||||||
|
})
|
||||||
|
if (!removed) {
|
||||||
|
ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return
|
||||||
|
}
|
||||||
|
if (!isCustom) {
|
||||||
|
const envMapping = PROVIDER_ENV_MAP[poolKey]
|
||||||
|
if (!envMapping) {
|
||||||
|
ctx.status = 404; ctx.body = { error: `Provider "${poolKey}" not found` }; return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await clearStoredAuthProvider(poolKey)
|
||||||
// TODO: Test if provider works without gateway restart
|
// TODO: Test if provider works without gateway restart
|
||||||
// try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
|
// try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
|
||||||
ctx.body = { success: true }
|
ctx.body = { success: true }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { readdir, readFile } from 'fs/promises'
|
|||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import {
|
import {
|
||||||
readConfigYaml, writeConfigYaml,
|
readConfigYaml, updateConfigYaml,
|
||||||
safeReadFile, extractDescription, listFilesRecursive, getHermesDir,
|
safeReadFile, extractDescription, listFilesRecursive, getHermesDir,
|
||||||
} from '../../services/config-helpers'
|
} from '../../services/config-helpers'
|
||||||
import { pinSkill } from '../../services/hermes/hermes-cli'
|
import { pinSkill } from '../../services/hermes/hermes-cli'
|
||||||
@@ -260,14 +260,15 @@ export async function toggle(ctx: any) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const config = await readConfigYaml()
|
await updateConfigYaml((config) => {
|
||||||
if (!config.skills) config.skills = {}
|
if (!config.skills) config.skills = {}
|
||||||
if (!Array.isArray(config.skills.disabled)) config.skills.disabled = []
|
if (!Array.isArray(config.skills.disabled)) config.skills.disabled = []
|
||||||
const disabled = config.skills.disabled as string[]
|
const disabled = config.skills.disabled as string[]
|
||||||
const idx = disabled.indexOf(name)
|
const idx = disabled.indexOf(name)
|
||||||
if (enabled) { if (idx !== -1) disabled.splice(idx, 1) }
|
if (enabled) { if (idx !== -1) disabled.splice(idx, 1) }
|
||||||
else { if (idx === -1) disabled.push(name) }
|
else { if (idx === -1) disabled.push(name) }
|
||||||
await writeConfigYaml(config)
|
return config
|
||||||
|
})
|
||||||
ctx.body = { success: true }
|
ctx.body = { success: true }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.status = 500
|
ctx.status = 500
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { readFile, writeFile, chmod } from 'fs/promises'
|
import { chmod } from 'fs/promises'
|
||||||
import { getActiveEnvPath } from '../../services/hermes/hermes-profile'
|
import { getActiveEnvPath } from '../../services/hermes/hermes-profile'
|
||||||
import { restartGateway } from '../../services/hermes/hermes-cli'
|
import { restartGateway } from '../../services/hermes/hermes-cli'
|
||||||
|
import { safeFileStore } from '../../services/safe-file-store'
|
||||||
|
|
||||||
const ILINK_BASE = 'https://ilinkai.weixin.qq.com'
|
const ILINK_BASE = 'https://ilinkai.weixin.qq.com'
|
||||||
const envPath = () => getActiveEnvPath()
|
const envPath = () => getActiveEnvPath()
|
||||||
@@ -38,10 +39,10 @@ export async function save(ctx: any) {
|
|||||||
const { account_id, token, base_url } = ctx.request.body as { account_id: string; token: string; base_url?: string }
|
const { account_id, token, base_url } = ctx.request.body as { account_id: string; token: string; base_url?: string }
|
||||||
if (!account_id || !token) { ctx.status = 400; ctx.body = { error: 'Missing account_id or token' }; return }
|
if (!account_id || !token) { ctx.status = 400; ctx.body = { error: 'Missing account_id or token' }; return }
|
||||||
try {
|
try {
|
||||||
let raw: string
|
|
||||||
try { raw = await readFile(envPath(), 'utf-8') } catch { raw = '' }
|
|
||||||
const entries: Record<string, string> = { WEIXIN_ACCOUNT_ID: account_id, WEIXIN_TOKEN: token }
|
const entries: Record<string, string> = { WEIXIN_ACCOUNT_ID: account_id, WEIXIN_TOKEN: token }
|
||||||
if (base_url) entries.WEIXIN_BASE_URL = base_url
|
if (base_url) entries.WEIXIN_BASE_URL = base_url
|
||||||
|
const ep = envPath()
|
||||||
|
await safeFileStore.updateText(ep, (raw) => {
|
||||||
const lines = raw.split('\n')
|
const lines = raw.split('\n')
|
||||||
const existingKeys = new Set<string>()
|
const existingKeys = new Set<string>()
|
||||||
const result: string[] = []
|
const result: string[] = []
|
||||||
@@ -56,9 +57,8 @@ export async function save(ctx: any) {
|
|||||||
result.push(line)
|
result.push(line)
|
||||||
}
|
}
|
||||||
for (const [key, val] of Object.entries(entries)) { if (!existingKeys.has(key)) { result.push(`${key}=${val}`) } }
|
for (const [key, val] of Object.entries(entries)) { if (!existingKeys.has(key)) { result.push(`${key}=${val}`) } }
|
||||||
let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n'
|
return result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n'
|
||||||
const ep = envPath()
|
})
|
||||||
await writeFile(ep, output, 'utf-8')
|
|
||||||
try { await chmod(ep, 0o600) } catch { }
|
try { await chmod(ep, 0o600) } catch { }
|
||||||
await restartGateway()
|
await restartGateway()
|
||||||
ctx.body = { success: true }
|
ctx.body = { success: true }
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { readFile, writeFile, copyFile, chmod } from 'fs/promises'
|
import { readFile, chmod } from 'fs/promises'
|
||||||
import { readdir, stat } from 'fs/promises'
|
import { readdir, stat } from 'fs/promises'
|
||||||
import { existsSync, readFileSync } from 'fs'
|
import { existsSync, readFileSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import YAML from 'js-yaml'
|
|
||||||
import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath } from './hermes/hermes-profile'
|
import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath } from './hermes/hermes-profile'
|
||||||
import { logger } from './logger'
|
import { logger } from './logger'
|
||||||
|
import { safeFileStore } from './safe-file-store'
|
||||||
|
|
||||||
// --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) ---
|
// --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) ---
|
||||||
export const PROVIDER_ENV_MAP: Record<string, { api_key_env: string; base_url_env: string }> = {
|
export const PROVIDER_ENV_MAP: Record<string, { api_key_env: string; base_url_env: string }> = {
|
||||||
@@ -72,32 +72,24 @@ export interface ModelGroup {
|
|||||||
const configPath = () => getActiveConfigPath()
|
const configPath = () => getActiveConfigPath()
|
||||||
|
|
||||||
export async function readConfigYaml(): Promise<Record<string, any>> {
|
export async function readConfigYaml(): Promise<Record<string, any>> {
|
||||||
const raw = await safeReadFile(configPath())
|
return safeFileStore.readYaml(configPath())
|
||||||
if (!raw) return {}
|
|
||||||
return (YAML.load(raw, { json: true }) as Record<string, any>) || {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeConfigYaml(config: Record<string, any>): Promise<void> {
|
export async function writeConfigYaml(config: Record<string, any>): Promise<void> {
|
||||||
const cp = configPath()
|
await safeFileStore.writeYaml(configPath(), config, { backup: true })
|
||||||
await copyFile(cp, cp + '.bak')
|
}
|
||||||
const yamlStr = YAML.dump(config, {
|
|
||||||
lineWidth: -1,
|
export async function updateConfigYaml<T = void>(
|
||||||
noRefs: true,
|
updater: (config: Record<string, any>) => Record<string, any> | { data: Record<string, any>; result: T; write?: boolean } | Promise<Record<string, any> | { data: Record<string, any>; result: T; write?: boolean }>,
|
||||||
quotingType: '"',
|
): Promise<T | undefined> {
|
||||||
})
|
return safeFileStore.updateYaml(configPath(), updater, { backup: true })
|
||||||
await writeFile(cp, yamlStr, 'utf-8')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- .env helpers ---
|
// --- .env helpers ---
|
||||||
|
|
||||||
export async function saveEnvValue(key: string, value: string): Promise<void> {
|
export async function saveEnvValue(key: string, value: string): Promise<void> {
|
||||||
const envPath = getActiveEnvPath()
|
const envPath = getActiveEnvPath()
|
||||||
let raw: string
|
await safeFileStore.updateText(envPath, (raw) => {
|
||||||
try {
|
|
||||||
raw = await readFile(envPath, 'utf-8')
|
|
||||||
} catch {
|
|
||||||
raw = ''
|
|
||||||
}
|
|
||||||
const remove = !value
|
const remove = !value
|
||||||
const lines = raw.split('\n')
|
const lines = raw.split('\n')
|
||||||
let found = false
|
let found = false
|
||||||
@@ -120,8 +112,8 @@ export async function saveEnvValue(key: string, value: string): Promise<void> {
|
|||||||
if (!found && !remove) {
|
if (!found && !remove) {
|
||||||
result.push(`${key}=${value}`)
|
result.push(`${key}=${value}`)
|
||||||
}
|
}
|
||||||
let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n'
|
return result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n'
|
||||||
await writeFile(envPath, output, 'utf-8')
|
})
|
||||||
try { await chmod(envPath, 0o600) } catch { /* ignore */ }
|
try { await chmod(envPath, 0o600) } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,39 +7,36 @@
|
|||||||
* 3. 启动/停止网关进程
|
* 3. 启动/停止网关进程
|
||||||
*
|
*
|
||||||
* 启动检测流程(detectStatus):
|
* 启动检测流程(detectStatus):
|
||||||
* ① 读取 gateway.pid → 获取 PID
|
* ① 读取 gateway.pid,缺失时回退读取 gateway_state.json → 获取 PID
|
||||||
* ② 读取 config.yaml (platforms.api_server.extra.port/host) → 获取配置端口
|
* ② 读取 config.yaml (platforms.api_server.extra.port/host) → 获取配置端口
|
||||||
* ③ PID 存活?
|
* ③ PID 存活且配置端口 health check 通过?
|
||||||
* - 否 → 标记为 stopped
|
* - 是 → 配置与运行状态匹配,注册网关
|
||||||
* - 是 → 继续
|
|
||||||
* ④ 对配置端口做 health check?
|
|
||||||
* - 通过 → 配置与运行状态匹配,注册网关
|
|
||||||
* - 失败 → 用 lsof 查 PID 实际监听端口
|
|
||||||
* ⑤ 实际端口 ≠ 配置端口?
|
|
||||||
* - 是 → 更新 config.yaml 到实际端口,重新 health check,通过则注册
|
|
||||||
* - 否 → 标记为 stopped
|
* - 否 → 标记为 stopped
|
||||||
*
|
*
|
||||||
|
* detectStatus 只做只读检测:不会认领未知端口上的进程,也不会探测实际监听端口后回写
|
||||||
|
* config.yaml。端口修正发生在启动前的 resolvePort 阶段。
|
||||||
|
*
|
||||||
* 端口分配流程(resolvePort,启动前调用):
|
* 端口分配流程(resolvePort,启动前调用):
|
||||||
* ① 读取配置端口
|
* ① 读取配置端口
|
||||||
* ② 检查是否被已管理的网关占用
|
* ② 如果内存记录或 PID 文件对应的配置端口仍健康运行,复用该端口
|
||||||
* ③ 检查是否被外部系统进程占用(TCP bind 测试)
|
* ③ 收集本轮已分配端口、其他已管理网关端口、Web UI 端口
|
||||||
* ④ 冲突则从 base+1 递增找空闲端口,并写入 config.yaml
|
* ④ 从 8642 起递增查找空闲端口,并写入 config.yaml
|
||||||
*
|
*
|
||||||
* 启动模式:
|
* 启动模式:
|
||||||
* - 正常系统(macOS/Linux):hermes gateway start/stop(系统服务管理)
|
* - 所有平台统一使用 `hermes gateway run --replace`
|
||||||
* - WSL / Docker:hermes gateway run(detached 子进程,手动 kill)
|
* - 停止时先尝试 `hermes gateway stop`,再根据 PID / 监听端口清理进程
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn, execSync, type ChildProcess } from 'child_process'
|
import { spawn, type ChildProcess } from 'child_process'
|
||||||
import { resolve, join } from 'path'
|
import { join } from 'path'
|
||||||
import { homedir } from 'os'
|
import { readFileSync, existsSync, readdirSync, unlinkSync } from 'fs'
|
||||||
import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs'
|
|
||||||
import { execFile } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import { createServer } from 'net'
|
import { createServer } from 'net'
|
||||||
import yaml from 'js-yaml'
|
import yaml from 'js-yaml'
|
||||||
import { logger } from '../logger'
|
import { logger } from '../logger'
|
||||||
import { detectHermesHome, getHermesBin } from './hermes-path'
|
import { detectHermesHome, getHermesBin } from './hermes-path'
|
||||||
|
import { safeFileStore } from '../safe-file-store'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
@@ -51,63 +48,6 @@ const HERMES_BASE = detectHermesHome()
|
|||||||
const HERMES_BIN = getHermesBin()
|
const HERMES_BIN = getHermesBin()
|
||||||
const DEFAULT_WEB_UI_PORT = 8648
|
const DEFAULT_WEB_UI_PORT = 8648
|
||||||
|
|
||||||
/**
|
|
||||||
* 检测系统的 init 系统(服务管理器)
|
|
||||||
* - macOS → launchd
|
|
||||||
* - Windows → windows-service
|
|
||||||
* - Linux → systemd / sysvinit / other
|
|
||||||
*
|
|
||||||
* 没有 systemd/launchd/windows-service 的环境需要用 "gateway run" 代替 "gateway start"
|
|
||||||
* (适用于 WSL/Docker/Termux/proot 等无服务管理器的环境)
|
|
||||||
*/
|
|
||||||
function detectInitSystem(): string {
|
|
||||||
const platform = process.platform
|
|
||||||
|
|
||||||
// macOS → launchd
|
|
||||||
if (platform === 'darwin') {
|
|
||||||
return 'launchd'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Windows → Service Manager
|
|
||||||
if (platform === 'win32') {
|
|
||||||
return 'windows-service'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Linux 才检查 /proc
|
|
||||||
if (platform === 'linux') {
|
|
||||||
try {
|
|
||||||
if (existsSync('/.dockerenv') || existsSync('/run/.containerenv')) {
|
|
||||||
return 'container'
|
|
||||||
}
|
|
||||||
|
|
||||||
const comm = readFileSync('/proc/1/comm', 'utf-8').trim()
|
|
||||||
|
|
||||||
if (comm === 'systemd') {
|
|
||||||
return existsSync('/run/systemd/system') ? 'systemd' : 'other'
|
|
||||||
}
|
|
||||||
if (comm === 'init') return 'sysvinit'
|
|
||||||
|
|
||||||
return 'other'
|
|
||||||
} catch {
|
|
||||||
return 'unknown'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注意:虽然此函数仍然存在,但当前所有平台都统一使用 run 模式
|
|
||||||
// 保留此函数是为了将来如果需要切换回 start/stop 模式时可以参考
|
|
||||||
const initSystem = detectInitSystem()
|
|
||||||
/**
|
|
||||||
* 所有平台统一使用 run 模式
|
|
||||||
* run 模式会自动处理锁定文件冲突(--replace 标志),更可靠
|
|
||||||
* 子进程跟随父进程生命周期,父进程关闭时子进程自动关闭
|
|
||||||
*/
|
|
||||||
const needsRunMode = true
|
|
||||||
// 启动时输出 init 系统检测结果(方便调试)
|
|
||||||
logger.debug('Detected init system: %s (needsRunMode: %s, platform: %s)', initSystem, needsRunMode, process.platform)
|
|
||||||
|
|
||||||
const GATEWAY_RUNTIME_ENV_KEYS = new Set([
|
const GATEWAY_RUNTIME_ENV_KEYS = new Set([
|
||||||
'PATH',
|
'PATH',
|
||||||
'HOME',
|
'HOME',
|
||||||
@@ -399,12 +339,10 @@ export class GatewayManager {
|
|||||||
* host: <host>
|
* host: <host>
|
||||||
* 同时清理旧的顶层 port/host(避免 Hermes 读取错误)
|
* 同时清理旧的顶层 port/host(避免 Hermes 读取错误)
|
||||||
*/
|
*/
|
||||||
private writeProfilePort(name: string, port: number, host: string): void {
|
private async writeProfilePort(name: string, port: number, host: string): Promise<void> {
|
||||||
const configPath = join(this.profileDir(name), 'config.yaml')
|
const configPath = join(this.profileDir(name), 'config.yaml')
|
||||||
try {
|
try {
|
||||||
const content = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : ''
|
await safeFileStore.updateYaml(configPath, (cfg) => {
|
||||||
const cfg = (yaml.load(content, { json: true }) as any) || {}
|
|
||||||
|
|
||||||
// 确保 platforms.api_server 结构存在(不会影响其他位置的 platforms)
|
// 确保 platforms.api_server 结构存在(不会影响其他位置的 platforms)
|
||||||
if (!cfg.platforms) cfg.platforms = {}
|
if (!cfg.platforms) cfg.platforms = {}
|
||||||
if (!cfg.platforms.api_server) cfg.platforms.api_server = {}
|
if (!cfg.platforms.api_server) cfg.platforms.api_server = {}
|
||||||
@@ -423,8 +361,8 @@ export class GatewayManager {
|
|||||||
if (cfg.platforms.api_server.host !== undefined) {
|
if (cfg.platforms.api_server.host !== undefined) {
|
||||||
delete cfg.platforms.api_server.host
|
delete cfg.platforms.api_server.host
|
||||||
}
|
}
|
||||||
|
return cfg
|
||||||
writeFileSync(configPath, yaml.dump(cfg, { lineWidth: -1 }), 'utf-8')
|
})
|
||||||
logger.debug('Updated %s: api_server.extra.port = %d', configPath, port)
|
logger.debug('Updated %s: api_server.extra.port = %d', configPath, port)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(err, 'Failed to write config for profile "%s"', name)
|
logger.error(err, 'Failed to write config for profile "%s"', name)
|
||||||
@@ -487,7 +425,7 @@ export class GatewayManager {
|
|||||||
} else {
|
} else {
|
||||||
logger.debug('Assigning port %d for profile "%s"', port, name)
|
logger.debug('Assigning port %d for profile "%s"', port, name)
|
||||||
}
|
}
|
||||||
this.writeProfilePort(name, port, host)
|
await this.writeProfilePort(name, port, host)
|
||||||
|
|
||||||
this.allocatedPorts.add(port)
|
this.allocatedPorts.add(port)
|
||||||
return { port, host }
|
return { port, host }
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { copyFile, mkdir, readFile, rename, rm, writeFile } from 'fs/promises'
|
||||||
|
import { dirname, resolve } from 'path'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import YAML, { type DumpOptions } from 'js-yaml'
|
||||||
|
|
||||||
|
type TextUpdater<T = void> = (current: string) => string | { content: string; result: T } | Promise<string | { content: string; result: T }>
|
||||||
|
type YamlUpdateResult<T> = { data: Record<string, any>; result: T; write?: boolean }
|
||||||
|
type YamlUpdater<T = void> = (current: Record<string, any>) => Record<string, any> | YamlUpdateResult<T> | Promise<Record<string, any> | YamlUpdateResult<T>>
|
||||||
|
|
||||||
|
export interface SafeWriteOptions {
|
||||||
|
backup?: boolean
|
||||||
|
backupPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafeYamlOptions extends SafeWriteOptions {
|
||||||
|
dumpOptions?: DumpOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextUpdateResult<T>(value: unknown): value is { content: string; result: T } {
|
||||||
|
return !!value && typeof value === 'object' && Object.hasOwn(value as Record<string, unknown>, 'content')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isYamlUpdateResult<T>(value: unknown): value is YamlUpdateResult<T> {
|
||||||
|
return !!value && typeof value === 'object' && Object.hasOwn(value as Record<string, unknown>, 'data')
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SafeFileStore {
|
||||||
|
private queues = new Map<string, Promise<unknown>>()
|
||||||
|
|
||||||
|
private normalizePath(filePath: string): string {
|
||||||
|
return resolve(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async withLock<T>(filePath: string, task: () => Promise<T>): Promise<T> {
|
||||||
|
const key = this.normalizePath(filePath)
|
||||||
|
const previous = this.queues.get(key) || Promise.resolve()
|
||||||
|
let release!: () => void
|
||||||
|
const current = new Promise<void>(resolve => { release = resolve })
|
||||||
|
const next = previous.then(() => current, () => current)
|
||||||
|
this.queues.set(key, next)
|
||||||
|
|
||||||
|
await previous.catch(() => undefined)
|
||||||
|
try {
|
||||||
|
return await task()
|
||||||
|
} finally {
|
||||||
|
release()
|
||||||
|
if (this.queues.get(key) === next) this.queues.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async readText(filePath: string): Promise<string> {
|
||||||
|
return readFile(this.normalizePath(filePath), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeText(filePath: string, content: string, options: SafeWriteOptions = {}): Promise<void> {
|
||||||
|
await this.withLock(filePath, () => this.writeTextUnlocked(filePath, content, options))
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateText<T = void>(filePath: string, updater: TextUpdater<T>, options: SafeWriteOptions = {}): Promise<T | undefined> {
|
||||||
|
return this.withLock(filePath, async () => {
|
||||||
|
let current = ''
|
||||||
|
try {
|
||||||
|
current = await this.readText(filePath)
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code !== 'ENOENT') throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updater(current)
|
||||||
|
const content = isTextUpdateResult<T>(updated) ? updated.content : updated
|
||||||
|
await this.writeTextUnlocked(filePath, content, options)
|
||||||
|
return isTextUpdateResult<T>(updated) ? updated.result : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async readYaml(filePath: string): Promise<Record<string, any>> {
|
||||||
|
try {
|
||||||
|
const raw = await this.readText(filePath)
|
||||||
|
return (YAML.load(raw, { json: true }) as Record<string, any>) || {}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'ENOENT') return {}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeYaml(filePath: string, data: Record<string, any>, options: SafeYamlOptions = {}): Promise<void> {
|
||||||
|
const yamlStr = YAML.dump(data, {
|
||||||
|
lineWidth: -1,
|
||||||
|
noRefs: true,
|
||||||
|
quotingType: '"',
|
||||||
|
...(options.dumpOptions || {}),
|
||||||
|
})
|
||||||
|
await this.writeText(filePath, yamlStr, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateYaml<T = void>(filePath: string, updater: YamlUpdater<T>, options: SafeYamlOptions = {}): Promise<T | undefined> {
|
||||||
|
return this.withLock(filePath, async () => {
|
||||||
|
let raw = ''
|
||||||
|
try {
|
||||||
|
raw = await this.readText(filePath)
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code !== 'ENOENT') throw err
|
||||||
|
}
|
||||||
|
const current = raw ? ((YAML.load(raw, { json: true }) as Record<string, any>) || {}) : {}
|
||||||
|
const updated = await updater(current)
|
||||||
|
const data = isYamlUpdateResult<T>(updated) ? updated.data : updated
|
||||||
|
if (isYamlUpdateResult<T>(updated) && updated.write === false) {
|
||||||
|
return updated.result
|
||||||
|
}
|
||||||
|
const yamlStr = YAML.dump(data, {
|
||||||
|
lineWidth: -1,
|
||||||
|
noRefs: true,
|
||||||
|
quotingType: '"',
|
||||||
|
...(options.dumpOptions || {}),
|
||||||
|
})
|
||||||
|
await this.writeTextUnlocked(filePath, yamlStr, options)
|
||||||
|
return isYamlUpdateResult<T>(updated) ? updated.result : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeTextUnlocked(filePath: string, content: string, options: SafeWriteOptions): Promise<void> {
|
||||||
|
const target = this.normalizePath(filePath)
|
||||||
|
const dir = dirname(target)
|
||||||
|
const temp = `${target}.tmp.${process.pid}.${Date.now()}.${randomUUID()}`
|
||||||
|
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
if (options.backup) {
|
||||||
|
try {
|
||||||
|
await copyFile(target, options.backupPath || `${target}.bak`)
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code !== 'ENOENT') throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(temp, content, 'utf-8')
|
||||||
|
await rename(temp, target)
|
||||||
|
} catch (err) {
|
||||||
|
await rm(temp, { force: true }).catch(() => undefined)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const safeFileStore = new SafeFileStore()
|
||||||
@@ -86,7 +86,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
value: 'glm-coding-plan',
|
value: 'glm-coding-plan',
|
||||||
builtin: true,
|
builtin: true,
|
||||||
base_url: 'https://api.z.ai/api/anthropic',
|
base_url: 'https://api.z.ai/api/anthropic',
|
||||||
models: ['glm-5.1', 'glm-5', 'glm-5-turbo', 'glm-4.7', 'glm-4.5', 'glm-4.5-flash'],
|
models: ['glm-5.1', 'glm-5', 'glm-5v-turbo', 'glm-5-turbo', 'glm-4.7', 'glm-4.5', 'glm-4.5-flash'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Kimi for Coding',
|
label: 'Kimi for Coding',
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import YAML from 'js-yaml'
|
||||||
|
|
||||||
|
const { mockGatewayManager } = vi.hoisted(() => ({
|
||||||
|
mockGatewayManager: {
|
||||||
|
getActiveProfile: vi.fn(() => 'default'),
|
||||||
|
stop: vi.fn().mockResolvedValue(undefined),
|
||||||
|
start: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../packages/server/src/services/gateway-bootstrap', () => ({
|
||||||
|
getGatewayManagerInstance: () => mockGatewayManager,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const originalHermesHome = process.env.HERMES_HOME
|
||||||
|
const tempHomes: string[] = []
|
||||||
|
let hermesHome = ''
|
||||||
|
|
||||||
|
async function loadController() {
|
||||||
|
vi.resetModules()
|
||||||
|
process.env.HERMES_HOME = hermesHome
|
||||||
|
return import('../../packages/server/src/controllers/hermes/config')
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(body: unknown): any {
|
||||||
|
return { request: { body }, query: {}, status: 200, body: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
hermesHome = await mkdtemp(join(tmpdir(), 'hermes-config-controller-'))
|
||||||
|
tempHomes.push(hermesHome)
|
||||||
|
await mkdir(hermesHome, { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.resetModules()
|
||||||
|
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
|
||||||
|
else process.env.HERMES_HOME = originalHermesHome
|
||||||
|
await Promise.all(tempHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||||
|
hermesHome = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('config controller locked file updates', () => {
|
||||||
|
it('deep merges a config section and restarts platform gateways', async () => {
|
||||||
|
await writeFile(join(hermesHome, 'config.yaml'), [
|
||||||
|
'telegram:',
|
||||||
|
' enabled: false',
|
||||||
|
' extra:',
|
||||||
|
' mode: old',
|
||||||
|
'model:',
|
||||||
|
' default: glm-5.1',
|
||||||
|
'',
|
||||||
|
].join('\n'), 'utf-8')
|
||||||
|
const { updateConfig } = await loadController()
|
||||||
|
const ctx = makeCtx({ section: 'telegram', values: { enabled: true, extra: { token_mode: 'env' } } })
|
||||||
|
|
||||||
|
await updateConfig(ctx)
|
||||||
|
|
||||||
|
expect(ctx.body).toEqual({ success: true })
|
||||||
|
expect(mockGatewayManager.stop).toHaveBeenCalledWith('default')
|
||||||
|
expect(mockGatewayManager.start).toHaveBeenCalledWith('default')
|
||||||
|
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
||||||
|
expect(config.telegram.enabled).toBe(true)
|
||||||
|
expect(config.telegram.extra).toEqual({ mode: 'old', token_mode: 'env' })
|
||||||
|
expect(config.model.default).toBe('glm-5.1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears credential env values and removes matching config fields without losing unrelated env keys', async () => {
|
||||||
|
await writeFile(join(hermesHome, 'config.yaml'), [
|
||||||
|
'platforms:',
|
||||||
|
' weixin:',
|
||||||
|
' token: old-token',
|
||||||
|
' extra:',
|
||||||
|
' account_id: old-account',
|
||||||
|
' base_url: https://old.example',
|
||||||
|
'model:',
|
||||||
|
' default: glm-5.1',
|
||||||
|
'',
|
||||||
|
].join('\n'), 'utf-8')
|
||||||
|
await writeFile(join(hermesHome, '.env'), [
|
||||||
|
'OPENROUTER_API_KEY=keep',
|
||||||
|
'WEIXIN_TOKEN=old-token',
|
||||||
|
'WEIXIN_ACCOUNT_ID=old-account',
|
||||||
|
'',
|
||||||
|
].join('\n'), 'utf-8')
|
||||||
|
const { updateCredentials } = await loadController()
|
||||||
|
const ctx = makeCtx({ platform: 'weixin', values: { token: '', extra: { account_id: '', base_url: 'https://new.example' } } })
|
||||||
|
|
||||||
|
await updateCredentials(ctx)
|
||||||
|
|
||||||
|
expect(ctx.body).toEqual({ success: true })
|
||||||
|
const env = await readFile(join(hermesHome, '.env'), 'utf-8')
|
||||||
|
expect(env).toContain('OPENROUTER_API_KEY=keep')
|
||||||
|
expect(env).not.toContain('WEIXIN_TOKEN=')
|
||||||
|
expect(env).not.toContain('WEIXIN_ACCOUNT_ID=')
|
||||||
|
expect(env).toContain('WEIXIN_BASE_URL=https://new.example')
|
||||||
|
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
||||||
|
expect(config.platforms.weixin.token).toBeUndefined()
|
||||||
|
expect(config.platforms.weixin.extra.account_id).toBeUndefined()
|
||||||
|
expect(config.platforms.weixin.extra.base_url).toBe('https://old.example')
|
||||||
|
expect(config.model.default).toBe('glm-5.1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import YAML from 'js-yaml'
|
||||||
|
|
||||||
|
const originalHermesHome = process.env.HERMES_HOME
|
||||||
|
const tempHomes: string[] = []
|
||||||
|
let hermesHome = ''
|
||||||
|
|
||||||
|
async function loadHelpers() {
|
||||||
|
vi.resetModules()
|
||||||
|
process.env.HERMES_HOME = hermesHome
|
||||||
|
return import('../../packages/server/src/services/config-helpers')
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
hermesHome = await mkdtemp(join(tmpdir(), 'hermes-config-helpers-'))
|
||||||
|
tempHomes.push(hermesHome)
|
||||||
|
await mkdir(hermesHome, { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.resetModules()
|
||||||
|
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
|
||||||
|
else process.env.HERMES_HOME = originalHermesHome
|
||||||
|
await Promise.all(tempHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||||
|
hermesHome = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('config-helpers locked file updates', () => {
|
||||||
|
it('merges concurrent config.yaml updates by re-reading under the file lock', async () => {
|
||||||
|
await writeFile(join(hermesHome, 'config.yaml'), 'model:\n default: old\n', 'utf-8')
|
||||||
|
const { updateConfigYaml } = await loadHelpers()
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
updateConfigYaml(async (cfg) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 25))
|
||||||
|
cfg.model.default = 'glm-5.1'
|
||||||
|
return cfg
|
||||||
|
}),
|
||||||
|
updateConfigYaml((cfg) => {
|
||||||
|
cfg.platforms = cfg.platforms || {}
|
||||||
|
cfg.platforms.api_server = { extra: { port: 8648 } }
|
||||||
|
return cfg
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
||||||
|
expect(config.model.default).toBe('glm-5.1')
|
||||||
|
expect(config.platforms.api_server.extra.port).toBe(8648)
|
||||||
|
await expect(readFile(join(hermesHome, 'config.yaml.bak'), 'utf-8')).resolves.toContain('model:')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes concurrent .env updates without losing keys', async () => {
|
||||||
|
await writeFile(join(hermesHome, '.env'), 'OPENROUTER_API_KEY=keep\n', 'utf-8')
|
||||||
|
const { saveEnvValue } = await loadHelpers()
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
saveEnvValue('DEEPSEEK_API_KEY', 'deepseek'),
|
||||||
|
saveEnvValue('MOONSHOT_API_KEY', 'moonshot'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const env = await readFile(join(hermesHome, '.env'), 'utf-8')
|
||||||
|
expect(env).toContain('OPENROUTER_API_KEY=keep')
|
||||||
|
expect(env).toContain('DEEPSEEK_API_KEY=deepseek')
|
||||||
|
expect(env).toContain('MOONSHOT_API_KEY=moonshot')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips writing config.yaml when an updater returns write false', async () => {
|
||||||
|
const configPath = join(hermesHome, 'config.yaml')
|
||||||
|
await writeFile(configPath, 'model:\n default: old\n', 'utf-8')
|
||||||
|
const before = await readFile(configPath, 'utf-8')
|
||||||
|
const { updateConfigYaml } = await loadHelpers()
|
||||||
|
|
||||||
|
const result = await updateConfigYaml((cfg) => ({ data: cfg, result: 'unchanged', write: false }))
|
||||||
|
|
||||||
|
expect(result).toBe('unchanged')
|
||||||
|
await expect(readFile(configPath, 'utf-8')).resolves.toBe(before)
|
||||||
|
await expect(readFile(`${configPath}.bak`, 'utf-8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import YAML from 'js-yaml'
|
||||||
|
|
||||||
|
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||||
|
pinSkill: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
|
||||||
|
getSkillUsageStatsFromDb: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../packages/server/src/db', () => ({
|
||||||
|
getDb: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../packages/server/src/db/hermes/schemas', () => ({
|
||||||
|
MODEL_CONTEXT_TABLE: 'model_context',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const originalHermesHome = process.env.HERMES_HOME
|
||||||
|
const tempHomes: string[] = []
|
||||||
|
let hermesHome = ''
|
||||||
|
|
||||||
|
async function loadModelsController() {
|
||||||
|
vi.resetModules()
|
||||||
|
process.env.HERMES_HOME = hermesHome
|
||||||
|
return import('../../packages/server/src/controllers/hermes/models')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSkillsController() {
|
||||||
|
vi.resetModules()
|
||||||
|
process.env.HERMES_HOME = hermesHome
|
||||||
|
return import('../../packages/server/src/controllers/hermes/skills')
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(body: unknown): any {
|
||||||
|
return { request: { body }, status: 200, body: undefined, query: {}, params: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
hermesHome = await mkdtemp(join(tmpdir(), 'hermes-config-controller-'))
|
||||||
|
tempHomes.push(hermesHome)
|
||||||
|
await mkdir(hermesHome, { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.resetModules()
|
||||||
|
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
|
||||||
|
else process.env.HERMES_HOME = originalHermesHome
|
||||||
|
await Promise.all(tempHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||||
|
hermesHome = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('config mutating controllers', () => {
|
||||||
|
it('setConfigModel updates only the model section and preserves existing config', async () => {
|
||||||
|
await writeFile(join(hermesHome, 'config.yaml'), [
|
||||||
|
'terminal:',
|
||||||
|
' backend: local',
|
||||||
|
'model:',
|
||||||
|
' default: old',
|
||||||
|
' provider: old-provider',
|
||||||
|
'',
|
||||||
|
].join('\n'), 'utf-8')
|
||||||
|
const { setConfigModel } = await loadModelsController()
|
||||||
|
const ctx = makeCtx({ default: 'glm-5.1', provider: 'custom:glm' })
|
||||||
|
|
||||||
|
await setConfigModel(ctx)
|
||||||
|
|
||||||
|
expect(ctx.body).toEqual({ success: true })
|
||||||
|
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
||||||
|
expect(config.model).toEqual({ default: 'glm-5.1', provider: 'custom:glm' })
|
||||||
|
expect(config.terminal.backend).toBe('local')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skill toggle preserves unrelated config while adding and removing disabled skills', async () => {
|
||||||
|
await writeFile(join(hermesHome, 'config.yaml'), [
|
||||||
|
'model:',
|
||||||
|
' default: glm-5.1',
|
||||||
|
'skills:',
|
||||||
|
' disabled:',
|
||||||
|
' - old-skill',
|
||||||
|
'',
|
||||||
|
].join('\n'), 'utf-8')
|
||||||
|
const { toggle } = await loadSkillsController()
|
||||||
|
|
||||||
|
await toggle(makeCtx({ name: 'new-skill', enabled: false }))
|
||||||
|
await toggle(makeCtx({ name: 'old-skill', enabled: true }))
|
||||||
|
|
||||||
|
const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
|
||||||
|
expect(config.model.default).toBe('glm-5.1')
|
||||||
|
expect(config.skills.disabled).toEqual(['new-skill'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,13 +5,14 @@ vi.mock('os', async () => {
|
|||||||
return { ...actual, homedir: () => '/fake/home' }
|
return { ...actual, homedir: () => '/fake/home' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const { mockReadFile, mockWriteFile, mockMkdir, mockSaveEnvValue, mockReadConfigYaml, mockWriteConfigYaml, mockResolveWithSource, mockInvalidate, mockReadAppConfig, mockWriteAppConfig } = vi.hoisted(() => ({
|
const { mockReadFile, mockWriteFile, mockMkdir, mockSaveEnvValue, mockReadConfigYaml, mockWriteConfigYaml, mockUpdateConfigYaml, mockResolveWithSource, mockInvalidate, mockReadAppConfig, mockWriteAppConfig } = vi.hoisted(() => ({
|
||||||
mockReadFile: vi.fn(),
|
mockReadFile: vi.fn(),
|
||||||
mockWriteFile: vi.fn().mockResolvedValue(undefined),
|
mockWriteFile: vi.fn().mockResolvedValue(undefined),
|
||||||
mockMkdir: vi.fn().mockResolvedValue(undefined),
|
mockMkdir: vi.fn().mockResolvedValue(undefined),
|
||||||
mockSaveEnvValue: vi.fn().mockResolvedValue(undefined),
|
mockSaveEnvValue: vi.fn().mockResolvedValue(undefined),
|
||||||
mockReadConfigYaml: vi.fn(),
|
mockReadConfigYaml: vi.fn(),
|
||||||
mockWriteConfigYaml: vi.fn().mockResolvedValue(undefined),
|
mockWriteConfigYaml: vi.fn().mockResolvedValue(undefined),
|
||||||
|
mockUpdateConfigYaml: vi.fn(),
|
||||||
mockResolveWithSource: vi.fn(),
|
mockResolveWithSource: vi.fn(),
|
||||||
mockInvalidate: vi.fn(),
|
mockInvalidate: vi.fn(),
|
||||||
mockReadAppConfig: vi.fn(),
|
mockReadAppConfig: vi.fn(),
|
||||||
@@ -28,6 +29,7 @@ vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
|||||||
saveEnvValue: mockSaveEnvValue,
|
saveEnvValue: mockSaveEnvValue,
|
||||||
readConfigYaml: mockReadConfigYaml,
|
readConfigYaml: mockReadConfigYaml,
|
||||||
writeConfigYaml: mockWriteConfigYaml,
|
writeConfigYaml: mockWriteConfigYaml,
|
||||||
|
updateConfigYaml: mockUpdateConfigYaml,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../packages/server/src/services/hermes/copilot-models', () => ({
|
vi.mock('../../packages/server/src/services/hermes/copilot-models', () => ({
|
||||||
@@ -58,6 +60,17 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockReadFile.mockResolvedValue('')
|
mockReadFile.mockResolvedValue('')
|
||||||
mockReadConfigYaml.mockResolvedValue({})
|
mockReadConfigYaml.mockResolvedValue({})
|
||||||
|
mockUpdateConfigYaml.mockImplementation(async (updater: any) => {
|
||||||
|
const cfg = await mockReadConfigYaml()
|
||||||
|
const updated = await updater(cfg)
|
||||||
|
if (updated && typeof updated === 'object' && Object.hasOwn(updated, 'data')) {
|
||||||
|
if (updated.write === false) return updated.result
|
||||||
|
await mockWriteConfigYaml(updated.data)
|
||||||
|
return updated.result
|
||||||
|
}
|
||||||
|
await mockWriteConfigYaml(updated)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import YAML from 'js-yaml'
|
||||||
|
import { SafeFileStore } from '../../packages/server/src/services/safe-file-store'
|
||||||
|
|
||||||
|
const tempDirs: string[] = []
|
||||||
|
|
||||||
|
async function tempFile(name: string): Promise<string> {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'hermes-safe-file-store-'))
|
||||||
|
tempDirs.push(dir)
|
||||||
|
return join(dir, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SafeFileStore', () => {
|
||||||
|
it('serializes concurrent YAML read-modify-write updates for the same file', async () => {
|
||||||
|
const store = new SafeFileStore()
|
||||||
|
const file = await tempFile('config.yaml')
|
||||||
|
await writeFile(file, 'model:\n default: old\n', 'utf-8')
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
store.updateYaml(file, async (cfg) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 25))
|
||||||
|
cfg.model.default = 'glm-5.1'
|
||||||
|
return cfg
|
||||||
|
}),
|
||||||
|
store.updateYaml(file, (cfg) => {
|
||||||
|
cfg.platforms = cfg.platforms || {}
|
||||||
|
cfg.platforms.api_server = { extra: { port: 8648 } }
|
||||||
|
return cfg
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = YAML.load(await readFile(file, 'utf-8')) as any
|
||||||
|
expect(result.model.default).toBe('glm-5.1')
|
||||||
|
expect(result.platforms.api_server.extra.port).toBe(8648)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('backs up the previous content and writes through a temporary file', async () => {
|
||||||
|
const store = new SafeFileStore()
|
||||||
|
const file = await tempFile('config.yaml')
|
||||||
|
await writeFile(file, 'model:\n default: old\n', 'utf-8')
|
||||||
|
|
||||||
|
await store.writeText(file, 'model:\n default: new\n', { backup: true })
|
||||||
|
|
||||||
|
await expect(readFile(`${file}.bak`, 'utf-8')).resolves.toContain('default: old')
|
||||||
|
await expect(readFile(file, 'utf-8')).resolves.toContain('default: new')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const { mockRestartGateway } = vi.hoisted(() => ({
|
||||||
|
mockRestartGateway: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||||
|
restartGateway: mockRestartGateway,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const originalHermesHome = process.env.HERMES_HOME
|
||||||
|
const tempHomes: string[] = []
|
||||||
|
let hermesHome = ''
|
||||||
|
|
||||||
|
async function loadController() {
|
||||||
|
vi.resetModules()
|
||||||
|
process.env.HERMES_HOME = hermesHome
|
||||||
|
return import('../../packages/server/src/controllers/hermes/weixin')
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(body: unknown): any {
|
||||||
|
return { request: { body }, status: 200, body: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
hermesHome = await mkdtemp(join(tmpdir(), 'hermes-weixin-controller-'))
|
||||||
|
tempHomes.push(hermesHome)
|
||||||
|
await mkdir(hermesHome, { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.resetModules()
|
||||||
|
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
|
||||||
|
else process.env.HERMES_HOME = originalHermesHome
|
||||||
|
await Promise.all(tempHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||||
|
hermesHome = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('weixin controller save', () => {
|
||||||
|
it('updates .env through the locked file store and preserves unrelated keys', async () => {
|
||||||
|
await writeFile(join(hermesHome, '.env'), [
|
||||||
|
'OPENROUTER_API_KEY=keep',
|
||||||
|
'WEIXIN_TOKEN=old-token',
|
||||||
|
'',
|
||||||
|
].join('\n'), 'utf-8')
|
||||||
|
const { save } = await loadController()
|
||||||
|
const ctx = makeCtx({ account_id: 'acct-1', token: 'new-token', base_url: 'https://weixin.local' })
|
||||||
|
|
||||||
|
await save(ctx)
|
||||||
|
|
||||||
|
expect(ctx.body).toEqual({ success: true })
|
||||||
|
expect(mockRestartGateway).toHaveBeenCalled()
|
||||||
|
const env = await readFile(join(hermesHome, '.env'), 'utf-8')
|
||||||
|
expect(env).toContain('OPENROUTER_API_KEY=keep')
|
||||||
|
expect(env).toContain('WEIXIN_ACCOUNT_ID=acct-1')
|
||||||
|
expect(env).toContain('WEIXIN_TOKEN=new-token')
|
||||||
|
expect(env).toContain('WEIXIN_BASE_URL=https://weixin.local')
|
||||||
|
expect(env).not.toContain('old-token')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing required credentials without touching .env', async () => {
|
||||||
|
await writeFile(join(hermesHome, '.env'), 'OPENROUTER_API_KEY=keep\n', 'utf-8')
|
||||||
|
const { save } = await loadController()
|
||||||
|
const ctx = makeCtx({ account_id: 'acct-1' })
|
||||||
|
|
||||||
|
await save(ctx)
|
||||||
|
|
||||||
|
expect(ctx.status).toBe(400)
|
||||||
|
expect(ctx.body).toEqual({ error: 'Missing account_id or token' })
|
||||||
|
expect(mockRestartGateway).not.toHaveBeenCalled()
|
||||||
|
await expect(readFile(join(hermesHome, '.env'), 'utf-8')).resolves.toBe('OPENROUTER_API_KEY=keep\n')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user