From 52e1337b6984631666019ec6974a3dafbc802cc4 Mon Sep 17 00:00:00 2001 From: JohnDoe6345789 Date: Tue, 30 Dec 2025 00:32:52 +0000 Subject: [PATCH] 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. --- .../src/app/api/packages/index/route.ts | 33 ++ .../src/components/level4/Level4Tabs.tsx | 9 +- .../src/lib/db/comments/crud/set-comments.ts | 8 +- .../db/comments/crud/update-comment.test.ts | 9 +- .../config/get-component-configs.ts | 13 +- .../config/set-component-configs.ts | 8 +- .../hierarchy/get-component-hierarchy.ts | 13 +- .../hierarchy/set-component-hierarchy.ts | 8 +- .../lib/db/database-admin/clear-database.ts | 12 +- .../functions/comments/crud/update-comment.ts | 7 +- .../functions/components/hierarchy/types.ts | 6 +- .../install/getters/get-installed-packages.ts | 13 +- .../install/getters/set-installed-packages.ts | 10 +- .../src/lib/db/pages/crud/add-page.test.ts | 9 +- .../nextjs/src/lib/db/pages/crud/get-pages.ts | 35 +- .../src/lib/db/pages/crud/set-pages.test.ts | 15 +- .../src/lib/db/schemas/crud/get-schemas.ts | 16 +- .../crud/delete/delete-session-by-token.ts | 10 +- .../src/lib/db/sessions/map-session-record.ts | 11 +- .../src/lib/db/smtp-config/get-smtp-config.ts | 6 +- .../functions/get-instance.ts | 2 +- .../client/dbal-integration/functions/get.ts | 3 +- .../dbal-integration/functions/kv-get.ts | 3 +- .../dbal-integration/functions/kv-list-add.ts | 3 +- .../dbal-integration/functions/kv-list-get.ts | 3 +- .../dbal-integration/functions/kv-set.ts | 3 +- .../dbal-integration/functions/list-add.ts | 7 +- .../dbal-integration/functions/list-get.ts | 3 +- .../client/dbal-integration/functions/set.ts | 3 +- .../analysis/runs/stats/StatsUtils.ts | 12 +- .../lua/functions/setup/setup-context-api.ts | 8 +- .../modular/modular-package-seed-data.ts | 6 + .../loader/state/initialize-package-system.ts | 22 +- .../src/lib/packages/package-glue/index.ts | 28 ++ .../package-glue/package-repo-config.ts | 156 ++++++++ .../scripts/export-all-packages-for-seed.ts | 3 + .../scripts/get-accessible-packages.ts | 54 +++ .../scripts/resolve-dependencies.ts | 115 ++++++ .../packages/package-glue/sources/index.ts | 31 ++ .../sources/local-package-source.ts | 200 ++++++++++ .../sources/package-source-manager.ts | 344 ++++++++++++++++++ .../sources/package-source-types.test.ts | 136 +++++++ .../sources/package-source-types.ts | 102 ++++++ .../sources/remote-package-source.ts | 232 ++++++++++++ .../declarative-component-renderer/types.ts | 8 +- .../schema-utils.serialization.test.ts | 5 +- .../functions/field/get-default-value.ts | 3 +- .../schema/functions/field/validate-field.ts | 3 +- .../record/crud/create-empty-record.ts | 5 +- .../schema/functions/record/filter-records.ts | 9 +- .../schema/functions/record/sort-records.ts | 8 +- .../functions/record/validate-record.ts | 6 +- packages/admin_dialog/seed/metadata.json | 2 +- packages/arcade_lobby/seed/metadata.json | 2 +- packages/dashboard/seed/metadata.json | 2 +- packages/forum_forge/seed/metadata.json | 2 +- packages/index.json | 33 +- packages/social_hub/seed/metadata.json | 2 +- packages/stream_cast/seed/metadata.json | 2 +- 59 files changed, 1729 insertions(+), 93 deletions(-) create mode 100644 frontends/nextjs/src/app/api/packages/index/route.ts create mode 100644 frontends/nextjs/src/lib/packages/package-glue/package-repo-config.ts create mode 100644 frontends/nextjs/src/lib/packages/package-glue/scripts/get-accessible-packages.ts create mode 100644 frontends/nextjs/src/lib/packages/package-glue/scripts/resolve-dependencies.ts create mode 100644 frontends/nextjs/src/lib/packages/package-glue/sources/index.ts create mode 100644 frontends/nextjs/src/lib/packages/package-glue/sources/local-package-source.ts create mode 100644 frontends/nextjs/src/lib/packages/package-glue/sources/package-source-manager.ts create mode 100644 frontends/nextjs/src/lib/packages/package-glue/sources/package-source-types.test.ts create mode 100644 frontends/nextjs/src/lib/packages/package-glue/sources/package-source-types.ts create mode 100644 frontends/nextjs/src/lib/packages/package-glue/sources/remote-package-source.ts diff --git a/frontends/nextjs/src/app/api/packages/index/route.ts b/frontends/nextjs/src/app/api/packages/index/route.ts new file mode 100644 index 000000000..2fb8cdee0 --- /dev/null +++ b/frontends/nextjs/src/app/api/packages/index/route.ts @@ -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 } + ) + } +} diff --git a/frontends/nextjs/src/components/level4/Level4Tabs.tsx b/frontends/nextjs/src/components/level4/Level4Tabs.tsx index 9b9d5ad1c..44597305d 100644 --- a/frontends/nextjs/src/components/level4/Level4Tabs.tsx +++ b/frontends/nextjs/src/components/level4/Level4Tabs.tsx @@ -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 - onWorkflowsChange: (workflows: any[]) => Promise - onLuaScriptsChange: (scripts: any[]) => Promise + onSchemasChange: (schemas: ModelSchema[]) => Promise + onWorkflowsChange: (workflows: Workflow[]) => Promise + onLuaScriptsChange: (scripts: LuaScript[]) => Promise } export function Level4Tabs({ diff --git a/frontends/nextjs/src/lib/db/comments/crud/set-comments.ts b/frontends/nextjs/src/lib/db/comments/crud/set-comments.ts index 6d6933170..8f0a586d4 100644 --- a/frontends/nextjs/src/lib/db/comments/crud/set-comments.ts +++ b/frontends/nextjs/src/lib/db/comments/crud/set-comments.ts @@ -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 { 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) } diff --git a/frontends/nextjs/src/lib/db/comments/crud/update-comment.test.ts b/frontends/nextjs/src/lib/db/comments/crud/update-comment.test.ts index 8f2e4058d..066bcc633 100644 --- a/frontends/nextjs/src/lib/db/comments/crud/update-comment.test.ts +++ b/frontends/nextjs/src/lib/db/comments/crud/update-comment.test.ts @@ -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 }> = [ { 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)) }) diff --git a/frontends/nextjs/src/lib/db/components/config/get-component-configs.ts b/frontends/nextjs/src/lib/db/components/config/get-component-configs.ts index 8d1e73310..13f550adb 100644 --- a/frontends/nextjs/src/lib/db/components/config/get-component-configs.ts +++ b/frontends/nextjs/src/lib/db/components/config/get-component-configs.ts @@ -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> { const adapter = getAdapter() - const result = await adapter.list('ComponentConfig') + const result = (await adapter.list('ComponentConfig')) as { data: DBALComponentConfigRecord[] } const configs: Record = {} - for (const config of result.data as any[]) { + for (const config of result.data) { configs[config.id] = { id: config.id, componentId: config.componentId, diff --git a/frontends/nextjs/src/lib/db/components/config/set-component-configs.ts b/frontends/nextjs/src/lib/db/components/config/set-component-configs.ts index acdd24b70..256ba4c43 100644 --- a/frontends/nextjs/src/lib/db/components/config/set-component-configs.ts +++ b/frontends/nextjs/src/lib/db/components/config/set-component-configs.ts @@ -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): Promise { 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) } diff --git a/frontends/nextjs/src/lib/db/components/hierarchy/get-component-hierarchy.ts b/frontends/nextjs/src/lib/db/components/hierarchy/get-component-hierarchy.ts index 85b5d553b..c2a8ec5c2 100644 --- a/frontends/nextjs/src/lib/db/components/hierarchy/get-component-hierarchy.ts +++ b/frontends/nextjs/src/lib/db/components/hierarchy/get-component-hierarchy.ts @@ -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> { const adapter = getAdapter() - const result = await adapter.list('ComponentNode') + const result = (await adapter.list('ComponentNode')) as { data: DBALComponentNodeRecord[] } const hierarchy: Record = {} - for (const node of result.data as any[]) { + for (const node of result.data) { hierarchy[node.id] = { id: node.id, type: node.type, diff --git a/frontends/nextjs/src/lib/db/components/hierarchy/set-component-hierarchy.ts b/frontends/nextjs/src/lib/db/components/hierarchy/set-component-hierarchy.ts index e66d2ff17..9eea44caa 100644 --- a/frontends/nextjs/src/lib/db/components/hierarchy/set-component-hierarchy.ts +++ b/frontends/nextjs/src/lib/db/components/hierarchy/set-component-hierarchy.ts @@ -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 ): Promise { 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) } diff --git a/frontends/nextjs/src/lib/db/database-admin/clear-database.ts b/frontends/nextjs/src/lib/db/database-admin/clear-database.ts index d77b30793..88943acb0 100644 --- a/frontends/nextjs/src/lib/db/database-admin/clear-database.ts +++ b/frontends/nextjs/src/lib/db/database-admin/clear-database.ts @@ -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 { 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) diff --git a/frontends/nextjs/src/lib/db/functions/comments/crud/update-comment.ts b/frontends/nextjs/src/lib/db/functions/comments/crud/update-comment.ts index 3a0721eda..265f79098 100644 --- a/frontends/nextjs/src/lib/db/functions/comments/crud/update-comment.ts +++ b/frontends/nextjs/src/lib/db/functions/comments/crud/update-comment.ts @@ -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 ): Promise => { - const data: any = {} + const data: CommentUpdateData = {} if (updates.content !== undefined) data.content = updates.content if (updates.updatedAt !== undefined) data.updatedAt = BigInt(updates.updatedAt) diff --git a/frontends/nextjs/src/lib/db/functions/components/hierarchy/types.ts b/frontends/nextjs/src/lib/db/functions/components/hierarchy/types.ts index cbc4e329b..89771d866 100644 --- a/frontends/nextjs/src/lib/db/functions/components/hierarchy/types.ts +++ b/frontends/nextjs/src/lib/db/functions/components/hierarchy/types.ts @@ -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 - styles: Record + props: Record + styles: Record events: Record conditionalRendering?: { condition: string diff --git a/frontends/nextjs/src/lib/db/packages/install/getters/get-installed-packages.ts b/frontends/nextjs/src/lib/db/packages/install/getters/get-installed-packages.ts index 8b78bda6f..581316b5d 100644 --- a/frontends/nextjs/src/lib/db/packages/install/getters/get-installed-packages.ts +++ b/frontends/nextjs/src/lib/db/packages/install/getters/get-installed-packages.ts @@ -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 { 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, diff --git a/frontends/nextjs/src/lib/db/packages/install/getters/set-installed-packages.ts b/frontends/nextjs/src/lib/db/packages/install/getters/set-installed-packages.ts index 9d4156bf8..5ff097d8f 100644 --- a/frontends/nextjs/src/lib/db/packages/install/getters/set-installed-packages.ts +++ b/frontends/nextjs/src/lib/db/packages/install/getters/set-installed-packages.ts @@ -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 { 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 diff --git a/frontends/nextjs/src/lib/db/pages/crud/add-page.test.ts b/frontends/nextjs/src/lib/db/pages/crud/add-page.test.ts index 9955b9ab6..3f4f9fd0b 100644 --- a/frontends/nextjs/src/lib/db/pages/crud/add-page.test.ts +++ b/frontends/nextjs/src/lib/db/pages/crud/add-page.test.ts @@ -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', diff --git a/frontends/nextjs/src/lib/db/pages/crud/get-pages.ts b/frontends/nextjs/src/lib/db/pages/crud/get-pages.ts index 2124e99df..e08c2d7dc 100644 --- a/frontends/nextjs/src/lib/db/pages/crud/get-pages.ts +++ b/frontends/nextjs/src/lib/db/pages/crud/get-pages.ts @@ -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([ + '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 { 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, })) } diff --git a/frontends/nextjs/src/lib/db/pages/crud/set-pages.test.ts b/frontends/nextjs/src/lib/db/pages/crud/set-pages.test.ts index 2f7878d72..a33f3688e 100644 --- a/frontends/nextjs/src/lib/db/pages/crud/set-pages.test.ts +++ b/frontends/nextjs/src/lib/db/pages/crud/set-pages.test.ts @@ -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) diff --git a/frontends/nextjs/src/lib/db/schemas/crud/get-schemas.ts b/frontends/nextjs/src/lib/db/schemas/crud/get-schemas.ts index 8eb0bc93b..202945082 100644 --- a/frontends/nextjs/src/lib/db/schemas/crud/get-schemas.ts +++ b/frontends/nextjs/src/lib/db/schemas/crud/get-schemas.ts @@ -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 { 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, diff --git a/frontends/nextjs/src/lib/db/sessions/crud/delete/delete-session-by-token.ts b/frontends/nextjs/src/lib/db/sessions/crud/delete/delete-session-by-token.ts index 4a3481cab..a24af2b8f 100644 --- a/frontends/nextjs/src/lib/db/sessions/crud/delete/delete-session-by-token.ts +++ b/frontends/nextjs/src/lib/db/sessions/crud/delete/delete-session-by-token.ts @@ -1,10 +1,16 @@ import { getAdapter } from '../../../core/dbal-client' +type DBALSessionRecord = { + id: string +} + export async function deleteSessionByToken(token: string): Promise { 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 } diff --git a/frontends/nextjs/src/lib/db/sessions/map-session-record.ts b/frontends/nextjs/src/lib/db/sessions/map-session-record.ts index 1c59a30c6..3ca914690 100644 --- a/frontends/nextjs/src/lib/db/sessions/map-session-record.ts +++ b/frontends/nextjs/src/lib/db/sessions/map-session-record.ts @@ -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, diff --git a/frontends/nextjs/src/lib/db/smtp-config/get-smtp-config.ts b/frontends/nextjs/src/lib/db/smtp-config/get-smtp-config.ts index 3b7f42142..445fd4447 100644 --- a/frontends/nextjs/src/lib/db/smtp-config/get-smtp-config.ts +++ b/frontends/nextjs/src/lib/db/smtp-config/get-smtp-config.ts @@ -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 { 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 { diff --git a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/get-instance.ts b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/get-instance.ts index 5eee6499e..cb945a8bb 100644 --- a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/get-instance.ts +++ b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/get-instance.ts @@ -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') diff --git a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/get.ts b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/get.ts index c7b057bb1..fb727b428 100644 --- a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/get.ts +++ b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/get.ts @@ -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 { +export async function get(key: string, context: TenantContext): Promise { const fullKey = this.getKey(key, context) const item = this.store.get(fullKey) if (!item) return null diff --git a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/kv-get.ts b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/kv-get.ts index 1a70c8ac9..5abef9971 100644 --- a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/kv-get.ts +++ b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/kv-get.ts @@ -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( +export async function kvGet( key: string, tenantId = 'default', userId = 'system' diff --git a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/kv-list-add.ts b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/kv-list-add.ts index 6f3bac292..06911c14c 100644 --- a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/kv-list-add.ts +++ b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/kv-list-add.ts @@ -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 { diff --git a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/kv-list-get.ts b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/kv-list-get.ts index 38b3d3c3a..fd18d606f 100644 --- a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/kv-list-get.ts +++ b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/kv-list-get.ts @@ -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 { +): Promise { 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}`) diff --git a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/kv-set.ts b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/kv-set.ts index 789b8d84a..82a64f471 100644 --- a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/kv-set.ts +++ b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/kv-set.ts @@ -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' diff --git a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/list-add.ts b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/list-add.ts index 5520a6429..15c45a17b 100644 --- a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/list-add.ts +++ b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/list-add.ts @@ -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 { +export async function listAdd( + key: string, + items: JsonValue[], + context: TenantContext +): Promise { const fullKey = this.getKey(key, context) const existing = this.store.get(fullKey)?.value || [] this.store.set(fullKey, { value: [...existing, ...items] }) diff --git a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/list-get.ts b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/list-get.ts index adc9050e3..88af58879 100644 --- a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/list-get.ts +++ b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/list-get.ts @@ -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 { +): Promise { const fullKey = this.getKey(key, context) const list = this.store.get(fullKey)?.value || [] if (start !== undefined && end !== undefined) { diff --git a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/set.ts b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/set.ts index 5d73a127e..27e32bed7 100644 --- a/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/set.ts +++ b/frontends/nextjs/src/lib/dbal/core/client/dbal-integration/functions/set.ts @@ -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 { diff --git a/frontends/nextjs/src/lib/github/workflows/analysis/runs/stats/StatsUtils.ts b/frontends/nextjs/src/lib/github/workflows/analysis/runs/stats/StatsUtils.ts index 81c043632..ec34570ac 100644 --- a/frontends/nextjs/src/lib/github/workflows/analysis/runs/stats/StatsUtils.ts +++ b/frontends/nextjs/src/lib/github/workflows/analysis/runs/stats/StatsUtils.ts @@ -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) { + return toTopCounts(...args) } - static summarizeWorkflowRuns(...args: any[]) { - return summarizeWorkflowRuns(...(args as any)) + static summarizeWorkflowRuns(...args: Parameters) { + return summarizeWorkflowRuns(...args) } - static formatWorkflowRunAnalysis(...args: any[]) { - return formatWorkflowRunAnalysis(...(args as any)) + static formatWorkflowRunAnalysis(...args: Parameters) { + return formatWorkflowRunAnalysis(...args) } } diff --git a/frontends/nextjs/src/lib/lua/functions/setup/setup-context-api.ts b/frontends/nextjs/src/lib/lua/functions/setup/setup-context-api.ts index dc43d3ecc..2f3ba5d84 100644 --- a/frontends/nextjs/src/lib/lua/functions/setup/setup-context-api.ts +++ b/frontends/nextjs/src/lib/lua/functions/setup/setup-context-api.ts @@ -7,14 +7,16 @@ import * as fengari from 'fengari-web' const lua = fengari.lua +type LuaState = Parameters[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[] = [] diff --git a/frontends/nextjs/src/lib/packages/loader/modular/modular-package-seed-data.ts b/frontends/nextjs/src/lib/packages/loader/modular/modular-package-seed-data.ts index 38d3ef0a1..c516d8b5f 100644 --- a/frontends/nextjs/src/lib/packages/loader/modular/modular-package-seed-data.ts +++ b/frontends/nextjs/src/lib/packages/loader/modular/modular-package-seed-data.ts @@ -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 } /** diff --git a/frontends/nextjs/src/lib/packages/loader/state/initialize-package-system.ts b/frontends/nextjs/src/lib/packages/loader/state/initialize-package-system.ts index 9bcc6758d..3b0c03cfc 100644 --- a/frontends/nextjs/src/lib/packages/loader/state/initialize-package-system.ts +++ b/frontends/nextjs/src/lib/packages/loader/state/initialize-package-system.ts @@ -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 { // 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 { }) 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) diff --git a/frontends/nextjs/src/lib/packages/package-glue/index.ts b/frontends/nextjs/src/lib/packages/package-glue/index.ts index 64f26ea7e..01c11acb7 100644 --- a/frontends/nextjs/src/lib/packages/package-glue/index.ts +++ b/frontends/nextjs/src/lib/packages/package-glue/index.ts @@ -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' diff --git a/frontends/nextjs/src/lib/packages/package-glue/package-repo-config.ts b/frontends/nextjs/src/lib/packages/package-glue/package-repo-config.ts new file mode 100644 index 000000000..ffbbac93d --- /dev/null +++ b/frontends/nextjs/src/lib/packages/package-glue/package-repo-config.ts @@ -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() + 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 +} diff --git a/frontends/nextjs/src/lib/packages/package-glue/scripts/export-all-packages-for-seed.ts b/frontends/nextjs/src/lib/packages/package-glue/scripts/export-all-packages-for-seed.ts index 20a130528..73bbd6a41 100644 --- a/frontends/nextjs/src/lib/packages/package-glue/scripts/export-all-packages-for-seed.ts +++ b/frontends/nextjs/src/lib/packages/package-glue/scripts/export-all-packages-for-seed.ts @@ -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', }) } diff --git a/frontends/nextjs/src/lib/packages/package-glue/scripts/get-accessible-packages.ts b/frontends/nextjs/src/lib/packages/package-glue/scripts/get-accessible-packages.ts new file mode 100644 index 000000000..a538c5c2e --- /dev/null +++ b/frontends/nextjs/src/lib/packages/package-glue/scripts/get-accessible-packages.ts @@ -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 { + const grouped: Record = { + 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 +} diff --git a/frontends/nextjs/src/lib/packages/package-glue/scripts/resolve-dependencies.ts b/frontends/nextjs/src/lib/packages/package-glue/scripts/resolve-dependencies.ts new file mode 100644 index 000000000..92d9b377b --- /dev/null +++ b/frontends/nextjs/src/lib/packages/package-glue/scripts/resolve-dependencies.ts @@ -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() + const visiting = new Set() + const resolved = new Set() + + // 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[] { + 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 +} diff --git a/frontends/nextjs/src/lib/packages/package-glue/sources/index.ts b/frontends/nextjs/src/lib/packages/package-glue/sources/index.ts new file mode 100644 index 000000000..f31b9cfd0 --- /dev/null +++ b/frontends/nextjs/src/lib/packages/package-glue/sources/index.ts @@ -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' diff --git a/frontends/nextjs/src/lib/packages/package-glue/sources/local-package-source.ts b/frontends/nextjs/src/lib/packages/package-glue/sources/local-package-source.ts new file mode 100644 index 000000000..67cce0550 --- /dev/null +++ b/frontends/nextjs/src/lib/packages/package-glue/sources/local-package-source.ts @@ -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 = new Map() + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_LOCAL_SOURCE, ...config } + } + + getConfig(): PackageSourceConfig { + return this.config + } + + async fetchIndex(): Promise { + 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 { + // Check cache first + if (this.packageCache.has(packageId)) { + return this.packageCache.get(packageId)! + } + + try { + // Load metadata from seed/metadata.json + const seedJson = await loadPackageSeedJson( + 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 { + const index = await this.fetchIndex() + return index.some((entry) => entry.packageId === packageId) + } + + async getVersions(packageId: string): Promise { + // 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 { + 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 { + const scripts = loadLuaScriptsFolder(packageId) + return scripts || [] + } +} + +/** + * Create a local package source with default configuration + */ +export const createLocalSource = ( + configOverrides?: Partial +): LocalPackageSource => new LocalPackageSource(configOverrides) diff --git a/frontends/nextjs/src/lib/packages/package-glue/sources/package-source-manager.ts b/frontends/nextjs/src/lib/packages/package-glue/sources/package-source-manager.ts new file mode 100644 index 000000000..3fa83df1b --- /dev/null +++ b/frontends/nextjs/src/lib/packages/package-glue/sources/package-source-manager.ts @@ -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 = new Map() + private config: PackageSourceManagerConfig + private mergedIndex: Map | null = null + + constructor(config: Partial = {}) { + 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 { + if (this.mergedIndex) { + return Array.from(this.mergedIndex.values()) + } + + const sortedSources = this.getSourcesByPriority() + const packageMap = new Map() + + 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 { + 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 { + 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 { + 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> { + const versions = new Map() + + 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, + 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 +} diff --git a/frontends/nextjs/src/lib/packages/package-glue/sources/package-source-types.test.ts b/frontends/nextjs/src/lib/packages/package-glue/sources/package-source-types.test.ts new file mode 100644 index 000000000..3032e88e8 --- /dev/null +++ b/frontends/nextjs/src/lib/packages/package-glue/sources/package-source-types.test.ts @@ -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) + }) +}) diff --git a/frontends/nextjs/src/lib/packages/package-glue/sources/package-source-types.ts b/frontends/nextjs/src/lib/packages/package-glue/sources/package-source-types.ts new file mode 100644 index 000000000..02b621afb --- /dev/null +++ b/frontends/nextjs/src/lib/packages/package-glue/sources/package-source-types.ts @@ -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 + + /** Load full package data for a specific package */ + loadPackage(packageId: string): Promise + + /** Check if a package exists in this source */ + hasPackage(packageId: string): Promise + + /** Get package versions available */ + getVersions(packageId: string): Promise +} + +/** + * 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 +} diff --git a/frontends/nextjs/src/lib/packages/package-glue/sources/remote-package-source.ts b/frontends/nextjs/src/lib/packages/package-glue/sources/remote-package-source.ts new file mode 100644 index 000000000..2492b84a5 --- /dev/null +++ b/frontends/nextjs/src/lib/packages/package-glue/sources/remote-package-source.ts @@ -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 = new Map() + private cacheExpiry: number = 5 * 60 * 1000 // 5 minutes + private lastFetch: number = 0 + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_REMOTE_SOURCE, ...config } + } + + getConfig(): PackageSourceConfig { + return this.config + } + + async fetchIndex(): Promise { + // 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 { + 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 { + const index = await this.fetchIndex() + return index.some((entry) => entry.packageId === packageId) + } + + async getVersions(packageId: string): Promise { + 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 { + 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 { + 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 +): RemotePackageSource => new RemotePackageSource(configOverrides) diff --git a/frontends/nextjs/src/lib/rendering/declarative-component-renderer/types.ts b/frontends/nextjs/src/lib/rendering/declarative-component-renderer/types.ts index 2cbbf3a36..dfae10c1e 100644 --- a/frontends/nextjs/src/lib/rendering/declarative-component-renderer/types.ts +++ b/frontends/nextjs/src/lib/rendering/declarative-component-renderer/types.ts @@ -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[] diff --git a/frontends/nextjs/src/lib/schema/__tests__/schema-utils.serialization.test.ts b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.serialization.test.ts index 432281f81..e13436df8 100644 --- a/frontends/nextjs/src/lib/schema/__tests__/schema-utils.serialization.test.ts +++ b/frontends/nextjs/src/lib/schema/__tests__/schema-utils.serialization.test.ts @@ -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> beforeEach(() => { records = [ @@ -201,7 +202,7 @@ describe('schema-utils serialization', () => { }) describe('filterRecords', () => { - let records: any[] + let records: Array> beforeEach(() => { records = [ diff --git a/frontends/nextjs/src/lib/schema/functions/field/get-default-value.ts b/frontends/nextjs/src/lib/schema/functions/field/get-default-value.ts index 57e00488b..f2c849881 100644 --- a/frontends/nextjs/src/lib/schema/functions/field/get-default-value.ts +++ b/frontends/nextjs/src/lib/schema/functions/field/get-default-value.ts @@ -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) { diff --git a/frontends/nextjs/src/lib/schema/functions/field/validate-field.ts b/frontends/nextjs/src/lib/schema/functions/field/validate-field.ts index 88b80c6dd..f0093ed38 100644 --- a/frontends/nextjs/src/lib/schema/functions/field/validate-field.ts +++ b/frontends/nextjs/src/lib/schema/functions/field/validate-field.ts @@ -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` } diff --git a/frontends/nextjs/src/lib/schema/functions/record/crud/create-empty-record.ts b/frontends/nextjs/src/lib/schema/functions/record/crud/create-empty-record.ts index a1d64d1c8..d71beec1c 100644 --- a/frontends/nextjs/src/lib/schema/functions/record/crud/create-empty-record.ts +++ b/frontends/nextjs/src/lib/schema/functions/record/crud/create-empty-record.ts @@ -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 => { + const record: Record = {} for (const field of model.fields) { if (field.name === 'id') { diff --git a/frontends/nextjs/src/lib/schema/functions/record/filter-records.ts b/frontends/nextjs/src/lib/schema/functions/record/filter-records.ts index 4ee6ad09a..19b716ec9 100644 --- a/frontends/nextjs/src/lib/schema/functions/record/filter-records.ts +++ b/frontends/nextjs/src/lib/schema/functions/record/filter-records.ts @@ -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[], searchTerm: string, searchFields: string[], - filters: Record -): any[] => { + filters: Record +): Record[] => { let filtered = records if (searchTerm) { diff --git a/frontends/nextjs/src/lib/schema/functions/record/sort-records.ts b/frontends/nextjs/src/lib/schema/functions/record/sort-records.ts index bd0f2052c..0104eb611 100644 --- a/frontends/nextjs/src/lib/schema/functions/record/sort-records.ts +++ b/frontends/nextjs/src/lib/schema/functions/record/sort-records.ts @@ -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[], + field: string, + direction: 'asc' | 'desc' +): Record[] => { return [...records].sort((a, b) => { const aVal = a[field] const bVal = b[field] diff --git a/frontends/nextjs/src/lib/schema/functions/record/validate-record.ts b/frontends/nextjs/src/lib/schema/functions/record/validate-record.ts index 8ef43b2e1..97b74176c 100644 --- a/frontends/nextjs/src/lib/schema/functions/record/validate-record.ts +++ b/frontends/nextjs/src/lib/schema/functions/record/validate-record.ts @@ -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 => { +export const validateRecord = ( + model: ModelSchema, + record: Record +): Record => { const errors: Record = {} for (const field of model.fields) { diff --git a/packages/admin_dialog/seed/metadata.json b/packages/admin_dialog/seed/metadata.json index 81800d917..38ca86a6f 100644 --- a/packages/admin_dialog/seed/metadata.json +++ b/packages/admin_dialog/seed/metadata.json @@ -6,7 +6,7 @@ "icon": "static_content/icon.svg", "author": "MetaBuilder", "category": "ui", - "dependencies": [], + "dependencies": ["ui_dialogs", "ui_permissions"], "exports": { "components": [] }, diff --git a/packages/arcade_lobby/seed/metadata.json b/packages/arcade_lobby/seed/metadata.json index 0fb6caa53..a0c343af7 100644 --- a/packages/arcade_lobby/seed/metadata.json +++ b/packages/arcade_lobby/seed/metadata.json @@ -6,7 +6,7 @@ "icon": "static_content/icon.svg", "author": "MetaBuilder", "category": "gaming", - "dependencies": [], + "dependencies": ["ui_permissions", "dashboard"], "exports": { "components": [] }, diff --git a/packages/dashboard/seed/metadata.json b/packages/dashboard/seed/metadata.json index 41d514e76..7b0397236 100644 --- a/packages/dashboard/seed/metadata.json +++ b/packages/dashboard/seed/metadata.json @@ -6,7 +6,7 @@ "icon": "static_content/icon.svg", "author": "MetaBuilder", "category": "ui", - "dependencies": [], + "dependencies": ["data_table", "ui_permissions"], "exports": { "components": [ "StatCard", diff --git a/packages/forum_forge/seed/metadata.json b/packages/forum_forge/seed/metadata.json index 8eeaa1618..dcf92059e 100644 --- a/packages/forum_forge/seed/metadata.json +++ b/packages/forum_forge/seed/metadata.json @@ -6,7 +6,7 @@ "icon": "static_content/icon.svg", "author": "MetaBuilder", "category": "social", - "dependencies": [], + "dependencies": ["ui_permissions", "data_table", "form_builder"], "exports": { "components": [] }, diff --git a/packages/index.json b/packages/index.json index cce34675a..2f3702d68 100644 --- a/packages/index.json +++ b/packages/index.json @@ -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": [] }, diff --git a/packages/social_hub/seed/metadata.json b/packages/social_hub/seed/metadata.json index d19c0070a..e9229c3e0 100644 --- a/packages/social_hub/seed/metadata.json +++ b/packages/social_hub/seed/metadata.json @@ -6,7 +6,7 @@ "icon": "static_content/icon.svg", "author": "MetaBuilder", "category": "social", - "dependencies": [], + "dependencies": ["ui_permissions", "form_builder"], "exports": { "components": [ "social_hub_root", diff --git a/packages/stream_cast/seed/metadata.json b/packages/stream_cast/seed/metadata.json index f8aa45b2b..ad152ca82 100644 --- a/packages/stream_cast/seed/metadata.json +++ b/packages/stream_cast/seed/metadata.json @@ -6,7 +6,7 @@ "icon": "static_content/icon.svg", "author": "MetaBuilder", "category": "media", - "dependencies": [], + "dependencies": ["ui_permissions", "dashboard"], "exports": { "components": [] },