mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
feat: Implement local and remote package sources with package source manager
- Added LocalPackageSource to load packages from the local filesystem. - Introduced RemotePackageSource to fetch packages from a remote registry. - Created PackageSourceManager to manage multiple package sources and resolve conflicts. - Added tests for package source types and configurations. - Updated metadata.json files for various packages to include dependencies. - Enhanced type definitions for package sources and related data structures.
This commit is contained in:
33
frontends/nextjs/src/app/api/packages/index/route.ts
Normal file
33
frontends/nextjs/src/app/api/packages/index/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
|
||||
/**
|
||||
* GET /api/packages/index
|
||||
* Returns the package index from packages/index.json
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// Determine the path to packages/index.json
|
||||
// In development, this is relative to the project root
|
||||
// In production, it should be bundled or served from a known location
|
||||
const indexPath = join(process.cwd(), '..', '..', '..', 'packages', 'index.json')
|
||||
|
||||
const indexContent = await readFile(indexPath, 'utf-8')
|
||||
const indexData = JSON.parse(indexContent)
|
||||
|
||||
return NextResponse.json(indexData, {
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load package index:', error)
|
||||
|
||||
// Return empty index on error
|
||||
return NextResponse.json(
|
||||
{ packages: [], error: 'Failed to load package index' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui'
|
||||
import type { AppConfiguration, User } from '@/lib/level-types'
|
||||
import type { AppConfiguration, LuaScript, User, Workflow } from '@/lib/level-types'
|
||||
import type { ModelSchema } from '@/lib/types/schema-types'
|
||||
|
||||
import { level4TabsConfig } from './tabs/config'
|
||||
import { TabContent } from './tabs/TabContent'
|
||||
@@ -8,9 +9,9 @@ interface Level4TabsProps {
|
||||
appConfig: AppConfiguration
|
||||
user: User
|
||||
nerdMode: boolean
|
||||
onSchemasChange: (schemas: any[]) => Promise<void>
|
||||
onWorkflowsChange: (workflows: any[]) => Promise<void>
|
||||
onLuaScriptsChange: (scripts: any[]) => Promise<void>
|
||||
onSchemasChange: (schemas: ModelSchema[]) => Promise<void>
|
||||
onWorkflowsChange: (workflows: Workflow[]) => Promise<void>
|
||||
onLuaScriptsChange: (scripts: LuaScript[]) => Promise<void>
|
||||
}
|
||||
|
||||
export function Level4Tabs({
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { getAdapter } from '../../core/dbal-client'
|
||||
import type { Comment } from '../../types/level-types'
|
||||
|
||||
type DBALCommentRecord = {
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Set all comments (replaces existing)
|
||||
*/
|
||||
@@ -8,8 +12,8 @@ export async function setComments(comments: Comment[]): Promise<void> {
|
||||
const adapter = getAdapter()
|
||||
|
||||
// Delete existing comments
|
||||
const existing = await adapter.list('Comment')
|
||||
for (const c of existing.data as any[]) {
|
||||
const existing = (await adapter.list('Comment')) as { data: DBALCommentRecord[] }
|
||||
for (const c of existing.data) {
|
||||
await adapter.delete('Comment', c.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Comment } from '../../types/level-types'
|
||||
|
||||
const mockUpdate = vi.fn()
|
||||
const mockAdapter = { update: mockUpdate }
|
||||
@@ -14,13 +15,15 @@ describe('updateComment', () => {
|
||||
mockUpdate.mockReset()
|
||||
})
|
||||
|
||||
it.each([
|
||||
const cases: Array<{ commentId: string; updates: Partial<Comment> }> = [
|
||||
{ commentId: 'c1', updates: { content: 'Updated' } },
|
||||
{ commentId: 'c2', updates: { content: 'New text', updatedAt: 2000 } },
|
||||
])('should update $commentId', async ({ commentId, updates }) => {
|
||||
]
|
||||
|
||||
it.each(cases)('should update $commentId', async ({ commentId, updates }) => {
|
||||
mockUpdate.mockResolvedValue(undefined)
|
||||
|
||||
await updateComment(commentId, updates as any)
|
||||
await updateComment(commentId, updates)
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith('Comment', commentId, expect.any(Object))
|
||||
})
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { getAdapter } from '../../core/dbal-client'
|
||||
import type { ComponentConfig } from '../types'
|
||||
|
||||
type DBALComponentConfigRecord = {
|
||||
id: string
|
||||
componentId: string
|
||||
props: string
|
||||
styles: string
|
||||
events: string
|
||||
conditionalRendering?: string | null
|
||||
}
|
||||
|
||||
export async function getComponentConfigs(): Promise<Record<string, ComponentConfig>> {
|
||||
const adapter = getAdapter()
|
||||
const result = await adapter.list('ComponentConfig')
|
||||
const result = (await adapter.list('ComponentConfig')) as { data: DBALComponentConfigRecord[] }
|
||||
const configs: Record<string, ComponentConfig> = {}
|
||||
for (const config of result.data as any[]) {
|
||||
for (const config of result.data) {
|
||||
configs[config.id] = {
|
||||
id: config.id,
|
||||
componentId: config.componentId,
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { getAdapter } from '../../core/dbal-client'
|
||||
import type { ComponentConfig } from '../types'
|
||||
|
||||
type DBALComponentConfigRecord = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export async function setComponentConfigs(configs: Record<string, ComponentConfig>): Promise<void> {
|
||||
const adapter = getAdapter()
|
||||
|
||||
// Delete existing configs
|
||||
const existing = await adapter.list('ComponentConfig')
|
||||
for (const c of existing.data as any[]) {
|
||||
const existing = (await adapter.list('ComponentConfig')) as { data: DBALComponentConfigRecord[] }
|
||||
for (const c of existing.data) {
|
||||
await adapter.delete('ComponentConfig', c.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { getAdapter } from '../../core/dbal-client'
|
||||
import type { ComponentNode } from '../types'
|
||||
|
||||
type DBALComponentNodeRecord = {
|
||||
id: string
|
||||
type: string
|
||||
parentId?: string | null
|
||||
childIds: string
|
||||
order: number
|
||||
pageId: string
|
||||
}
|
||||
|
||||
export async function getComponentHierarchy(): Promise<Record<string, ComponentNode>> {
|
||||
const adapter = getAdapter()
|
||||
const result = await adapter.list('ComponentNode')
|
||||
const result = (await adapter.list('ComponentNode')) as { data: DBALComponentNodeRecord[] }
|
||||
const hierarchy: Record<string, ComponentNode> = {}
|
||||
for (const node of result.data as any[]) {
|
||||
for (const node of result.data) {
|
||||
hierarchy[node.id] = {
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { getAdapter } from '../../core/dbal-client'
|
||||
import type { ComponentNode } from '../types'
|
||||
|
||||
type DBALComponentNodeRecord = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export async function setComponentHierarchy(
|
||||
hierarchy: Record<string, ComponentNode>
|
||||
): Promise<void> {
|
||||
const adapter = getAdapter()
|
||||
|
||||
// Delete existing hierarchy
|
||||
const existing = await adapter.list('ComponentNode')
|
||||
for (const n of existing.data as any[]) {
|
||||
const existing = (await adapter.list('ComponentNode')) as { data: DBALComponentNodeRecord[] }
|
||||
for (const n of existing.data) {
|
||||
await adapter.delete('ComponentNode', n.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,14 @@ const ENTITY_TYPES = [
|
||||
'PasswordResetToken',
|
||||
] as const
|
||||
|
||||
type DBALDeleteCandidate = {
|
||||
id?: string
|
||||
packageId?: string
|
||||
name?: string
|
||||
key?: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data from the database
|
||||
*/
|
||||
@@ -29,8 +37,8 @@ export async function clearDatabase(): Promise<void> {
|
||||
const adapter = getAdapter()
|
||||
for (const entityType of ENTITY_TYPES) {
|
||||
try {
|
||||
const result = await adapter.list(entityType)
|
||||
for (const item of result.data as any[]) {
|
||||
const result = (await adapter.list(entityType)) as { data: DBALDeleteCandidate[] }
|
||||
for (const item of result.data) {
|
||||
const id = item.id || item.packageId || item.name || item.key || item.username
|
||||
if (id) {
|
||||
await adapter.delete(entityType, id)
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
import type { Comment } from '../../../types/level-types'
|
||||
import { prisma } from '../../prisma'
|
||||
|
||||
type CommentUpdateData = {
|
||||
content?: string
|
||||
updatedAt?: bigint
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a comment
|
||||
* @param commentId - ID of comment to update
|
||||
@@ -15,7 +20,7 @@ export const updateComment = async (
|
||||
commentId: string,
|
||||
updates: Partial<Comment>
|
||||
): Promise<void> => {
|
||||
const data: any = {}
|
||||
const data: CommentUpdateData = {}
|
||||
if (updates.content !== undefined) data.content = updates.content
|
||||
if (updates.updatedAt !== undefined) data.updatedAt = BigInt(updates.updatedAt)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
/**
|
||||
* Component Types
|
||||
* Shared types for component hierarchy and config
|
||||
@@ -15,8 +17,8 @@ export interface ComponentNode {
|
||||
export interface ComponentConfig {
|
||||
id: string
|
||||
componentId: string
|
||||
props: Record<string, any>
|
||||
styles: Record<string, any>
|
||||
props: Record<string, JsonValue>
|
||||
styles: Record<string, JsonValue>
|
||||
events: Record<string, string>
|
||||
conditionalRendering?: {
|
||||
condition: string
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { getAdapter } from '../../../core/dbal-client'
|
||||
import type { InstalledPackage } from '../../packages/package-types'
|
||||
|
||||
type DBALInstalledPackageRecord = {
|
||||
packageId: string
|
||||
installedAt: number | string | Date
|
||||
version: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all installed packages from database
|
||||
*/
|
||||
export async function getInstalledPackages(): Promise<InstalledPackage[]> {
|
||||
const adapter = getAdapter()
|
||||
const result = await adapter.list('InstalledPackage')
|
||||
return (result.data as any[]).map(p => ({
|
||||
const result = (await adapter.list('InstalledPackage')) as {
|
||||
data: DBALInstalledPackageRecord[]
|
||||
}
|
||||
return result.data.map(p => ({
|
||||
packageId: p.packageId,
|
||||
installedAt: Number(p.installedAt),
|
||||
version: p.version,
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { getAdapter } from '../../../core/dbal-client'
|
||||
import type { InstalledPackage } from '../../packages/package-types'
|
||||
|
||||
type DBALInstalledPackageRecord = {
|
||||
packageId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Set all installed packages (replaces existing)
|
||||
*/
|
||||
export async function setInstalledPackages(packages: InstalledPackage[]): Promise<void> {
|
||||
const adapter = getAdapter()
|
||||
// Delete all existing
|
||||
const existing = await adapter.list('InstalledPackage')
|
||||
for (const item of existing.data as any[]) {
|
||||
const existing = (await adapter.list('InstalledPackage')) as {
|
||||
data: DBALInstalledPackageRecord[]
|
||||
}
|
||||
for (const item of existing.data) {
|
||||
await adapter.delete('InstalledPackage', item.packageId)
|
||||
}
|
||||
// Create new ones
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PageConfig } from '../../types/level-types'
|
||||
|
||||
const mockCreate = vi.fn()
|
||||
const mockAdapter = { create: mockCreate }
|
||||
@@ -14,7 +15,7 @@ describe('addPage', () => {
|
||||
mockCreate.mockReset()
|
||||
})
|
||||
|
||||
it.each([
|
||||
const cases: Array<{ name: string; page: PageConfig }> = [
|
||||
{
|
||||
name: 'basic page',
|
||||
page: {
|
||||
@@ -38,10 +39,12 @@ describe('addPage', () => {
|
||||
requiredRole: 'user',
|
||||
},
|
||||
},
|
||||
])('should add $name', async ({ page }) => {
|
||||
]
|
||||
|
||||
it.each(cases)('should add $name', async ({ page }) => {
|
||||
mockCreate.mockResolvedValue(undefined)
|
||||
|
||||
await addPage(page as any)
|
||||
await addPage(page)
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
'PageConfig',
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
import { getAdapter } from '../../core/dbal-client'
|
||||
import type { PageConfig } from '../../types/level-types'
|
||||
import type { PageConfig, UserRole } from '../../types/level-types'
|
||||
|
||||
type DBALPageRecord = {
|
||||
id: string
|
||||
path: string
|
||||
title: string
|
||||
level: number | string
|
||||
componentTree: string
|
||||
requiresAuth: boolean
|
||||
requiredRole?: string | null
|
||||
}
|
||||
|
||||
const USER_ROLES = new Set<UserRole>([
|
||||
'public',
|
||||
'user',
|
||||
'moderator',
|
||||
'admin',
|
||||
'god',
|
||||
'supergod',
|
||||
])
|
||||
|
||||
function toUserRole(role: string): UserRole {
|
||||
return USER_ROLES.has(role as UserRole) ? (role as UserRole) : 'user'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pages
|
||||
*/
|
||||
export async function getPages(): Promise<PageConfig[]> {
|
||||
const adapter = getAdapter()
|
||||
const result = await adapter.list('PageConfig')
|
||||
return (result.data as any[]).map(p => ({
|
||||
const result = (await adapter.list('PageConfig')) as { data: DBALPageRecord[] }
|
||||
return result.data.map(p => ({
|
||||
id: p.id,
|
||||
path: p.path,
|
||||
title: p.title,
|
||||
level: p.level as any,
|
||||
componentTree: JSON.parse(p.componentTree),
|
||||
level: Number(p.level) as PageConfig['level'],
|
||||
componentTree: JSON.parse(p.componentTree) as PageConfig['componentTree'],
|
||||
requiresAuth: p.requiresAuth,
|
||||
requiredRole: (p.requiredRole as any) || undefined,
|
||||
requiredRole: p.requiredRole ? toUserRole(p.requiredRole) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PageConfig } from '../../types/level-types'
|
||||
|
||||
const mockList = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
@@ -18,7 +19,13 @@ describe('setPages', () => {
|
||||
mockCreate.mockReset()
|
||||
})
|
||||
|
||||
it.each([
|
||||
const cases: Array<{
|
||||
name: string
|
||||
existing: Array<{ id: string }>
|
||||
newPages: PageConfig[]
|
||||
expectedDeletes: number
|
||||
expectedCreates: number
|
||||
}> = [
|
||||
{
|
||||
name: 'replace all pages',
|
||||
existing: [{ id: 'old1' }],
|
||||
@@ -28,12 +35,14 @@ describe('setPages', () => {
|
||||
expectedDeletes: 1,
|
||||
expectedCreates: 1,
|
||||
},
|
||||
])('should $name', async ({ existing, newPages, expectedDeletes, expectedCreates }) => {
|
||||
]
|
||||
|
||||
it.each(cases)('should $name', async ({ existing, newPages, expectedDeletes, expectedCreates }) => {
|
||||
mockList.mockResolvedValue({ data: existing })
|
||||
mockDelete.mockResolvedValue(undefined)
|
||||
mockCreate.mockResolvedValue(undefined)
|
||||
|
||||
await setPages(newPages as any)
|
||||
await setPages(newPages)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledTimes(expectedDeletes)
|
||||
expect(mockCreate).toHaveBeenCalledTimes(expectedCreates)
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { getAdapter } from '../../core/dbal-client'
|
||||
import type { ModelSchema } from '../../types/schema-types'
|
||||
|
||||
type DBALModelSchemaRecord = {
|
||||
name: string
|
||||
label?: string | null
|
||||
labelPlural?: string | null
|
||||
icon?: string | null
|
||||
fields: string
|
||||
listDisplay?: string | null
|
||||
listFilter?: string | null
|
||||
searchFields?: string | null
|
||||
ordering?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all schemas
|
||||
*/
|
||||
export async function getSchemas(): Promise<ModelSchema[]> {
|
||||
const adapter = getAdapter()
|
||||
const result = await adapter.list('ModelSchema')
|
||||
return (result.data as any[]).map(s => ({
|
||||
const result = (await adapter.list('ModelSchema')) as { data: DBALModelSchemaRecord[] }
|
||||
return result.data.map(s => ({
|
||||
name: s.name,
|
||||
label: s.label || undefined,
|
||||
labelPlural: s.labelPlural || undefined,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { getAdapter } from '../../../core/dbal-client'
|
||||
|
||||
type DBALSessionRecord = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export async function deleteSessionByToken(token: string): Promise<boolean> {
|
||||
const adapter = getAdapter()
|
||||
const result = await adapter.list('Session', { filter: { token } })
|
||||
const result = (await adapter.list('Session', { filter: { token } })) as {
|
||||
data: DBALSessionRecord[]
|
||||
}
|
||||
if (!result.data.length) return false
|
||||
const session = result.data[0] as any
|
||||
const session = result.data[0]
|
||||
await adapter.delete('Session', session.id)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Session } from './types'
|
||||
|
||||
export function mapSessionRecord(record: any): Session {
|
||||
type SessionRecord = {
|
||||
id: string
|
||||
userId: string
|
||||
token: string
|
||||
expiresAt: number | string | Date
|
||||
createdAt: number | string | Date
|
||||
lastActivity: number | string | Date
|
||||
}
|
||||
|
||||
export function mapSessionRecord(record: SessionRecord): Session {
|
||||
return {
|
||||
id: record.id,
|
||||
userId: record.userId,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { SMTPConfig } from '../../password'
|
||||
import { getAdapter } from '../core/dbal-client'
|
||||
|
||||
type DBALSMTPConfig = SMTPConfig
|
||||
|
||||
/**
|
||||
* Get SMTP configuration
|
||||
*/
|
||||
export async function getSMTPConfig(): Promise<SMTPConfig | null> {
|
||||
const adapter = getAdapter()
|
||||
const result = await adapter.list('SMTPConfig')
|
||||
const config = (result.data as any[])[0]
|
||||
const result = (await adapter.list('SMTPConfig')) as { data: DBALSMTPConfig[] }
|
||||
const config = result.data[0]
|
||||
if (!config) return null
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DBALClient, type DBALConfig } from '@/dbal'
|
||||
|
||||
// Note: This was extracted from a class static method
|
||||
// The original `this` context is lost, so this function may not work correctly
|
||||
export function getInstance(): any {
|
||||
export function getInstance(): never {
|
||||
// Original code referenced DBALIntegration.instance which may not exist here
|
||||
// TODO: Review and fix this extraction
|
||||
throw new Error('getInstance was incorrectly extracted - needs manual review')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DBALClient, type DBALConfig } from '@/dbal'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
export async function get(key: string, context: TenantContext): Promise<any | null> {
|
||||
export async function get(key: string, context: TenantContext): Promise<JsonValue | null> {
|
||||
const fullKey = this.getKey(key, context)
|
||||
const item = this.store.get(fullKey)
|
||||
if (!item) return null
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { DBALClient, type DBALConfig } from '@/dbal'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
// Note: This was extracted from a class method
|
||||
// The original `this` context is lost, so this function may not work correctly
|
||||
export async function kvGet<T = any>(
|
||||
export async function kvGet<T = JsonValue>(
|
||||
key: string,
|
||||
tenantId = 'default',
|
||||
userId = 'system'
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { DBALClient, type DBALConfig } from '@/dbal'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
export async function kvListAdd(
|
||||
key: string,
|
||||
items: any[],
|
||||
items: JsonValue[],
|
||||
tenantId = 'default',
|
||||
userId = 'system'
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DBALClient, type DBALConfig } from '@/dbal'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
export async function kvListGet(
|
||||
key: string,
|
||||
@@ -6,7 +7,7 @@ export async function kvListGet(
|
||||
userId = 'system',
|
||||
start?: number,
|
||||
end?: number
|
||||
): Promise<any[]> {
|
||||
): Promise<JsonValue[]> {
|
||||
if (!this.kvStore || !this.tenantManager) throw new Error('DBAL not initialized')
|
||||
const context = await this.tenantManager.getTenantContext(tenantId, userId)
|
||||
if (!context) throw new Error(`Tenant not found: ${tenantId}`)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { DBALClient, type DBALConfig } from '@/dbal'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
// KV Store operations
|
||||
export async function kvSet(
|
||||
key: string,
|
||||
value: any,
|
||||
value: JsonValue,
|
||||
ttl?: number,
|
||||
tenantId = 'default',
|
||||
userId = 'system'
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { DBALClient, type DBALConfig } from '@/dbal'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
export async function listAdd(key: string, items: any[], context: TenantContext): Promise<void> {
|
||||
export async function listAdd(
|
||||
key: string,
|
||||
items: JsonValue[],
|
||||
context: TenantContext
|
||||
): Promise<void> {
|
||||
const fullKey = this.getKey(key, context)
|
||||
const existing = this.store.get(fullKey)?.value || []
|
||||
this.store.set(fullKey, { value: [...existing, ...items] })
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { DBALClient, type DBALConfig } from '@/dbal'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
export async function listGet(
|
||||
key: string,
|
||||
context: TenantContext,
|
||||
start?: number,
|
||||
end?: number
|
||||
): Promise<any[]> {
|
||||
): Promise<JsonValue[]> {
|
||||
const fullKey = this.getKey(key, context)
|
||||
const list = this.store.get(fullKey)?.value || []
|
||||
if (start !== undefined && end !== undefined) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { DBALClient, type DBALConfig } from '@/dbal'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
export async function set(
|
||||
key: string,
|
||||
value: any,
|
||||
value: JsonValue,
|
||||
context: TenantContext,
|
||||
ttl?: number
|
||||
): Promise<void> {
|
||||
|
||||
@@ -9,15 +9,15 @@ import { toTopCounts } from './functions/to-top-counts'
|
||||
* This is a convenience wrapper. Prefer importing individual functions.
|
||||
*/
|
||||
export class StatsUtils {
|
||||
static toTopCounts(...args: any[]) {
|
||||
return toTopCounts(...(args as any))
|
||||
static toTopCounts(...args: Parameters<typeof toTopCounts>) {
|
||||
return toTopCounts(...args)
|
||||
}
|
||||
|
||||
static summarizeWorkflowRuns(...args: any[]) {
|
||||
return summarizeWorkflowRuns(...(args as any))
|
||||
static summarizeWorkflowRuns(...args: Parameters<typeof summarizeWorkflowRuns>) {
|
||||
return summarizeWorkflowRuns(...args)
|
||||
}
|
||||
|
||||
static formatWorkflowRunAnalysis(...args: any[]) {
|
||||
return formatWorkflowRunAnalysis(...(args as any))
|
||||
static formatWorkflowRunAnalysis(...args: Parameters<typeof formatWorkflowRunAnalysis>) {
|
||||
return formatWorkflowRunAnalysis(...args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,16 @@ import * as fengari from 'fengari-web'
|
||||
|
||||
const lua = fengari.lua
|
||||
|
||||
type LuaState = Parameters<typeof lua.lua_gettop>[0]
|
||||
|
||||
/**
|
||||
* Setup the context API functions (log, print) in Lua state
|
||||
* @param L - Lua state
|
||||
* @param logs - Array to collect log messages
|
||||
*/
|
||||
export const setupContextAPI = (L: any, logs: string[]): void => {
|
||||
export const setupContextAPI = (L: LuaState, logs: string[]): void => {
|
||||
// Create log function
|
||||
const logFunction = function (LState: any) {
|
||||
const logFunction = function (LState: LuaState) {
|
||||
const nargs = lua.lua_gettop(LState)
|
||||
const messages: string[] = []
|
||||
|
||||
@@ -38,7 +40,7 @@ export const setupContextAPI = (L: any, logs: string[]): void => {
|
||||
lua.lua_setglobal(L, fengari.to_luastring('log'))
|
||||
|
||||
// Create print function (same behavior but tab-separated)
|
||||
const printFunction = function (LState: any) {
|
||||
const printFunction = function (LState: LuaState) {
|
||||
const nargs = lua.lua_gettop(LState)
|
||||
const messages: string[] = []
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ export type ModularPackageMetadataSeed = {
|
||||
description: string
|
||||
author: string
|
||||
category: string
|
||||
/** Minimum permission level required (1=Public, 2=User, 3=Moderator, 4=Admin, 5=God, 6=Supergod) */
|
||||
minLevel: number
|
||||
/** Package dependencies that must be loaded first */
|
||||
dependencies: string[]
|
||||
/** Icon path relative to package root */
|
||||
icon?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { PACKAGE_CATALOG } from '../../../package-lib/package-catalog'
|
||||
import { loadPackageComponents } from '../../../rendering/declarative-component-renderer'
|
||||
import { buildPackageRegistry, exportAllPackagesForSeed } from '../../package-glue'
|
||||
import {
|
||||
buildPackageRegistry,
|
||||
exportAllPackagesForSeed,
|
||||
resolveDependencyOrder,
|
||||
} from '../../package-glue'
|
||||
import { setPackageRegistry } from '../registry/set-package-registry'
|
||||
import { packageSystemState } from './package-system-state'
|
||||
|
||||
@@ -14,6 +18,18 @@ export async function initializePackageSystem(): Promise<void> {
|
||||
// Load modular packages from /packages folder structure
|
||||
try {
|
||||
const packageRegistry = await buildPackageRegistry()
|
||||
|
||||
// Resolve dependencies and get load order
|
||||
const dependencyResult = resolveDependencyOrder(packageRegistry)
|
||||
|
||||
if (dependencyResult.unresolvable.length > 0) {
|
||||
console.warn('⚠️ Packages with missing dependencies:', dependencyResult.unresolvable)
|
||||
}
|
||||
|
||||
if (dependencyResult.circular.length > 0) {
|
||||
console.warn('⚠️ Circular dependencies detected:', dependencyResult.circular)
|
||||
}
|
||||
|
||||
setPackageRegistry(packageRegistry)
|
||||
|
||||
const seedData = exportAllPackagesForSeed(packageRegistry)
|
||||
@@ -26,8 +42,8 @@ export async function initializePackageSystem(): Promise<void> {
|
||||
})
|
||||
|
||||
console.log(
|
||||
`✅ Loaded ${seedData.packages.length} modular packages:`,
|
||||
seedData.packages.map(p => p.name).join(', ')
|
||||
`✅ Loaded ${seedData.packages.length} modular packages in dependency order:`,
|
||||
dependencyResult.loadOrder.join(' → ')
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Could not load modular packages:', error)
|
||||
|
||||
@@ -2,6 +2,11 @@ import { buildPackageRegistry } from './scripts/build-package-registry'
|
||||
import { checkDependencies } from './scripts/check-dependencies'
|
||||
import { exportAllPackagesForSeed } from './scripts/export-all-packages-for-seed'
|
||||
import { getAllPackageScripts } from './scripts/get-all-package-scripts'
|
||||
import {
|
||||
canAccessPackage,
|
||||
getAccessiblePackages,
|
||||
getPackagesByLevel,
|
||||
} from './scripts/get-accessible-packages'
|
||||
import { getInstalledPackages } from './scripts/get-installed-packages'
|
||||
import { getPackage } from './scripts/get-package'
|
||||
import { getPackageComponents } from './scripts/get-package-components'
|
||||
@@ -14,20 +19,31 @@ import { installPackageComponents } from './scripts/install-package-components'
|
||||
import { installPackageScripts } from './scripts/install-package-scripts'
|
||||
import { isPackageInstalled } from './scripts/is-package-installed'
|
||||
import { loadPackageIndex } from './scripts/load-package-index'
|
||||
import {
|
||||
getAllDependencies,
|
||||
getDependents,
|
||||
resolveDependencyOrder,
|
||||
} from './scripts/resolve-dependencies'
|
||||
import { uninstallPackage } from './scripts/uninstall-package'
|
||||
|
||||
export type { LuaScriptFile, PackageDefinition, PackageRegistry } from './types'
|
||||
export type { DependencyResolutionResult } from './scripts/resolve-dependencies'
|
||||
|
||||
export {
|
||||
buildPackageRegistry,
|
||||
canAccessPackage,
|
||||
checkDependencies,
|
||||
exportAllPackagesForSeed,
|
||||
getAccessiblePackages,
|
||||
getAllDependencies,
|
||||
getAllPackageScripts,
|
||||
getDependents,
|
||||
getInstalledPackages,
|
||||
getPackage,
|
||||
getPackageComponents,
|
||||
getPackageExamples,
|
||||
getPackagesByCategory,
|
||||
getPackagesByLevel,
|
||||
getPackageScriptFiles,
|
||||
getPackageScripts,
|
||||
installPackage,
|
||||
@@ -35,7 +51,19 @@ export {
|
||||
installPackageScripts,
|
||||
isPackageInstalled,
|
||||
loadPackageIndex,
|
||||
resolveDependencyOrder,
|
||||
uninstallPackage,
|
||||
}
|
||||
export { getPackageGlue } from './get-package-glue'
|
||||
export { PackageGlue, packageGlue } from './package-glue'
|
||||
|
||||
// Package sources - multi-source repository support
|
||||
export * from './sources'
|
||||
export {
|
||||
getPackageRepoConfig,
|
||||
validatePackageRepoConfig,
|
||||
DEFAULT_PACKAGE_REPO_CONFIG,
|
||||
DEVELOPMENT_PACKAGE_REPO_CONFIG,
|
||||
PRODUCTION_PACKAGE_REPO_CONFIG,
|
||||
} from './package-repo-config'
|
||||
export type { PackageRepoConfig } from './package-repo-config'
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { PackageSourceConfig, ConflictResolution } from './sources'
|
||||
|
||||
/**
|
||||
* Package repository configuration
|
||||
* This file defines all package sources and how they should be used
|
||||
*/
|
||||
export interface PackageRepoConfig {
|
||||
/** Conflict resolution strategy */
|
||||
conflictResolution: ConflictResolution
|
||||
/** List of package sources */
|
||||
sources: PackageSourceConfig[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration - local only
|
||||
*/
|
||||
export const DEFAULT_PACKAGE_REPO_CONFIG: PackageRepoConfig = {
|
||||
conflictResolution: 'priority',
|
||||
sources: [
|
||||
{
|
||||
id: 'local',
|
||||
name: 'Local Packages',
|
||||
type: 'local',
|
||||
url: '/packages',
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Development configuration - local with staging remote
|
||||
*/
|
||||
export const DEVELOPMENT_PACKAGE_REPO_CONFIG: PackageRepoConfig = {
|
||||
conflictResolution: 'local-first',
|
||||
sources: [
|
||||
{
|
||||
id: 'local',
|
||||
name: 'Local Development',
|
||||
type: 'local',
|
||||
url: '/packages',
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'staging',
|
||||
name: 'Staging Registry',
|
||||
type: 'remote',
|
||||
url: 'https://staging.registry.metabuilder.dev/api/v1',
|
||||
priority: 10,
|
||||
enabled: false, // Enable when staging registry is available
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Production configuration - remote first with local fallback
|
||||
*/
|
||||
export const PRODUCTION_PACKAGE_REPO_CONFIG: PackageRepoConfig = {
|
||||
conflictResolution: 'latest-version',
|
||||
sources: [
|
||||
{
|
||||
id: 'local',
|
||||
name: 'Bundled Packages',
|
||||
type: 'local',
|
||||
url: '/packages',
|
||||
priority: 10,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'production',
|
||||
name: 'MetaBuilder Registry',
|
||||
type: 'remote',
|
||||
url: 'https://registry.metabuilder.dev/api/v1',
|
||||
priority: 0,
|
||||
enabled: false, // Enable when production registry is available
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Get package repo config based on environment
|
||||
*/
|
||||
export const getPackageRepoConfig = (): PackageRepoConfig => {
|
||||
const env = process.env.NODE_ENV || 'development'
|
||||
const enableRemote = process.env.NEXT_PUBLIC_ENABLE_REMOTE_PACKAGES === 'true'
|
||||
|
||||
let config: PackageRepoConfig
|
||||
|
||||
switch (env) {
|
||||
case 'production':
|
||||
config = { ...PRODUCTION_PACKAGE_REPO_CONFIG }
|
||||
break
|
||||
case 'development':
|
||||
config = { ...DEVELOPMENT_PACKAGE_REPO_CONFIG }
|
||||
break
|
||||
default:
|
||||
config = { ...DEFAULT_PACKAGE_REPO_CONFIG }
|
||||
}
|
||||
|
||||
// Override remote source enabled state from env
|
||||
if (enableRemote) {
|
||||
config.sources = config.sources.map((source) => ({
|
||||
...source,
|
||||
enabled: source.type === 'remote' ? true : source.enabled,
|
||||
}))
|
||||
}
|
||||
|
||||
// Add auth token from env
|
||||
const authToken = process.env.PACKAGE_REGISTRY_AUTH_TOKEN
|
||||
if (authToken) {
|
||||
config.sources = config.sources.map((source) => ({
|
||||
...source,
|
||||
authToken: source.type === 'remote' ? authToken : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a package repo configuration
|
||||
*/
|
||||
export const validatePackageRepoConfig = (config: PackageRepoConfig): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!config.sources || config.sources.length === 0) {
|
||||
errors.push('At least one package source is required')
|
||||
}
|
||||
|
||||
const ids = new Set<string>()
|
||||
for (const source of config.sources) {
|
||||
if (!source.id) {
|
||||
errors.push('Source ID is required')
|
||||
}
|
||||
if (ids.has(source.id)) {
|
||||
errors.push(`Duplicate source ID: ${source.id}`)
|
||||
}
|
||||
ids.add(source.id)
|
||||
|
||||
if (!source.url) {
|
||||
errors.push(`Source ${source.id}: URL is required`)
|
||||
}
|
||||
|
||||
if (source.type === 'remote' && !source.url.startsWith('http')) {
|
||||
errors.push(`Source ${source.id}: Remote URL must start with http(s)`)
|
||||
}
|
||||
}
|
||||
|
||||
const enabledSources = config.sources.filter((s) => s.enabled)
|
||||
if (enabledSources.length === 0) {
|
||||
errors.push('At least one source must be enabled')
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
@@ -50,6 +50,9 @@ export function exportAllPackagesForSeed(registry: PackageRegistry) {
|
||||
description: pkg.description,
|
||||
author: pkg.author,
|
||||
category: pkg.category,
|
||||
minLevel: pkg.minLevel ?? 1,
|
||||
dependencies: pkg.dependencies ?? [],
|
||||
icon: 'static_content/icon.svg',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { PackageDefinition, PackageRegistry } from '../types'
|
||||
|
||||
/**
|
||||
* Filter packages by user permission level
|
||||
* Returns only packages the user has access to based on minLevel
|
||||
*/
|
||||
export function getAccessiblePackages(
|
||||
registry: PackageRegistry,
|
||||
userLevel: number
|
||||
): PackageRegistry {
|
||||
const accessibleRegistry: PackageRegistry = {}
|
||||
|
||||
for (const [packageId, pkg] of Object.entries(registry)) {
|
||||
if (pkg.minLevel <= userLevel) {
|
||||
accessibleRegistry[packageId] = pkg
|
||||
}
|
||||
}
|
||||
|
||||
return accessibleRegistry
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can access a specific package
|
||||
*/
|
||||
export function canAccessPackage(
|
||||
pkg: PackageDefinition,
|
||||
userLevel: number
|
||||
): boolean {
|
||||
return pkg.minLevel <= userLevel
|
||||
}
|
||||
|
||||
/**
|
||||
* Get packages grouped by permission level
|
||||
*/
|
||||
export function getPackagesByLevel(registry: PackageRegistry): Record<number, PackageDefinition[]> {
|
||||
const grouped: Record<number, PackageDefinition[]> = {
|
||||
1: [], // Public
|
||||
2: [], // User
|
||||
3: [], // Moderator
|
||||
4: [], // Admin
|
||||
5: [], // God
|
||||
6: [], // Supergod
|
||||
}
|
||||
|
||||
for (const pkg of Object.values(registry)) {
|
||||
const level = pkg.minLevel ?? 1
|
||||
if (!grouped[level]) {
|
||||
grouped[level] = []
|
||||
}
|
||||
grouped[level].push(pkg)
|
||||
}
|
||||
|
||||
return grouped
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { PackageRegistry } from '../types'
|
||||
|
||||
/**
|
||||
* Result of dependency resolution
|
||||
*/
|
||||
export interface DependencyResolutionResult {
|
||||
/** Packages in load order (dependencies first) */
|
||||
loadOrder: string[]
|
||||
/** Packages with missing dependencies */
|
||||
unresolvable: Array<{ packageId: string; missing: string[] }>
|
||||
/** Circular dependency chains detected */
|
||||
circular: string[][]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve package dependencies using topological sort
|
||||
* Returns packages in the order they should be loaded
|
||||
*/
|
||||
export function resolveDependencyOrder(registry: PackageRegistry): DependencyResolutionResult {
|
||||
const result: DependencyResolutionResult = {
|
||||
loadOrder: [],
|
||||
unresolvable: [],
|
||||
circular: [],
|
||||
}
|
||||
|
||||
const visited = new Set<string>()
|
||||
const visiting = new Set<string>()
|
||||
const resolved = new Set<string>()
|
||||
|
||||
// Check for missing dependencies first
|
||||
for (const [packageId, pkg] of Object.entries(registry)) {
|
||||
const missing = pkg.dependencies.filter(dep => !registry[dep])
|
||||
if (missing.length > 0) {
|
||||
result.unresolvable.push({ packageId, missing })
|
||||
}
|
||||
}
|
||||
|
||||
// Topological sort with cycle detection
|
||||
function visit(packageId: string, path: string[] = []): boolean {
|
||||
if (resolved.has(packageId)) return true
|
||||
if (visiting.has(packageId)) {
|
||||
// Circular dependency detected
|
||||
const cycleStart = path.indexOf(packageId)
|
||||
result.circular.push([...path.slice(cycleStart), packageId])
|
||||
return false
|
||||
}
|
||||
|
||||
const pkg = registry[packageId]
|
||||
if (!pkg) return false
|
||||
|
||||
visiting.add(packageId)
|
||||
path.push(packageId)
|
||||
|
||||
// Visit dependencies first
|
||||
for (const depId of pkg.dependencies) {
|
||||
if (registry[depId] && !visit(depId, [...path])) {
|
||||
// Dependency failed to resolve
|
||||
}
|
||||
}
|
||||
|
||||
visiting.delete(packageId)
|
||||
resolved.add(packageId)
|
||||
result.loadOrder.push(packageId)
|
||||
return true
|
||||
}
|
||||
|
||||
// Visit all packages
|
||||
for (const packageId of Object.keys(registry)) {
|
||||
if (!resolved.has(packageId)) {
|
||||
visit(packageId)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dependencies for a package (transitive)
|
||||
*/
|
||||
export function getAllDependencies(
|
||||
registry: PackageRegistry,
|
||||
packageId: string,
|
||||
visited = new Set<string>()
|
||||
): string[] {
|
||||
if (visited.has(packageId)) return []
|
||||
visited.add(packageId)
|
||||
|
||||
const pkg = registry[packageId]
|
||||
if (!pkg) return []
|
||||
|
||||
const deps: string[] = []
|
||||
for (const depId of pkg.dependencies) {
|
||||
if (registry[depId]) {
|
||||
deps.push(depId)
|
||||
deps.push(...getAllDependencies(registry, depId, visited))
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(deps)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get packages that depend on a given package
|
||||
*/
|
||||
export function getDependents(registry: PackageRegistry, packageId: string): string[] {
|
||||
const dependents: string[] = []
|
||||
|
||||
for (const [id, pkg] of Object.entries(registry)) {
|
||||
if (pkg.dependencies.includes(packageId)) {
|
||||
dependents.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
return dependents
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Package source types and interfaces
|
||||
export type {
|
||||
PackageSourceType,
|
||||
PackageSourceConfig,
|
||||
PackageIndexEntry,
|
||||
PackageData,
|
||||
PackageSource,
|
||||
} from './package-source-types'
|
||||
|
||||
export {
|
||||
DEFAULT_LOCAL_SOURCE,
|
||||
DEFAULT_REMOTE_SOURCE,
|
||||
} from './package-source-types'
|
||||
|
||||
// Local package source
|
||||
export { LocalPackageSource, createLocalSource } from './local-package-source'
|
||||
|
||||
// Remote package source
|
||||
export { RemotePackageSource, createRemoteSource } from './remote-package-source'
|
||||
|
||||
// Package source manager
|
||||
export type {
|
||||
ConflictResolution,
|
||||
PackageSourceManagerConfig,
|
||||
MergedPackageEntry,
|
||||
} from './package-source-manager'
|
||||
|
||||
export {
|
||||
PackageSourceManager,
|
||||
createPackageSourceManager,
|
||||
} from './package-source-manager'
|
||||
@@ -0,0 +1,200 @@
|
||||
import type {
|
||||
PackageSource,
|
||||
PackageSourceConfig,
|
||||
PackageIndexEntry,
|
||||
PackageData,
|
||||
} from './package-source-types'
|
||||
import { DEFAULT_LOCAL_SOURCE } from './package-source-types'
|
||||
import { loadPackageSeedJson } from '../scripts/load-package-seed-json'
|
||||
import { loadLuaScript } from '../scripts/load-lua-script'
|
||||
import { loadLuaScriptsFolder } from '../scripts/load-lua-scripts-folder'
|
||||
import type { LuaScriptFile, PackageComponent, PackageExamples } from '../types'
|
||||
|
||||
/**
|
||||
* Package seed JSON structure
|
||||
*/
|
||||
interface PackageSeedJson {
|
||||
metadata: {
|
||||
packageId: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
author: string
|
||||
category: string
|
||||
dependencies?: string[]
|
||||
minLevel?: number
|
||||
icon?: string
|
||||
}
|
||||
components?: PackageComponent[]
|
||||
examples?: PackageExamples
|
||||
}
|
||||
|
||||
/**
|
||||
* Package index structure
|
||||
*/
|
||||
interface LocalPackageIndex {
|
||||
packages: Array<{
|
||||
packageId: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
author: string
|
||||
category: string
|
||||
dependencies?: string[]
|
||||
minLevel?: number
|
||||
icon?: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Local filesystem package source
|
||||
* Loads packages from the /packages directory
|
||||
*/
|
||||
export class LocalPackageSource implements PackageSource {
|
||||
private config: PackageSourceConfig
|
||||
private indexCache: PackageIndexEntry[] | null = null
|
||||
private packageCache: Map<string, PackageData> = new Map()
|
||||
|
||||
constructor(config: Partial<PackageSourceConfig> = {}) {
|
||||
this.config = { ...DEFAULT_LOCAL_SOURCE, ...config }
|
||||
}
|
||||
|
||||
getConfig(): PackageSourceConfig {
|
||||
return this.config
|
||||
}
|
||||
|
||||
async fetchIndex(): Promise<PackageIndexEntry[]> {
|
||||
if (this.indexCache) {
|
||||
return this.indexCache
|
||||
}
|
||||
|
||||
try {
|
||||
// Load the local package index
|
||||
const packageIndex = await this.loadLocalPackageIndex()
|
||||
|
||||
// Convert to PackageIndexEntry format
|
||||
const entries: PackageIndexEntry[] = packageIndex.packages.map((pkg) => ({
|
||||
packageId: pkg.packageId,
|
||||
name: pkg.name,
|
||||
version: pkg.version,
|
||||
description: pkg.description,
|
||||
author: pkg.author,
|
||||
category: pkg.category,
|
||||
dependencies: pkg.dependencies || [],
|
||||
minLevel: pkg.minLevel || 1,
|
||||
icon: pkg.icon,
|
||||
sourceId: this.config.id,
|
||||
}))
|
||||
|
||||
this.indexCache = entries
|
||||
return entries
|
||||
} catch (error) {
|
||||
console.error('Failed to load local package index:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async loadPackage(packageId: string): Promise<PackageData | null> {
|
||||
// Check cache first
|
||||
if (this.packageCache.has(packageId)) {
|
||||
return this.packageCache.get(packageId)!
|
||||
}
|
||||
|
||||
try {
|
||||
// Load metadata from seed/metadata.json
|
||||
const seedJson = await loadPackageSeedJson<PackageSeedJson | null>(
|
||||
packageId,
|
||||
'seed/metadata.json',
|
||||
null
|
||||
)
|
||||
if (!seedJson) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Load scripts
|
||||
const scriptFiles = await this.loadScriptFiles(packageId)
|
||||
const scriptsContent = await loadLuaScript(packageId)
|
||||
|
||||
// Build package data
|
||||
const packageData: PackageData = {
|
||||
metadata: {
|
||||
packageId: seedJson.metadata.packageId,
|
||||
name: seedJson.metadata.name,
|
||||
version: seedJson.metadata.version,
|
||||
description: seedJson.metadata.description,
|
||||
author: seedJson.metadata.author,
|
||||
category: seedJson.metadata.category,
|
||||
dependencies: seedJson.metadata.dependencies || [],
|
||||
minLevel: seedJson.metadata.minLevel || 1,
|
||||
icon: seedJson.metadata.icon,
|
||||
sourceId: this.config.id,
|
||||
},
|
||||
components: seedJson.components || [],
|
||||
scripts: scriptsContent || undefined,
|
||||
scriptFiles,
|
||||
examples: seedJson.examples,
|
||||
}
|
||||
|
||||
this.packageCache.set(packageId, packageData)
|
||||
return packageData
|
||||
} catch (error) {
|
||||
console.error(`Failed to load local package ${packageId}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async hasPackage(packageId: string): Promise<boolean> {
|
||||
const index = await this.fetchIndex()
|
||||
return index.some((entry) => entry.packageId === packageId)
|
||||
}
|
||||
|
||||
async getVersions(packageId: string): Promise<string[]> {
|
||||
// Local source only has one version per package
|
||||
const index = await this.fetchIndex()
|
||||
const pkg = index.find((entry) => entry.packageId === packageId)
|
||||
return pkg ? [pkg.version] : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached data
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.indexCache = null
|
||||
this.packageCache.clear()
|
||||
}
|
||||
|
||||
private async loadLocalPackageIndex(): Promise<LocalPackageIndex> {
|
||||
try {
|
||||
// Fetch from API endpoint
|
||||
const response = await fetch('/api/packages/index')
|
||||
if (response.ok) {
|
||||
return await response.json()
|
||||
}
|
||||
} catch {
|
||||
// Fallback: try direct fetch
|
||||
try {
|
||||
const response = await fetch('/packages/index.json')
|
||||
if (response.ok) {
|
||||
return await response.json()
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Return empty if we can't load
|
||||
return { packages: [] }
|
||||
}
|
||||
|
||||
private async loadScriptFiles(packageId: string): Promise<LuaScriptFile[]> {
|
||||
const scripts = loadLuaScriptsFolder(packageId)
|
||||
return scripts || []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a local package source with default configuration
|
||||
*/
|
||||
export const createLocalSource = (
|
||||
configOverrides?: Partial<PackageSourceConfig>
|
||||
): LocalPackageSource => new LocalPackageSource(configOverrides)
|
||||
@@ -0,0 +1,344 @@
|
||||
import type {
|
||||
PackageSource,
|
||||
PackageSourceConfig,
|
||||
PackageIndexEntry,
|
||||
PackageData,
|
||||
} from './package-source-types'
|
||||
import { LocalPackageSource } from './local-package-source'
|
||||
import { RemotePackageSource } from './remote-package-source'
|
||||
|
||||
/**
|
||||
* Conflict resolution strategy when same package exists in multiple sources
|
||||
*/
|
||||
export type ConflictResolution = 'priority' | 'latest-version' | 'local-first' | 'remote-first'
|
||||
|
||||
/**
|
||||
* Package source manager configuration
|
||||
*/
|
||||
export interface PackageSourceManagerConfig {
|
||||
/** How to resolve conflicts when same package exists in multiple sources */
|
||||
conflictResolution: ConflictResolution
|
||||
/** Whether to enable parallel fetching from sources */
|
||||
parallelFetch: boolean
|
||||
/** Maximum number of sources to query in parallel */
|
||||
maxParallelSources: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Merged package entry with source information
|
||||
*/
|
||||
export interface MergedPackageEntry extends PackageIndexEntry {
|
||||
/** All sources that have this package */
|
||||
availableSources: string[]
|
||||
/** The selected source based on conflict resolution */
|
||||
selectedSource: string
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: PackageSourceManagerConfig = {
|
||||
conflictResolution: 'priority',
|
||||
parallelFetch: true,
|
||||
maxParallelSources: 5,
|
||||
}
|
||||
|
||||
/**
|
||||
* Package Source Manager
|
||||
* Manages multiple package sources (local and remote) and merges their indexes
|
||||
*/
|
||||
export class PackageSourceManager {
|
||||
private sources: Map<string, PackageSource> = new Map()
|
||||
private config: PackageSourceManagerConfig
|
||||
private mergedIndex: Map<string, MergedPackageEntry> | null = null
|
||||
|
||||
constructor(config: Partial<PackageSourceManagerConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a package source
|
||||
*/
|
||||
addSource(source: PackageSource): void {
|
||||
const sourceConfig = source.getConfig()
|
||||
if (!sourceConfig.enabled) {
|
||||
return
|
||||
}
|
||||
this.sources.set(sourceConfig.id, source)
|
||||
this.mergedIndex = null // Invalidate cache
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a package source
|
||||
*/
|
||||
removeSource(sourceId: string): boolean {
|
||||
const removed = this.sources.delete(sourceId)
|
||||
if (removed) {
|
||||
this.mergedIndex = null
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific source by ID
|
||||
*/
|
||||
getSource(sourceId: string): PackageSource | undefined {
|
||||
return this.sources.get(sourceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered sources
|
||||
*/
|
||||
getSources(): PackageSource[] {
|
||||
return Array.from(this.sources.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sources sorted by priority
|
||||
*/
|
||||
getSourcesByPriority(): PackageSource[] {
|
||||
return this.getSources().sort(
|
||||
(a, b) => a.getConfig().priority - b.getConfig().priority
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and merge package indexes from all sources
|
||||
*/
|
||||
async fetchMergedIndex(): Promise<MergedPackageEntry[]> {
|
||||
if (this.mergedIndex) {
|
||||
return Array.from(this.mergedIndex.values())
|
||||
}
|
||||
|
||||
const sortedSources = this.getSourcesByPriority()
|
||||
const packageMap = new Map<string, MergedPackageEntry>()
|
||||
|
||||
if (this.config.parallelFetch) {
|
||||
// Fetch in parallel
|
||||
const results = await Promise.all(
|
||||
sortedSources.map(async (source) => ({
|
||||
source,
|
||||
entries: await source.fetchIndex(),
|
||||
}))
|
||||
)
|
||||
|
||||
// Process in priority order
|
||||
for (const { source, entries } of results) {
|
||||
this.mergeEntries(packageMap, entries, source.getConfig())
|
||||
}
|
||||
} else {
|
||||
// Fetch sequentially
|
||||
for (const source of sortedSources) {
|
||||
const entries = await source.fetchIndex()
|
||||
this.mergeEntries(packageMap, entries, source.getConfig())
|
||||
}
|
||||
}
|
||||
|
||||
this.mergedIndex = packageMap
|
||||
return Array.from(packageMap.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a package from the best available source
|
||||
*/
|
||||
async loadPackage(packageId: string): Promise<PackageData | null> {
|
||||
const mergedIndex = await this.fetchMergedIndex()
|
||||
const entry = mergedIndex.find((e) => e.packageId === packageId)
|
||||
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Try selected source first
|
||||
const selectedSource = this.sources.get(entry.selectedSource)
|
||||
if (selectedSource) {
|
||||
const pkg = await selectedSource.loadPackage(packageId)
|
||||
if (pkg) {
|
||||
return pkg
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to other sources
|
||||
for (const sourceId of entry.availableSources) {
|
||||
if (sourceId === entry.selectedSource) continue
|
||||
|
||||
const source = this.sources.get(sourceId)
|
||||
if (source) {
|
||||
const pkg = await source.loadPackage(packageId)
|
||||
if (pkg) {
|
||||
return pkg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a package from a specific source
|
||||
*/
|
||||
async loadPackageFromSource(
|
||||
packageId: string,
|
||||
sourceId: string
|
||||
): Promise<PackageData | null> {
|
||||
const source = this.sources.get(sourceId)
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
return source.loadPackage(packageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a package exists in any source
|
||||
*/
|
||||
async hasPackage(packageId: string): Promise<boolean> {
|
||||
const mergedIndex = await this.fetchMergedIndex()
|
||||
return mergedIndex.some((e) => e.packageId === packageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available versions of a package across all sources
|
||||
*/
|
||||
async getAllVersions(packageId: string): Promise<Map<string, string[]>> {
|
||||
const versions = new Map<string, string[]>()
|
||||
|
||||
await Promise.all(
|
||||
this.getSources().map(async (source) => {
|
||||
const sourceVersions = await source.getVersions(packageId)
|
||||
if (sourceVersions.length > 0) {
|
||||
versions.set(source.getConfig().id, sourceVersions)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return versions
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all source caches
|
||||
*/
|
||||
clearAllCaches(): void {
|
||||
this.mergedIndex = null
|
||||
for (const source of this.sources.values()) {
|
||||
if ('clearCache' in source && typeof source.clearCache === 'function') {
|
||||
source.clearCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mergeEntries(
|
||||
packageMap: Map<string, MergedPackageEntry>,
|
||||
entries: PackageIndexEntry[],
|
||||
sourceConfig: PackageSourceConfig
|
||||
): void {
|
||||
for (const entry of entries) {
|
||||
const existing = packageMap.get(entry.packageId)
|
||||
|
||||
if (!existing) {
|
||||
// First occurrence
|
||||
packageMap.set(entry.packageId, {
|
||||
...entry,
|
||||
availableSources: [sourceConfig.id],
|
||||
selectedSource: sourceConfig.id,
|
||||
})
|
||||
} else {
|
||||
// Package exists in multiple sources
|
||||
existing.availableSources.push(sourceConfig.id)
|
||||
|
||||
// Determine selected source based on resolution strategy
|
||||
const shouldReplace = this.shouldReplaceSource(
|
||||
existing,
|
||||
entry,
|
||||
sourceConfig.priority
|
||||
)
|
||||
|
||||
if (shouldReplace) {
|
||||
packageMap.set(entry.packageId, {
|
||||
...entry,
|
||||
availableSources: existing.availableSources,
|
||||
selectedSource: sourceConfig.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shouldReplaceSource(
|
||||
existing: MergedPackageEntry,
|
||||
newEntry: PackageIndexEntry,
|
||||
newPriority: number
|
||||
): boolean {
|
||||
const existingSource = this.sources.get(existing.selectedSource)
|
||||
const existingPriority = existingSource?.getConfig().priority ?? Infinity
|
||||
|
||||
switch (this.config.conflictResolution) {
|
||||
case 'priority':
|
||||
return newPriority < existingPriority
|
||||
|
||||
case 'latest-version':
|
||||
return this.compareVersions(newEntry.version, existing.version) > 0
|
||||
|
||||
case 'local-first': {
|
||||
const existingType = existingSource?.getConfig().type
|
||||
const newType = this.sources.get(newEntry.sourceId)?.getConfig().type
|
||||
if (existingType === 'local') return false
|
||||
if (newType === 'local') return true
|
||||
return newPriority < existingPriority
|
||||
}
|
||||
|
||||
case 'remote-first': {
|
||||
const existingType = existingSource?.getConfig().type
|
||||
const newType = this.sources.get(newEntry.sourceId)?.getConfig().type
|
||||
if (existingType === 'remote') return false
|
||||
if (newType === 'remote') return true
|
||||
return newPriority < existingPriority
|
||||
}
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private compareVersions(a: string, b: string): number {
|
||||
const partsA = a.split('.').map(Number)
|
||||
const partsB = b.split('.').map(Number)
|
||||
|
||||
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
||||
const partA = partsA[i] || 0
|
||||
const partB = partsB[i] || 0
|
||||
if (partA > partB) return 1
|
||||
if (partA < partB) return -1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a package source manager with default local and optional remote sources
|
||||
*/
|
||||
export const createPackageSourceManager = (
|
||||
options?: {
|
||||
enableRemote?: boolean
|
||||
remoteUrl?: string
|
||||
remoteAuthToken?: string
|
||||
conflictResolution?: ConflictResolution
|
||||
}
|
||||
): PackageSourceManager => {
|
||||
const manager = new PackageSourceManager({
|
||||
conflictResolution: options?.conflictResolution || 'priority',
|
||||
})
|
||||
|
||||
// Always add local source
|
||||
manager.addSource(new LocalPackageSource())
|
||||
|
||||
// Optionally add remote source
|
||||
if (options?.enableRemote) {
|
||||
manager.addSource(
|
||||
new RemotePackageSource({
|
||||
enabled: true,
|
||||
url: options.remoteUrl,
|
||||
authToken: options.remoteAuthToken,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return manager
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import type { PackageSourceConfig, PackageIndexEntry, PackageData } from './package-source-types'
|
||||
import { DEFAULT_LOCAL_SOURCE, DEFAULT_REMOTE_SOURCE } from './package-source-types'
|
||||
|
||||
describe('package-source-types', () => {
|
||||
describe('DEFAULT_LOCAL_SOURCE', () => {
|
||||
it.each([
|
||||
{ property: 'id', expected: 'local' },
|
||||
{ property: 'name', expected: 'Local Packages' },
|
||||
{ property: 'type', expected: 'local' },
|
||||
{ property: 'url', expected: '/packages' },
|
||||
{ property: 'priority', expected: 0 },
|
||||
{ property: 'enabled', expected: true },
|
||||
])('should have correct $property', ({ property, expected }) => {
|
||||
expect(DEFAULT_LOCAL_SOURCE[property as keyof PackageSourceConfig]).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DEFAULT_REMOTE_SOURCE', () => {
|
||||
it.each([
|
||||
{ property: 'id', expected: 'metabuilder-registry' },
|
||||
{ property: 'name', expected: 'MetaBuilder Registry' },
|
||||
{ property: 'type', expected: 'remote' },
|
||||
{ property: 'priority', expected: 10 },
|
||||
{ property: 'enabled', expected: false },
|
||||
])('should have correct $property', ({ property, expected }) => {
|
||||
expect(DEFAULT_REMOTE_SOURCE[property as keyof PackageSourceConfig]).toBe(expected)
|
||||
})
|
||||
|
||||
it('should have https URL', () => {
|
||||
expect(DEFAULT_REMOTE_SOURCE.url).toMatch(/^https:\/\//)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('PackageSourceConfig interface', () => {
|
||||
it('should allow valid local config', () => {
|
||||
const config: PackageSourceConfig = {
|
||||
id: 'test-local',
|
||||
name: 'Test Local',
|
||||
type: 'local',
|
||||
url: '/custom-packages',
|
||||
priority: 5,
|
||||
enabled: true,
|
||||
}
|
||||
expect(config.type).toBe('local')
|
||||
})
|
||||
|
||||
it('should allow valid remote config with auth', () => {
|
||||
const config: PackageSourceConfig = {
|
||||
id: 'test-remote',
|
||||
name: 'Test Remote',
|
||||
type: 'remote',
|
||||
url: 'https://example.com/api',
|
||||
priority: 10,
|
||||
enabled: true,
|
||||
authToken: 'secret-token',
|
||||
}
|
||||
expect(config.authToken).toBe('secret-token')
|
||||
})
|
||||
|
||||
it('should allow git config with branch', () => {
|
||||
const config: PackageSourceConfig = {
|
||||
id: 'test-git',
|
||||
name: 'Test Git',
|
||||
type: 'git',
|
||||
url: 'https://github.com/org/repo',
|
||||
priority: 20,
|
||||
enabled: true,
|
||||
branch: 'main',
|
||||
}
|
||||
expect(config.branch).toBe('main')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PackageIndexEntry interface', () => {
|
||||
it('should represent a complete package entry', () => {
|
||||
const entry: PackageIndexEntry = {
|
||||
packageId: 'test-package',
|
||||
name: 'Test Package',
|
||||
version: '1.0.0',
|
||||
description: 'A test package',
|
||||
author: 'Test Author',
|
||||
category: 'testing',
|
||||
dependencies: ['dep1', 'dep2'],
|
||||
minLevel: 2,
|
||||
sourceId: 'local',
|
||||
icon: 'test-icon.svg',
|
||||
checksum: 'abc123',
|
||||
}
|
||||
expect(entry.packageId).toBe('test-package')
|
||||
expect(entry.dependencies).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should allow minimal entry without optional fields', () => {
|
||||
const entry: PackageIndexEntry = {
|
||||
packageId: 'minimal',
|
||||
name: 'Minimal',
|
||||
version: '0.1.0',
|
||||
description: 'Minimal package',
|
||||
author: 'Anonymous',
|
||||
category: 'other',
|
||||
dependencies: [],
|
||||
minLevel: 1,
|
||||
sourceId: 'local',
|
||||
}
|
||||
expect(entry.icon).toBeUndefined()
|
||||
expect(entry.checksum).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PackageData interface', () => {
|
||||
it('should represent complete package data', () => {
|
||||
const data: PackageData = {
|
||||
metadata: {
|
||||
packageId: 'complete',
|
||||
name: 'Complete Package',
|
||||
version: '2.0.0',
|
||||
description: 'A complete package',
|
||||
author: 'Author',
|
||||
category: 'complete',
|
||||
dependencies: [],
|
||||
minLevel: 1,
|
||||
sourceId: 'local',
|
||||
},
|
||||
components: [{ id: 'component1', type: 'button' }],
|
||||
scripts: 'local M = {}; return M',
|
||||
scriptFiles: [
|
||||
{ name: 'init', path: 'scripts/init.lua', code: 'return true' },
|
||||
],
|
||||
examples: { demo: { title: 'Demo' } },
|
||||
}
|
||||
expect(data.components).toHaveLength(1)
|
||||
expect(data.scriptFiles).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { PackageComponent, PackageExamples, LuaScriptFile } from '../types'
|
||||
|
||||
/**
|
||||
* Package source types
|
||||
*/
|
||||
export type PackageSourceType = 'local' | 'remote' | 'git'
|
||||
|
||||
/**
|
||||
* Package source configuration
|
||||
*/
|
||||
export interface PackageSourceConfig {
|
||||
/** Unique identifier for this source */
|
||||
id: string
|
||||
/** Display name */
|
||||
name: string
|
||||
/** Source type */
|
||||
type: PackageSourceType
|
||||
/** Base URL or path */
|
||||
url: string
|
||||
/** Priority (lower = higher priority, used for conflict resolution) */
|
||||
priority: number
|
||||
/** Whether this source is enabled */
|
||||
enabled: boolean
|
||||
/** Authentication token for remote sources */
|
||||
authToken?: string
|
||||
/** Branch for git sources */
|
||||
branch?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Package index entry from a source
|
||||
*/
|
||||
export interface PackageIndexEntry {
|
||||
packageId: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
author: string
|
||||
category: string
|
||||
dependencies: string[]
|
||||
minLevel: number
|
||||
icon?: string
|
||||
/** Which source this package comes from */
|
||||
sourceId: string
|
||||
/** Checksum for integrity verification */
|
||||
checksum?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Full package data loaded from a source
|
||||
*/
|
||||
export interface PackageData {
|
||||
metadata: PackageIndexEntry
|
||||
components: PackageComponent[]
|
||||
scripts?: string
|
||||
scriptFiles: LuaScriptFile[]
|
||||
examples?: PackageExamples
|
||||
}
|
||||
|
||||
/**
|
||||
* Package source interface - implemented by local and remote loaders
|
||||
*/
|
||||
export interface PackageSource {
|
||||
/** Get the source configuration */
|
||||
getConfig(): PackageSourceConfig
|
||||
|
||||
/** Fetch the package index from this source */
|
||||
fetchIndex(): Promise<PackageIndexEntry[]>
|
||||
|
||||
/** Load full package data for a specific package */
|
||||
loadPackage(packageId: string): Promise<PackageData | null>
|
||||
|
||||
/** Check if a package exists in this source */
|
||||
hasPackage(packageId: string): Promise<boolean>
|
||||
|
||||
/** Get package versions available */
|
||||
getVersions(packageId: string): Promise<string[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Default local source configuration
|
||||
*/
|
||||
export const DEFAULT_LOCAL_SOURCE: PackageSourceConfig = {
|
||||
id: 'local',
|
||||
name: 'Local Packages',
|
||||
type: 'local',
|
||||
url: '/packages',
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Default remote source configuration (MetaBuilder registry)
|
||||
*/
|
||||
export const DEFAULT_REMOTE_SOURCE: PackageSourceConfig = {
|
||||
id: 'metabuilder-registry',
|
||||
name: 'MetaBuilder Registry',
|
||||
type: 'remote',
|
||||
url: 'https://registry.metabuilder.dev/api/v1',
|
||||
priority: 10,
|
||||
enabled: false, // Disabled by default until registry is available
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import type {
|
||||
PackageSource,
|
||||
PackageSourceConfig,
|
||||
PackageIndexEntry,
|
||||
PackageData,
|
||||
} from './package-source-types'
|
||||
import { DEFAULT_REMOTE_SOURCE } from './package-source-types'
|
||||
import type { LuaScriptFile, PackageComponent, PackageExamples } from '../types'
|
||||
|
||||
/**
|
||||
* Remote package registry API response types
|
||||
*/
|
||||
interface RemotePackageIndexResponse {
|
||||
packages: PackageIndexEntry[]
|
||||
totalCount: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
interface RemotePackageResponse {
|
||||
metadata: PackageIndexEntry
|
||||
components: PackageComponent[]
|
||||
scripts?: string
|
||||
scriptFiles: Array<{
|
||||
name: string
|
||||
path: string
|
||||
code: string
|
||||
category?: string
|
||||
description?: string
|
||||
}>
|
||||
examples?: PackageExamples
|
||||
}
|
||||
|
||||
interface RemoteVersionsResponse {
|
||||
packageId: string
|
||||
versions: Array<{
|
||||
version: string
|
||||
publishedAt: string
|
||||
checksum: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote package source
|
||||
* Loads packages from a remote registry API
|
||||
*/
|
||||
export class RemotePackageSource implements PackageSource {
|
||||
private config: PackageSourceConfig
|
||||
private indexCache: PackageIndexEntry[] | null = null
|
||||
private packageCache: Map<string, PackageData> = new Map()
|
||||
private cacheExpiry: number = 5 * 60 * 1000 // 5 minutes
|
||||
private lastFetch: number = 0
|
||||
|
||||
constructor(config: Partial<PackageSourceConfig> = {}) {
|
||||
this.config = { ...DEFAULT_REMOTE_SOURCE, ...config }
|
||||
}
|
||||
|
||||
getConfig(): PackageSourceConfig {
|
||||
return this.config
|
||||
}
|
||||
|
||||
async fetchIndex(): Promise<PackageIndexEntry[]> {
|
||||
// Check cache validity
|
||||
if (this.indexCache && Date.now() - this.lastFetch < this.cacheExpiry) {
|
||||
return this.indexCache
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithAuth(`${this.config.url}/packages`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch package index: ${response.status}`)
|
||||
}
|
||||
|
||||
const data: RemotePackageIndexResponse = await response.json()
|
||||
|
||||
// Add sourceId to each entry
|
||||
const entries = data.packages.map((pkg) => ({
|
||||
...pkg,
|
||||
sourceId: this.config.id,
|
||||
}))
|
||||
|
||||
this.indexCache = entries
|
||||
this.lastFetch = Date.now()
|
||||
return entries
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch remote package index:', error)
|
||||
// Return cached data if available, even if expired
|
||||
return this.indexCache || []
|
||||
}
|
||||
}
|
||||
|
||||
async loadPackage(packageId: string, version?: string): Promise<PackageData | null> {
|
||||
const cacheKey = version ? `${packageId}@${version}` : packageId
|
||||
|
||||
// Check cache first
|
||||
if (this.packageCache.has(cacheKey)) {
|
||||
return this.packageCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
try {
|
||||
const url = version
|
||||
? `${this.config.url}/packages/${packageId}/versions/${version}`
|
||||
: `${this.config.url}/packages/${packageId}`
|
||||
|
||||
const response = await this.fetchWithAuth(url)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
throw new Error(`Failed to fetch package: ${response.status}`)
|
||||
}
|
||||
|
||||
const data: RemotePackageResponse = await response.json()
|
||||
|
||||
// Convert to PackageData format
|
||||
const packageData: PackageData = {
|
||||
metadata: {
|
||||
...data.metadata,
|
||||
sourceId: this.config.id,
|
||||
},
|
||||
components: data.components,
|
||||
scripts: data.scripts,
|
||||
scriptFiles: data.scriptFiles as LuaScriptFile[],
|
||||
examples: data.examples,
|
||||
}
|
||||
|
||||
this.packageCache.set(cacheKey, packageData)
|
||||
return packageData
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch remote package ${packageId}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async hasPackage(packageId: string): Promise<boolean> {
|
||||
const index = await this.fetchIndex()
|
||||
return index.some((entry) => entry.packageId === packageId)
|
||||
}
|
||||
|
||||
async getVersions(packageId: string): Promise<string[]> {
|
||||
try {
|
||||
const response = await this.fetchWithAuth(
|
||||
`${this.config.url}/packages/${packageId}/versions`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data: RemoteVersionsResponse = await response.json()
|
||||
return data.versions.map((v) => v.version)
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch versions for ${packageId}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search packages by query
|
||||
*/
|
||||
async searchPackages(query: string, options?: {
|
||||
category?: string
|
||||
minLevel?: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<PackageIndexEntry[]> {
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query })
|
||||
if (options?.category) params.set('category', options.category)
|
||||
if (options?.minLevel) params.set('minLevel', String(options.minLevel))
|
||||
if (options?.page) params.set('page', String(options.page))
|
||||
if (options?.pageSize) params.set('pageSize', String(options.pageSize))
|
||||
|
||||
const response = await this.fetchWithAuth(
|
||||
`${this.config.url}/packages/search?${params}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data: RemotePackageIndexResponse = await response.json()
|
||||
return data.packages.map((pkg) => ({
|
||||
...pkg,
|
||||
sourceId: this.config.id,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Failed to search packages:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached data
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.indexCache = null
|
||||
this.packageCache.clear()
|
||||
this.lastFetch = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cache expiry time in milliseconds
|
||||
*/
|
||||
setCacheExpiry(ms: number): void {
|
||||
this.cacheExpiry = ms
|
||||
}
|
||||
|
||||
private async fetchWithAuth(url: string, init?: RequestInit): Promise<Response> {
|
||||
const headers = new Headers(init?.headers)
|
||||
|
||||
if (this.config.authToken) {
|
||||
headers.set('Authorization', `Bearer ${this.config.authToken}`)
|
||||
}
|
||||
|
||||
headers.set('Accept', 'application/json')
|
||||
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a remote package source with configuration
|
||||
*/
|
||||
export const createRemoteSource = (
|
||||
configOverrides?: Partial<PackageSourceConfig>
|
||||
): RemotePackageSource => new RemotePackageSource(configOverrides)
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
export interface DeclarativeComponentConfig {
|
||||
type: string
|
||||
category: string
|
||||
@@ -8,7 +10,7 @@ export interface DeclarativeComponentConfig {
|
||||
name: string
|
||||
type: string
|
||||
label: string
|
||||
defaultValue?: any
|
||||
defaultValue?: JsonValue
|
||||
required: boolean
|
||||
}>
|
||||
config: {
|
||||
@@ -16,7 +18,7 @@ export interface DeclarativeComponentConfig {
|
||||
styling: {
|
||||
className: string
|
||||
}
|
||||
children: any[]
|
||||
children: JsonValue[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +33,7 @@ export interface MessageFormat {
|
||||
|
||||
export interface LuaScriptDefinition {
|
||||
code: string
|
||||
parameters: any[]
|
||||
parameters: JsonValue[]
|
||||
returnType: string
|
||||
isSandboxed?: boolean
|
||||
allowedGlobals?: string[]
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getModelLabelPlural,
|
||||
sortRecords,
|
||||
} from '@/lib/schema-utils'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
import { createMockField, createMockModel } from './schema-utils.fixtures'
|
||||
|
||||
@@ -167,7 +168,7 @@ describe('schema-utils serialization', () => {
|
||||
})
|
||||
|
||||
describe('sortRecords', () => {
|
||||
let records: any[]
|
||||
let records: Array<Record<string, JsonValue>>
|
||||
|
||||
beforeEach(() => {
|
||||
records = [
|
||||
@@ -201,7 +202,7 @@ describe('schema-utils serialization', () => {
|
||||
})
|
||||
|
||||
describe('filterRecords', () => {
|
||||
let records: any[]
|
||||
let records: Array<Record<string, JsonValue>>
|
||||
|
||||
beforeEach(() => {
|
||||
records = [
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { FieldSchema } from '@/lib/schema-types'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
/**
|
||||
* Get the default value for a field based on its type
|
||||
* @param field - The field schema
|
||||
* @returns The appropriate default value for the field type
|
||||
*/
|
||||
export const getDefaultValue = (field: FieldSchema): any => {
|
||||
export const getDefaultValue = (field: FieldSchema): JsonValue => {
|
||||
if (field.default !== undefined) return field.default
|
||||
|
||||
switch (field.type) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FieldSchema } from '@/lib/schema-types'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
import { getFieldLabel } from './get-field-label'
|
||||
|
||||
@@ -8,7 +9,7 @@ import { getFieldLabel } from './get-field-label'
|
||||
* @param value - The value to validate
|
||||
* @returns Error message if validation fails, null otherwise
|
||||
*/
|
||||
export const validateField = (field: FieldSchema, value: any): string | null => {
|
||||
export const validateField = (field: FieldSchema, value: JsonValue): string | null => {
|
||||
if (field.required && (value === undefined || value === null || value === '')) {
|
||||
return `${getFieldLabel(field)} is required`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ModelSchema } from '@/lib/schema-types'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
import { getDefaultValue } from '../../field/get-default-value'
|
||||
import { generateId } from './generate-id'
|
||||
@@ -8,8 +9,8 @@ import { generateId } from './generate-id'
|
||||
* @param model - The model schema to create a record for
|
||||
* @returns A new record with default field values
|
||||
*/
|
||||
export const createEmptyRecord = (model: ModelSchema): any => {
|
||||
const record: any = {}
|
||||
export const createEmptyRecord = (model: ModelSchema): Record<string, JsonValue> => {
|
||||
const record: Record<string, JsonValue> = {}
|
||||
|
||||
for (const field of model.fields) {
|
||||
if (field.name === 'id') {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
/**
|
||||
* Filter records by search term and field filters
|
||||
* @param records - The records to filter
|
||||
@@ -6,12 +8,13 @@
|
||||
* @param filters - Field-value pairs to filter by
|
||||
* @returns Filtered records array
|
||||
*/
|
||||
|
||||
export const filterRecords = (
|
||||
records: any[],
|
||||
records: Record<string, JsonValue>[],
|
||||
searchTerm: string,
|
||||
searchFields: string[],
|
||||
filters: Record<string, any>
|
||||
): any[] => {
|
||||
filters: Record<string, JsonValue>
|
||||
): Record<string, JsonValue>[] => {
|
||||
let filtered = records
|
||||
|
||||
if (searchTerm) {
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
* @param direction - Sort direction ('asc' or 'desc')
|
||||
* @returns Sorted copy of the records array
|
||||
*/
|
||||
export const sortRecords = (records: any[], field: string, direction: 'asc' | 'desc'): any[] => {
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
export const sortRecords = (
|
||||
records: Record<string, JsonValue>[],
|
||||
field: string,
|
||||
direction: 'asc' | 'desc'
|
||||
): Record<string, JsonValue>[] => {
|
||||
return [...records].sort((a, b) => {
|
||||
const aVal = a[field]
|
||||
const bVal = b[field]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ModelSchema } from '@/lib/schema-types'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
import { validateField } from '../field/validate-field'
|
||||
|
||||
@@ -8,7 +9,10 @@ import { validateField } from '../field/validate-field'
|
||||
* @param record - The record to validate
|
||||
* @returns Object mapping field names to error messages
|
||||
*/
|
||||
export const validateRecord = (model: ModelSchema, record: any): Record<string, string> => {
|
||||
export const validateRecord = (
|
||||
model: ModelSchema,
|
||||
record: Record<string, JsonValue>
|
||||
): Record<string, string> => {
|
||||
const errors: Record<string, string> = {}
|
||||
|
||||
for (const field of model.fields) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"icon": "static_content/icon.svg",
|
||||
"author": "MetaBuilder",
|
||||
"category": "ui",
|
||||
"dependencies": [],
|
||||
"dependencies": ["ui_dialogs", "ui_permissions"],
|
||||
"exports": {
|
||||
"components": []
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"icon": "static_content/icon.svg",
|
||||
"author": "MetaBuilder",
|
||||
"category": "gaming",
|
||||
"dependencies": [],
|
||||
"dependencies": ["ui_permissions", "dashboard"],
|
||||
"exports": {
|
||||
"components": []
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"icon": "static_content/icon.svg",
|
||||
"author": "MetaBuilder",
|
||||
"category": "ui",
|
||||
"dependencies": [],
|
||||
"dependencies": ["data_table", "ui_permissions"],
|
||||
"exports": {
|
||||
"components": [
|
||||
"StatCard",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"icon": "static_content/icon.svg",
|
||||
"author": "MetaBuilder",
|
||||
"category": "social",
|
||||
"dependencies": [],
|
||||
"dependencies": ["ui_permissions", "data_table", "form_builder"],
|
||||
"exports": {
|
||||
"components": []
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"generatedAt": "2025-12-30T00:19:15.231Z",
|
||||
"generatedAt": "2025-12-30T00:26:21.122Z",
|
||||
"packages": [
|
||||
{
|
||||
"packageId": "admin_dialog",
|
||||
@@ -9,7 +9,10 @@
|
||||
"icon": "static_content/icon.svg",
|
||||
"author": "MetaBuilder",
|
||||
"category": "ui",
|
||||
"dependencies": [],
|
||||
"dependencies": [
|
||||
"ui_dialogs",
|
||||
"ui_permissions"
|
||||
],
|
||||
"exports": {
|
||||
"components": []
|
||||
},
|
||||
@@ -23,7 +26,10 @@
|
||||
"icon": "static_content/icon.svg",
|
||||
"author": "MetaBuilder",
|
||||
"category": "gaming",
|
||||
"dependencies": [],
|
||||
"dependencies": [
|
||||
"ui_permissions",
|
||||
"dashboard"
|
||||
],
|
||||
"exports": {
|
||||
"components": []
|
||||
},
|
||||
@@ -75,7 +81,10 @@
|
||||
"icon": "static_content/icon.svg",
|
||||
"author": "MetaBuilder",
|
||||
"category": "ui",
|
||||
"dependencies": [],
|
||||
"dependencies": [
|
||||
"data_table",
|
||||
"ui_permissions"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"StatCard",
|
||||
@@ -135,7 +144,11 @@
|
||||
"icon": "static_content/icon.svg",
|
||||
"author": "MetaBuilder",
|
||||
"category": "social",
|
||||
"dependencies": [],
|
||||
"dependencies": [
|
||||
"ui_permissions",
|
||||
"data_table",
|
||||
"form_builder"
|
||||
],
|
||||
"exports": {
|
||||
"components": []
|
||||
},
|
||||
@@ -212,7 +225,10 @@
|
||||
"icon": "static_content/icon.svg",
|
||||
"author": "MetaBuilder",
|
||||
"category": "social",
|
||||
"dependencies": [],
|
||||
"dependencies": [
|
||||
"ui_permissions",
|
||||
"form_builder"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"social_hub_root",
|
||||
@@ -256,7 +272,10 @@
|
||||
"icon": "static_content/icon.svg",
|
||||
"author": "MetaBuilder",
|
||||
"category": "media",
|
||||
"dependencies": [],
|
||||
"dependencies": [
|
||||
"ui_permissions",
|
||||
"dashboard"
|
||||
],
|
||||
"exports": {
|
||||
"components": []
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"icon": "static_content/icon.svg",
|
||||
"author": "MetaBuilder",
|
||||
"category": "social",
|
||||
"dependencies": [],
|
||||
"dependencies": ["ui_permissions", "form_builder"],
|
||||
"exports": {
|
||||
"components": [
|
||||
"social_hub_root",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"icon": "static_content/icon.svg",
|
||||
"author": "MetaBuilder",
|
||||
"category": "media",
|
||||
"dependencies": [],
|
||||
"dependencies": ["ui_permissions", "dashboard"],
|
||||
"exports": {
|
||||
"components": []
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user