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:
2025-12-30 00:32:52 +00:00
parent ec76c69609
commit 52e1337b69
59 changed files with 1729 additions and 93 deletions

View 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 }
)
}
}

View File

@@ -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({

View File

@@ -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)
}

View File

@@ -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))
})

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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',

View File

@@ -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,
}))
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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')

View File

@@ -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

View File

@@ -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'

View File

@@ -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> {

View File

@@ -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}`)

View File

@@ -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'

View File

@@ -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] })

View File

@@ -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) {

View File

@@ -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> {

View File

@@ -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)
}
}

View File

@@ -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[] = []

View File

@@ -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
}
/**

View File

@@ -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)

View File

@@ -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'

View File

@@ -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
}

View File

@@ -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',
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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'

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)
})
})

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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[]

View File

@@ -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 = [

View File

@@ -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) {

View File

@@ -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`
}

View File

@@ -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') {

View File

@@ -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) {

View File

@@ -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]

View File

@@ -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) {

View File

@@ -6,7 +6,7 @@
"icon": "static_content/icon.svg",
"author": "MetaBuilder",
"category": "ui",
"dependencies": [],
"dependencies": ["ui_dialogs", "ui_permissions"],
"exports": {
"components": []
},

View File

@@ -6,7 +6,7 @@
"icon": "static_content/icon.svg",
"author": "MetaBuilder",
"category": "gaming",
"dependencies": [],
"dependencies": ["ui_permissions", "dashboard"],
"exports": {
"components": []
},

View File

@@ -6,7 +6,7 @@
"icon": "static_content/icon.svg",
"author": "MetaBuilder",
"category": "ui",
"dependencies": [],
"dependencies": ["data_table", "ui_permissions"],
"exports": {
"components": [
"StatCard",

View File

@@ -6,7 +6,7 @@
"icon": "static_content/icon.svg",
"author": "MetaBuilder",
"category": "social",
"dependencies": [],
"dependencies": ["ui_permissions", "data_table", "form_builder"],
"exports": {
"components": []
},

View File

@@ -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": []
},

View File

@@ -6,7 +6,7 @@
"icon": "static_content/icon.svg",
"author": "MetaBuilder",
"category": "social",
"dependencies": [],
"dependencies": ["ui_permissions", "form_builder"],
"exports": {
"components": [
"social_hub_root",

View File

@@ -6,7 +6,7 @@
"icon": "static_content/icon.svg",
"author": "MetaBuilder",
"category": "media",
"dependencies": [],
"dependencies": ["ui_permissions", "dashboard"],
"exports": {
"components": []
},