import { readdir, mkdir, rm } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { createRouter } from '../../create-router'; import { getNativeResourcesDir, getGlobalResourcesDir } from '../../data-path'; import { parseFrontmatter } from '../skills/skills'; async function readResourceDirs(dir: string): Promise> { const result = new Map(); try { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; const resourceFile = join(dir, entry.name, 'RESOURCE.md'); if (await Bun.file(resourceFile).exists()) { result.set(entry.name, resourceFile); } } } catch { // directory doesn't exist yet } return result; } async function readConfigFile(dir: string): Promise> { try { return await Bun.file(join(dir, 'config.json')).json(); } catch { return {}; } } function mergeConfig(native: Record, global: Record): Record { const merged: Record = {}; for (const key of Object.keys(native)) { merged[key] = global[key] ?? native[key]!; } for (const key of Object.keys(global)) { if (!(key in merged)) merged[key] = global[key]!; } return merged; } function isPrivileged(role: string) { return role === 'Super Admin'; } export async function readResourceConfig(name: string): Promise> { const nativeConfig = await readConfigFile(join(getNativeResourcesDir(), name)); const globalConfig = await readConfigFile(join(getGlobalResourcesDir(), name)); return mergeConfig(nativeConfig, globalConfig); } const CHECK_TIMEOUT_MS = 3_000; export const resourcesRouter = createRouter(); resourcesRouter.get('/', async (ctx) => { const nativeResources = await readResourceDirs(getNativeResourcesDir()); const globalResources = await readResourceDirs(getGlobalResourcesDir()); const merged = new Map(nativeResources); for (const [name, path] of globalResources) merged.set(name, path); const resources = await Promise.all( Array.from(merged.entries()).map(async ([dirName, filePath]) => { const raw = await Bun.file(filePath).text(); const { frontmatter } = parseFrontmatter(raw); const scope = globalResources.has(dirName) && !nativeResources.has(dirName) ? 'global' as const : nativeResources.has(dirName) ? 'native' as const : 'global' as const; const config = await readResourceConfig(dirName); return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, scope, config }; }), ); return ctx.json(resources); }); resourcesRouter.get('/:name', async (ctx) => { const name = ctx.req.param('name'); const nativeResources = await readResourceDirs(getNativeResourcesDir()); const globalResources = await readResourceDirs(getGlobalResourcesDir()); const filePath = globalResources.get(name) ?? nativeResources.get(name); if (!filePath) return ctx.text('Not found', 404); const scope = globalResources.has(name) && !nativeResources.has(name) ? 'global' as const : 'native' as const; const raw = await Bun.file(filePath).text(); const { frontmatter, body, rawYaml } = parseFrontmatter(raw); const config = await readResourceConfig(name); const globalConfigPath = join(getGlobalResourcesDir(), name, 'config.json'); const chatMeta = join(dirname(filePath), 'chat', 'meta.json'); const chatSessionId = await Bun.file(chatMeta).json().then((m: { id: string }) => m.id).catch(() => null); const guidePath = join(getNativeResourcesDir(), 'GUIDE.md'); return ctx.json({ dirName: name, name: frontmatter.name || name, description: frontmatter.description, scope, body, rawFrontmatter: rawYaml, filePath, config, configPath: globalConfigPath, chatSessionId, guidePath, }); }); resourcesRouter.patch('/:name/config', async (ctx) => { const user = ctx.get('user'); if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403); const name = ctx.req.param('name'); const body = await ctx.req.json>(); const globalDir = join(getGlobalResourcesDir(), name); await mkdir(globalDir, { recursive: true }); const existing = await readConfigFile(globalDir); for (const [key, value] of Object.entries(body)) { if (value === null) delete existing[key]; else existing[key] = value; } await Bun.write(join(globalDir, 'config.json'), JSON.stringify(existing, null, 2)); const merged = await readResourceConfig(name); return ctx.json(merged); }); resourcesRouter.post('/', async (ctx) => { const user = ctx.get('user'); if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403); const { name } = await ctx.req.json<{ name: string }>(); if (!name?.trim()) return ctx.text('Name is required', 400); const dirName = name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); if (!dirName) return ctx.text('Invalid name', 400); const dir = join(getGlobalResourcesDir(), dirName); const filePath = join(dir, 'RESOURCE.md'); if (await Bun.file(filePath).exists()) { return ctx.text('Resource already exists', 409); } await mkdir(dir, { recursive: true }); await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`); await Bun.write(join(dir, 'config.json'), JSON.stringify({ url: '', api_key: '', username: '', password: '' }, null, 2)); return ctx.json({ name: name.trim(), dirName, filePath, scope: 'global' }); }); resourcesRouter.delete('/:name', async (ctx) => { const user = ctx.get('user'); if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403); const name = ctx.req.param('name'); const nativeResources = await readResourceDirs(getNativeResourcesDir()); if (nativeResources.has(name)) return ctx.text('Cannot delete native resource', 400); const dir = join(getGlobalResourcesDir(), name); const filePath = join(dir, 'RESOURCE.md'); if (!(await Bun.file(filePath).exists())) return ctx.text('Not found', 404); await rm(dir, { recursive: true }); return ctx.json({ ok: true }); }); resourcesRouter.get('/:name/chat', async (ctx) => { const name = ctx.req.param('name'); const nativeResources = await readResourceDirs(getNativeResourcesDir()); const globalResources = await readResourceDirs(getGlobalResourcesDir()); const filePath = globalResources.get(name) ?? nativeResources.get(name); if (!filePath) return ctx.text('Not found', 404); const chatDir = join(getGlobalResourcesDir(), name, 'chat'); const sessionId = await Bun.file(join(chatDir, 'meta.json')).json().then((m: { id: string }) => m.id).catch(() => null); const messages = await Bun.file(join(chatDir, 'messages.json')).json().catch(() => []); return ctx.json({ sessionId, messages }); }); resourcesRouter.put('/:name/chat', async (ctx) => { const user = ctx.get('user'); if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403); const name = ctx.req.param('name'); const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>(); const chatDir = join(getGlobalResourcesDir(), name, 'chat'); await mkdir(chatDir, { recursive: true }); await Bun.write(join(chatDir, 'messages.json'), JSON.stringify(messages)); if (sessionId) await Bun.write(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId })); return ctx.json({ ok: true }); }); resourcesRouter.delete('/:name/chat', async (ctx) => { const user = ctx.get('user'); if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403); const name = ctx.req.param('name'); const chatDir = join(getGlobalResourcesDir(), name, 'chat'); await rm(chatDir, { recursive: true, force: true }); return ctx.json({ ok: true }); }); resourcesRouter.post('/:name/ping', async (ctx) => { const name = ctx.req.param('name'); const body = await ctx.req.json<{ url?: string }>().catch((): { url?: string } => ({})); const config = await readResourceConfig(name); const url = body.url ?? config.url; if (!url) return ctx.json({ error: 'No URL configured' }, 400); try { const start = performance.now(); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS); await fetch(url, { signal: controller.signal }); clearTimeout(timeout); const latencyMs = Math.round(performance.now() - start); return ctx.json({ reachable: true, latencyMs }); } catch { return ctx.json({ reachable: false, latencyMs: null }); } });