Barazo AppView backend barazo.forum
at main 220 lines 7.0 kB view raw
1import { readdir, readFile } from 'node:fs/promises' 2import { join } from 'node:path' 3 4import { sql } from 'drizzle-orm' 5import type { Logger } from '../logger.js' 6 7import { pluginManifestSchema, type PluginManifest } from '../../validation/plugin-manifest.js' 8 9// --------------------------------------------------------------------------- 10// Topological sort 11// --------------------------------------------------------------------------- 12 13export function topologicalSort(manifests: PluginManifest[]): PluginManifest[] { 14 const nameToManifest = new Map<string, PluginManifest>() 15 for (const m of manifests) { 16 nameToManifest.set(m.name, m) 17 } 18 19 const sorted: PluginManifest[] = [] 20 const visited = new Set<string>() 21 const visiting = new Set<string>() 22 23 function visit(name: string): void { 24 if (visited.has(name)) return 25 if (visiting.has(name)) { 26 throw new Error(`Circular dependency detected involving plugin "${name}"`) 27 } 28 29 visiting.add(name) 30 const manifest = nameToManifest.get(name) 31 if (manifest?.dependencies) { 32 for (const dep of manifest.dependencies) { 33 if (nameToManifest.has(dep)) { 34 visit(dep) 35 } 36 } 37 } 38 visiting.delete(name) 39 visited.add(name) 40 if (manifest) { 41 sorted.push(manifest) 42 } 43 } 44 45 for (const m of manifests) { 46 visit(m.name) 47 } 48 49 return sorted 50} 51 52// --------------------------------------------------------------------------- 53// Validate and filter 54// --------------------------------------------------------------------------- 55 56export function validateAndFilterPlugins( 57 rawManifests: unknown[], 58 _barazoVersion: string, 59 logger: Logger 60): PluginManifest[] { 61 const valid: PluginManifest[] = [] 62 63 for (const raw of rawManifests) { 64 const result = pluginManifestSchema.safeParse(raw) 65 if (!result.success) { 66 const name = (raw as Record<string, unknown>).name ?? 'unknown' 67 logger.warn({ name, errors: result.error.issues }, 'Skipping invalid plugin manifest') 68 continue 69 } 70 valid.push(result.data) 71 } 72 73 // Check that all declared dependencies exist in the valid set 74 const validNames = new Set(valid.map((m) => m.name)) 75 const filtered: PluginManifest[] = [] 76 77 for (const manifest of valid) { 78 const missingDeps = (manifest.dependencies ?? []).filter((dep) => !validNames.has(dep)) 79 if (missingDeps.length > 0) { 80 logger.warn( 81 { plugin: manifest.name, missingDeps }, 82 'Skipping plugin with missing dependencies' 83 ) 84 continue 85 } 86 filtered.push(manifest) 87 } 88 89 return filtered 90} 91 92// --------------------------------------------------------------------------- 93// Discover plugins from node_modules 94// --------------------------------------------------------------------------- 95 96export async function discoverPlugins( 97 nodeModulesPath: string, 98 logger: Logger 99): Promise<{ manifest: PluginManifest; packagePath: string }[]> { 100 const results: { manifest: PluginManifest; packagePath: string }[] = [] 101 102 // Scan @barazo/plugin-* (scoped packages) 103 const scopedDir = join(nodeModulesPath, '@barazo') 104 try { 105 const entries = await readdir(scopedDir, { withFileTypes: true }) 106 for (const entry of entries) { 107 if (entry.isDirectory() && entry.name.startsWith('plugin-')) { 108 const packagePath = join(scopedDir, entry.name) 109 const manifest = await tryReadManifest(packagePath, logger) 110 if (manifest) { 111 results.push({ manifest, packagePath }) 112 } 113 } 114 } 115 } catch { 116 // @barazo directory may not exist -- that is fine 117 } 118 119 // Scan barazo-plugin-* (unscoped packages) 120 try { 121 const entries = await readdir(nodeModulesPath, { withFileTypes: true }) 122 for (const entry of entries) { 123 if (entry.isDirectory() && entry.name.startsWith('barazo-plugin-')) { 124 const packagePath = join(nodeModulesPath, entry.name) 125 const manifest = await tryReadManifest(packagePath, logger) 126 if (manifest) { 127 results.push({ manifest, packagePath }) 128 } 129 } 130 } 131 } catch { 132 // node_modules may not exist -- that is fine 133 } 134 135 return results 136} 137 138async function tryReadManifest( 139 packagePath: string, 140 logger: Logger 141): Promise<PluginManifest | null> { 142 try { 143 const raw = await readFile(join(packagePath, 'plugin.json'), 'utf-8') 144 const parsed: unknown = JSON.parse(raw) 145 const result = pluginManifestSchema.safeParse(parsed) 146 if (!result.success) { 147 logger.warn({ packagePath, errors: result.error.issues }, 'Invalid plugin.json, skipping') 148 return null 149 } 150 return result.data 151 } catch { 152 // No plugin.json or unreadable -- skip silently 153 return null 154 } 155} 156 157// --------------------------------------------------------------------------- 158// Sync discovered plugins to database 159// --------------------------------------------------------------------------- 160 161interface DbExecutor { 162 execute(query: unknown): Promise<unknown> 163} 164 165export async function syncPluginsToDb( 166 discovered: { manifest: PluginManifest; packagePath: string }[], 167 db: DbExecutor, 168 logger: Logger 169): Promise<{ newPlugins: string[] }> { 170 const existingRows = (await db.execute(sql`SELECT name FROM plugins`)) as Array<{ 171 name: string 172 }> 173 const existingNames = new Set(existingRows.map((r) => r.name)) 174 const newPlugins: string[] = [] 175 176 for (const { manifest } of discovered) { 177 if (!existingNames.has(manifest.name)) { 178 newPlugins.push(manifest.name) 179 } 180 const manifestJson = JSON.stringify(manifest) 181 182 // Upsert plugin -- new plugins are inserted as disabled 183 await db.execute(sql` 184 INSERT INTO plugins (id, name, display_name, version, description, source, category, enabled, manifest_json, installed_at, updated_at) 185 VALUES (gen_random_uuid(), ${manifest.name}, ${manifest.displayName}, ${manifest.version}, ${manifest.description}, ${manifest.source}, ${manifest.category}, false, ${manifestJson}::jsonb, now(), now()) 186 ON CONFLICT (name) DO UPDATE SET 187 version = EXCLUDED.version, 188 display_name = EXCLUDED.display_name, 189 description = EXCLUDED.description, 190 source = EXCLUDED.source, 191 category = EXCLUDED.category, 192 manifest_json = EXCLUDED.manifest_json, 193 updated_at = now() 194 `) 195 196 // Sync permissions: delete old, insert current 197 const allPermissions = [...manifest.permissions.backend, ...manifest.permissions.frontend] 198 199 await db.execute(sql` 200 DELETE FROM plugin_permissions 201 WHERE plugin_id = (SELECT id FROM plugins WHERE name = ${manifest.name}) 202 `) 203 204 for (const permission of allPermissions) { 205 await db.execute(sql` 206 INSERT INTO plugin_permissions (id, plugin_id, permission, granted_at) 207 VALUES ( 208 gen_random_uuid(), 209 (SELECT id FROM plugins WHERE name = ${manifest.name}), 210 ${permission}, 211 now() 212 ) 213 `) 214 } 215 216 logger.info({ plugin: manifest.name, version: manifest.version }, 'Synced plugin to database') 217 } 218 219 return { newPlugins } 220}