Files
Hermes-ui/packages/server/src/controllers/hermes/profiles.ts
T

747 lines
25 KiB
TypeScript
Raw Normal View History

import { createReadStream, existsSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'fs'
import { mkdir, writeFile } from 'fs/promises'
import { basename, join } from 'path'
import { tmpdir } from 'os'
import { getWebUiHome } from '../../config'
import * as hermesCli from '../../services/hermes/hermes-cli'
import { SessionDeleter } from '../../services/hermes/session-deleter'
import { AgentBridgeClient } from '../../services/hermes/agent-bridge'
2026-05-20 12:59:34 +08:00
import {
getGatewayRuntimeStatusForProfile,
restartGatewayForProfile as restartGatewayRuntimeForProfile,
} from '../../services/hermes/gateway-autostart'
import { logger } from '../../services/logger'
import { smartCloneCleanup } from '../../services/hermes/profile-credentials'
import { detectHermesRootHome } from '../../services/hermes/hermes-path'
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
import { HermesSkillInjector } from '../../services/hermes/skill-injector'
import type { HermesProfile } from '../../services/hermes/hermes-cli'
2026-05-23 18:44:53 +08:00
import { listUserProfiles } from '../../db/hermes/users-store'
2026-05-20 10:02:15 +08:00
const bridgeCleanupClient = () => new AgentBridgeClient({ connectRetryMs: 0, timeoutMs: 5000 })
interface ProfileAvatarMeta {
type: 'generated' | 'image'
seed?: string
file?: string
mime?: string
updatedAt?: number
}
interface ProfileAvatarResponse {
type: 'generated' | 'image'
seed?: string
dataUrl?: string
updatedAt?: number
}
const RESERVED_PROFILE_NAMES = new Set([
'hermes', 'default', 'test', 'tmp', 'root', 'sudo',
])
const HERMES_SUBCOMMAND_PROFILE_NAMES = new Set([
'chat', 'model', 'gateway', 'setup', 'whatsapp', 'login', 'logout',
'status', 'cron', 'doctor', 'dump', 'config', 'pairing', 'skills', 'tools',
'mcp', 'sessions', 'insights', 'version', 'update', 'uninstall',
'profile', 'plugins', 'honcho', 'acp',
])
function normalizeProfileName(name: string): string {
return String(name || '').trim().toLowerCase()
}
function isForbiddenProfileName(name: string): boolean {
const normalized = normalizeProfileName(name)
if (!normalized || normalized === 'default') return false
return RESERVED_PROFILE_NAMES.has(normalized) || HERMES_SUBCOMMAND_PROFILE_NAMES.has(normalized)
}
function getActiveProfileFile(): string {
return join(detectHermesRootHome(), 'active_profile')
}
function listProfilesFromDisk(activeProfileName: string): HermesProfile[] {
const base = detectHermesRootHome()
const profiles: HermesProfile[] = [{
name: 'default',
active: activeProfileName === 'default',
model: '—',
alias: '',
}]
const profilesDir = join(base, 'profiles')
if (!existsSync(profilesDir)) return profiles
for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const name = entry.name
const dir = join(profilesDir, name)
if (!existsSync(join(dir, 'config.yaml')) && !existsSync(dir)) continue
profiles.push({
name,
active: name === activeProfileName,
model: '—',
alias: '',
})
}
return profiles
}
2026-05-14 09:03:57 +08:00
function profileExistsForManualSwitch(name: string): boolean {
const base = detectHermesRootHome()
2026-05-14 09:03:57 +08:00
if (!name || name === 'default') return true
return existsSync(join(base, 'profiles', name, 'config.yaml')) || existsSync(join(base, 'profiles', name))
}
async function injectBundledSkillsForProfile(name: string): Promise<void> {
try {
const targetDir = HermesSkillInjector.resolveTargetDirForProfile(name)
const result = await new HermesSkillInjector(undefined, targetDir).injectMissingSkills()
const target = result.targets[0]
if (target && (target.injected.length > 0 || target.updated.length > 0)) {
logger.info({
profile: name,
targetDir,
injected: target.injected,
updated: target.updated,
}, '[profiles] synced bundled skills for profile')
}
} catch (err: any) {
logger.warn(err, '[profiles] failed to sync bundled skills for profile "%s"', name)
}
}
function deleteForbiddenProfileFromDisk(name: string): boolean {
if (!isForbiddenProfileName(name)) return false
const base = detectHermesRootHome()
const profileDir = join(base, 'profiles', name)
if (!existsSync(profileDir)) return false
rmSync(profileDir, { recursive: true, force: true })
try {
if (normalizeProfileName(getActiveProfileName()) === normalizeProfileName(name)) {
writeFileSync(getActiveProfileFile(), 'default\n', 'utf-8')
}
} catch {}
logger.warn('[deleteProfile] removed reserved profile "%s" from disk after Hermes CLI rejected deletion', name)
return true
}
2026-05-20 12:59:34 +08:00
function filterVisibleProfiles(profiles: HermesProfile[]): HermesProfile[] {
return profiles.filter(profile => !isForbiddenProfileName(profile.name))
}
2026-05-23 18:44:53 +08:00
function requestedProfileName(ctx: any): string {
return ctx.state?.profile?.name || ctx.get?.('x-hermes-profile') || getActiveProfileName()
}
function filterProfilesForUser(ctx: any, profiles: HermesProfile[]): HermesProfile[] {
const user = ctx.state?.user
if (!user || user.role === 'super_admin') return profiles
const allowed = new Set(listUserProfiles(user.id).map(profile => profile.profile_name))
return profiles.filter(profile => allowed.has(profile.name))
}
function canAccessProfile(ctx: any, profileName: string): boolean {
const user = ctx.state?.user
if (!user || user.role === 'super_admin') return true
return listUserProfiles(user.id).some(profile => profile.profile_name === profileName)
}
function denyProfile(ctx: any, profileName: string): boolean {
if (canAccessProfile(ctx, profileName)) return false
ctx.status = 403
ctx.body = { error: `Profile "${profileName}" is not available for this user` }
return true
}
function profileMetadataRoot(): string {
return join(getWebUiHome(), 'profile-metadata')
}
function profileMetadataDir(name: string): string {
const segment = Buffer.from(name || 'default', 'utf-8').toString('base64url')
return join(profileMetadataRoot(), segment)
}
function profileAvatarMetaPath(name: string): string {
return join(profileMetadataDir(name), 'avatar.json')
}
function profileAvatarImagePath(name: string, file = 'avatar.bin'): string {
return join(profileMetadataDir(name), file)
}
function readProfileAvatar(name: string): ProfileAvatarResponse | null {
const metaPath = profileAvatarMetaPath(name)
if (!existsSync(metaPath)) return null
try {
const meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as ProfileAvatarMeta
if (meta.type === 'generated') {
return {
type: 'generated',
seed: typeof meta.seed === 'string' ? meta.seed : name,
updatedAt: meta.updatedAt,
}
}
if (meta.type === 'image' && meta.file && meta.mime) {
const imagePath = profileAvatarImagePath(name, meta.file)
if (!existsSync(imagePath)) return null
const data = readFileSync(imagePath).toString('base64')
return {
type: 'image',
dataUrl: `data:${meta.mime};base64,${data}`,
updatedAt: meta.updatedAt,
}
}
} catch (err) {
logger.warn(err, '[profiles] failed to read avatar metadata for profile "%s"', name)
}
return null
}
function attachProfileAvatars<T extends HermesProfile>(profiles: T[]): Array<T & { avatar: ProfileAvatarResponse | null }> {
return profiles.map(profile => ({
...profile,
avatar: readProfileAvatar(profile.name),
}))
}
function parseAvatarDataUrl(dataUrl: string): { mime: string; buffer: Buffer } {
const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp));base64,([a-zA-Z0-9+/=]+)$/)
if (!match) throw new Error('Avatar image must be a PNG, JPEG, or WebP data URL')
const buffer = Buffer.from(match[2], 'base64')
if (buffer.length > 1024 * 1024) throw new Error('Avatar image must be 1MB or smaller')
return { mime: match[1], buffer }
}
function removeProfileMetadata(name: string): void {
rmSync(profileMetadataDir(name), { recursive: true, force: true })
}
function renameProfileMetadata(oldName: string, newName: string): void {
const oldDir = profileMetadataDir(oldName)
const newDir = profileMetadataDir(newName)
if (!existsSync(oldDir) || oldDir === newDir) return
rmSync(newDir, { recursive: true, force: true })
renameSync(oldDir, newDir)
}
2026-05-14 09:03:57 +08:00
async function useProfileWithFallback(name: string): Promise<string> {
if (isForbiddenProfileName(name)) {
throw new Error(`Profile name '${name}' is reserved and cannot be activated`)
}
2026-05-14 09:03:57 +08:00
try {
return await hermesCli.useProfile(name)
} catch (err: any) {
if (!profileExistsForManualSwitch(name)) throw err
const base = detectHermesRootHome()
2026-05-14 09:03:57 +08:00
writeFileSync(join(base, 'active_profile'), `${name}\n`, 'utf-8')
logger.warn(err, '[switchProfile] hermes profile use failed; wrote active_profile directly for existing profile "%s"', name)
return `Switched to profile ${name}`
}
}
2026-05-20 12:59:34 +08:00
async function readBridgeWorkers(): Promise<{ reachable: boolean; workers: Record<string, boolean>; error?: string }> {
try {
const result = await new AgentBridgeClient({ timeoutMs: 5000 }).ping()
return {
reachable: true,
workers: ((result as any).workers || {}) as Record<string, boolean>,
}
} catch (err: any) {
return {
reachable: false,
workers: {},
error: err?.message || 'Bridge broker is not reachable',
}
}
}
function gatewayStatusLooksRunning(status?: string): boolean {
const normalized = String(status || '').trim().toLowerCase()
if (!normalized || normalized === '—') return false
if (normalized.includes('not running') || normalized === 'stopped' || normalized === 'stop') return false
return normalized.includes('running') || normalized === 'active'
}
async function buildRuntimeStatus(profile: HermesProfile | string, bridgeState?: Awaited<ReturnType<typeof readBridgeWorkers>>) {
const name = typeof profile === 'string' ? profile : profile.name
const bridge = bridgeState || await readBridgeWorkers()
let gateway: { running: boolean; profile: string; error?: string }
if (typeof profile !== 'string' && profile.gatewayStatus !== undefined) {
const profileListRunning = gatewayStatusLooksRunning(profile.gatewayStatus)
if (profileListRunning) {
gateway = {
running: true,
profile: name,
}
} else {
try {
gateway = await getGatewayRuntimeStatusForProfile(name)
} catch (err: any) {
gateway = {
running: false,
profile: name,
error: err?.message || 'Gateway status check failed',
}
}
2026-05-20 12:59:34 +08:00
}
} else {
try {
gateway = await getGatewayRuntimeStatusForProfile(name)
} catch (err: any) {
gateway = {
running: false,
profile: name,
error: err?.message || 'Gateway status check failed',
}
}
}
return {
profile: name,
bridge: {
running: !!bridge.workers[name],
profile: name,
reachable: bridge.reachable,
error: bridge.reachable ? undefined : bridge.error,
},
gateway,
}
}
export async function list(ctx: any) {
try {
let profiles: HermesProfile[]
try {
profiles = await hermesCli.listProfiles()
} catch (err: any) {
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
const activeProfileName = getActiveProfileName()
if (!isForbiddenProfileName(activeProfileName)) throw err
logger.warn(err, '[listProfiles] active_profile "%s" is invalid/reserved; resetting to default and listing profiles from disk', activeProfileName)
writeFileSync(getActiveProfileFile(), 'default\n', 'utf-8')
profiles = listProfilesFromDisk('default')
}
2026-05-04 12:46:26 +08:00
2026-05-23 18:44:53 +08:00
const activeProfileName = requestedProfileName(ctx)
2026-05-04 12:46:26 +08:00
2026-05-20 12:59:34 +08:00
profiles = filterVisibleProfiles(profiles)
2026-05-23 18:44:53 +08:00
profiles = filterProfilesForUser(ctx, profiles)
2026-05-20 12:59:34 +08:00
2026-05-23 18:44:53 +08:00
// Web UI active profile is request-scoped and comes from X-Hermes-Profile.
2026-05-04 12:46:26 +08:00
profiles.forEach(p => {
p.active = (p.name === activeProfileName)
})
ctx.body = { profiles: attachProfileAvatars(profiles) }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function create(ctx: any) {
const { name, clone } = ctx.request.body as { name?: string; clone?: boolean }
if (!name) {
ctx.status = 400
ctx.body = { error: 'Missing profile name' }
return
}
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved and cannot be created` }
return
}
try {
const output = await hermesCli.createProfile(name, clone)
// clone=true 时执行智能清理:
// - 删除 .env 中的独占平台凭据(Weixin / Telegram / Slack / ...
// - 禁用 config.yaml 中对应的平台节点
// 避免新 profile 与源 profile 共享同一个 bot token 导致互斥冲突。
let strippedCredentials: string[] = []
let disabledPlatforms: string[] = []
let strippedConfigCredentials: string[] = []
if (clone) {
try {
const cleanup = smartCloneCleanup(name)
strippedCredentials = cleanup.strippedCredentials
disabledPlatforms = cleanup.disabledPlatforms
strippedConfigCredentials = cleanup.strippedConfigCredentials
if (
strippedCredentials.length > 0 ||
disabledPlatforms.length > 0 ||
strippedConfigCredentials.length > 0
) {
logger.info(
'Smart clone cleanup for "%s": stripped %d env credentials (%s), disabled %d platforms (%s), stripped %d config credentials (%s)',
name,
strippedCredentials.length, strippedCredentials.join(','),
disabledPlatforms.length, disabledPlatforms.join(','),
strippedConfigCredentials.length, strippedConfigCredentials.join(','),
)
}
} catch (err: any) {
// 清理失败不应阻断 profile 创建,仅记日志
logger.error(err, 'Smart clone cleanup failed for "%s"', name)
}
}
await injectBundledSkillsForProfile(name)
ctx.body = {
success: true,
message: output.trim(),
strippedCredentials,
disabledPlatforms,
strippedConfigCredentials,
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function get(ctx: any) {
2026-05-23 18:44:53 +08:00
const name = String(ctx.params.name || '').trim() || 'default'
if (denyProfile(ctx, name)) return
try {
2026-05-23 18:44:53 +08:00
const profile = await hermesCli.getProfile(name)
ctx.body = { profile: { ...profile, avatar: readProfileAvatar(profile.name) } }
} catch (err: any) {
ctx.status = err.message.includes('not found') ? 404 : 500
ctx.body = { error: err.message }
}
}
export async function updateAvatar(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
2026-05-23 18:44:53 +08:00
if (denyProfile(ctx, name)) return
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved` }
return
}
const body = ctx.request.body as { type?: string; seed?: string; dataUrl?: string }
try {
const dir = profileMetadataDir(name)
await mkdir(dir, { recursive: true })
const updatedAt = Date.now()
if (body.type === 'generated') {
const seed = String(body.seed || name).trim() || name
const meta: ProfileAvatarMeta = { type: 'generated', seed, updatedAt }
rmSync(profileAvatarImagePath(name), { force: true })
await writeFile(profileAvatarMetaPath(name), JSON.stringify(meta, null, 2) + '\n', { mode: 0o600 })
ctx.body = { avatar: readProfileAvatar(name) }
return
}
if (body.type === 'image' && typeof body.dataUrl === 'string') {
const { mime, buffer } = parseAvatarDataUrl(body.dataUrl)
const meta: ProfileAvatarMeta = { type: 'image', file: 'avatar.bin', mime, updatedAt }
await writeFile(profileAvatarImagePath(name), buffer, { mode: 0o600 })
await writeFile(profileAvatarMetaPath(name), JSON.stringify(meta, null, 2) + '\n', { mode: 0o600 })
ctx.body = { avatar: readProfileAvatar(name) }
return
}
ctx.status = 400
ctx.body = { error: 'Invalid avatar payload' }
} catch (err: any) {
ctx.status = 400
ctx.body = { error: err.message }
}
}
export async function deleteAvatar(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
2026-05-23 18:44:53 +08:00
if (denyProfile(ctx, name)) return
try {
removeProfileMetadata(name)
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
2026-05-20 12:59:34 +08:00
export async function runtimeStatus(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
2026-05-23 18:44:53 +08:00
if (denyProfile(ctx, name)) return
2026-05-20 12:59:34 +08:00
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved` }
return
}
try {
const profiles = await listProfilesForStatus()
const profile = profiles.find(item => item.name === name)
ctx.body = await buildRuntimeStatus(profile || name)
} catch {
ctx.body = await buildRuntimeStatus(name)
}
}
export async function runtimeStatuses(ctx: any) {
try {
2026-05-23 18:44:53 +08:00
const profiles = filterProfilesForUser(ctx, await listProfilesForStatus())
2026-05-20 12:59:34 +08:00
const bridge = await readBridgeWorkers()
const statuses = await Promise.all(profiles.map(profile => buildRuntimeStatus(profile, bridge)))
ctx.body = { profiles: statuses }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
async function listProfilesForStatus(): Promise<HermesProfile[]> {
let profiles: HermesProfile[]
try {
profiles = await hermesCli.listProfiles()
} catch {
profiles = listProfilesFromDisk(getActiveProfileName())
}
return filterVisibleProfiles(profiles)
}
export async function restartGatewayForProfile(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
2026-05-23 18:44:53 +08:00
if (denyProfile(ctx, name)) return
2026-05-20 12:59:34 +08:00
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved` }
return
}
try {
const gateway = await restartGatewayRuntimeForProfile(name)
try {
const result = await bridgeCleanupClient().destroyProfile(name)
logger.info('[profiles] destroyed bridge sessions after gateway restart profile=%s destroyed=%s', name, result.destroyed)
} catch (err) {
logger.warn(err, '[profiles] failed to destroy bridge sessions after gateway restart profile=%s', name)
}
ctx.body = { success: true, gateway }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function restartProfileRuntime(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
2026-05-23 18:44:53 +08:00
if (denyProfile(ctx, name)) return
2026-05-20 12:59:34 +08:00
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved` }
return
}
try {
const result = await bridgeCleanupClient().destroyProfile(name)
logger.info('[profiles] destroyed bridge sessions after profile restart profile=%s destroyed=%s', name, result.destroyed)
const profiles = await listProfilesForStatus()
const profile = profiles.find(item => item.name === name)
ctx.body = {
success: true,
destroyed: result.destroyed,
status: await buildRuntimeStatus(profile || name),
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function remove(ctx: any) {
const { name } = ctx.params
2026-05-23 18:44:53 +08:00
if (denyProfile(ctx, name)) return
if (name === 'default') {
ctx.status = 400
ctx.body = { error: 'Cannot delete the default profile' }
return
}
try {
try {
2026-05-20 10:02:15 +08:00
const result = await bridgeCleanupClient().destroyProfile(name)
logger.info('[profiles] destroyed bridge sessions for deleted profile "%s" destroyed=%s', name, result.destroyed)
} catch (err) {
logger.warn(err, '[profiles] failed to destroy bridge sessions for deleted profile "%s"', name)
}
const ok = await hermesCli.deleteProfile(name)
if (ok) {
removeProfileMetadata(name)
ctx.body = { success: true }
} else if (deleteForbiddenProfileFromDisk(name)) {
removeProfileMetadata(name)
ctx.body = { success: true, fallback: 'removed_reserved_profile_from_disk' }
} else {
ctx.status = 500
ctx.body = { error: 'Failed to delete profile' }
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function rename(ctx: any) {
2026-05-23 18:44:53 +08:00
if (denyProfile(ctx, ctx.params.name)) return
const { new_name } = ctx.request.body as { new_name?: string }
if (!new_name) {
ctx.status = 400
ctx.body = { error: 'Missing new_name' }
return
}
try {
const ok = await hermesCli.renameProfile(ctx.params.name, new_name)
if (ok) {
renameProfileMetadata(ctx.params.name, new_name)
ctx.body = { success: true }
} else {
ctx.status = 500
ctx.body = { error: 'Failed to rename profile' }
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function switchProfile(ctx: any) {
const { name } = ctx.request.body as { name?: string }
if (!name) {
ctx.status = 400
ctx.body = { error: 'Missing profile name' }
return
}
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved and cannot be activated` }
return
}
try {
2026-05-23 18:44:53 +08:00
if (denyProfile(ctx, name)) return
2026-05-04 12:46:26 +08:00
2026-05-23 18:44:53 +08:00
const output = await useProfileWithFallback(name)
2026-05-04 12:46:26 +08:00
2026-05-23 18:44:53 +08:00
const actualActive = getActiveProfileName()
2026-05-04 12:46:26 +08:00
if (actualActive !== name) {
ctx.status = 500
ctx.body = { error: `Profile switch verification failed - active profile is ${actualActive}` }
return
}
2026-05-14 09:03:57 +08:00
try {
2026-05-24 09:49:21 +08:00
const result = await bridgeCleanupClient().destroyProfile(name)
logger.info('[switchProfile] destroyed bridge sessions for Hermes profile "%s" destroyed=%s', name, result.destroyed)
2026-05-14 09:03:57 +08:00
} catch (err: any) {
2026-05-24 09:49:21 +08:00
logger.warn(err, '[switchProfile] failed to destroy bridge sessions for profile "%s"', name)
2026-05-14 09:03:57 +08:00
}
try {
const detail = await hermesCli.getProfile(name)
logger.debug('Profile detail.path = %s', detail.path)
const profileConfig = join(detail.path, 'config.yaml')
if (!existsSync(profileConfig)) {
writeFileSync(profileConfig, '# Hermes Agent Configuration\n', 'utf-8')
logger.info('Created config.yaml for: %s', detail.path)
}
const profileEnv = join(detail.path, '.env')
if (!existsSync(profileEnv)) {
writeFileSync(profileEnv, '# Hermes Agent Environment Configuration\n', 'utf-8')
logger.info('Created .env for: %s', detail.path)
}
} catch (err: any) {
logger.error(err, 'Ensure config failed')
}
2026-05-04 12:46:26 +08:00
await injectBundledSkillsForProfile(name)
SessionDeleter.getInstance().switchProfile(name)
2026-05-23 18:44:53 +08:00
logger.info('[switchProfile] switched session deleter to Hermes profile "%s"', name)
2026-05-04 12:46:26 +08:00
ctx.body = {
success: true,
message: output.trim(),
2026-05-23 18:44:53 +08:00
active: name,
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function exportProfile(ctx: any) {
const { name } = ctx.params
2026-05-23 18:44:53 +08:00
if (denyProfile(ctx, name)) return
const outputPath = join(tmpdir(), `hermes-profile-${name}.tar.gz`)
try {
await hermesCli.exportProfile(name, outputPath)
if (!existsSync(outputPath)) {
ctx.status = 500
ctx.body = { error: 'Export file not found' }
return
}
const filename = basename(outputPath)
ctx.set('Content-Disposition', `attachment; filename="${filename}"`)
ctx.set('Content-Type', 'application/gzip')
ctx.body = createReadStream(outputPath)
ctx.res.on('finish', () => { try { unlinkSync(outputPath) } catch { } })
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function importProfile(ctx: any) {
const contentType = ctx.get('content-type') || ''
if (!contentType.startsWith('multipart/form-data')) {
ctx.status = 400
ctx.body = { error: 'Expected multipart/form-data' }
return
}
const boundary = '--' + contentType.split('boundary=')[1]
if (!boundary || boundary === '--undefined') {
ctx.status = 400
ctx.body = { error: 'Missing boundary' }
return
}
const tmpDir = join(tmpdir(), 'hermes-import')
await mkdir(tmpDir, { recursive: true })
const chunks: Buffer[] = []
for await (const chunk of ctx.req) chunks.push(chunk)
const body = Buffer.concat(chunks).toString('latin1')
const parts = body.split(boundary).slice(1, -1)
let archivePath = ''
for (const part of parts) {
const headerEnd = part.indexOf('\r\n\r\n')
if (headerEnd === -1) continue
const header = part.substring(0, headerEnd)
const data = part.substring(headerEnd + 4, part.length - 2)
const filenameMatch = header.match(/filename="([^"]+)"/)
if (!filenameMatch) continue
const filename = filenameMatch[1]
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
if (!['.gz', '.tar.gz', '.zip', '.tgz'].includes(ext)) continue
archivePath = join(tmpDir, filename)
await writeFile(archivePath, Buffer.from(data, 'binary'))
break
}
if (!archivePath) {
ctx.status = 400
ctx.body = { error: 'No archive file found (.gz, .zip, .tgz)' }
return
}
try {
const result = await hermesCli.importProfile(archivePath)
try { unlinkSync(archivePath) } catch { }
ctx.body = { success: true, message: result.trim() }
} catch (err: any) {
try { unlinkSync(archivePath) } catch { }
ctx.status = 500
ctx.body = { error: err.message }
}
}