Barazo AppView backend
barazo.forum
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}