diff --git a/src/main/ipc/modules.ts b/src/main/ipc/modules.ts index 983f133..727c69d 100644 --- a/src/main/ipc/modules.ts +++ b/src/main/ipc/modules.ts @@ -1,10 +1,11 @@ import { dialog, ipcMain } from 'electron' import { getProjectRoot, listInstalledModules, listModules, scaffoldApplicationModule, setModule } from '../auroraEngine' import { runCommandStreamed } from '../commandRunner' -import { installModulePackage, uninstallModulePackage } from '../moduleRegistry' +import { getAvailableModulePackages, installModulePackage, uninstallModulePackage } from '../moduleRegistry' export function registerModulesIpc(): void { ipcMain.handle('modules:listRegistry', () => listModules()) + ipcMain.handle('modules:listAvailable', () => getAvailableModulePackages()) ipcMain.handle('modules:pickAndInstallPackage', async () => { const picked = await dialog.showOpenDialog({ properties: ['openDirectory'] }) if (picked.canceled || !picked.filePaths[0]) return null diff --git a/src/main/moduleRegistry.test.ts b/src/main/moduleRegistry.test.ts index c2f43d9..e6e03c7 100644 --- a/src/main/moduleRegistry.test.ts +++ b/src/main/moduleRegistry.test.ts @@ -3,8 +3,8 @@ import { join } from 'path' import { tmpdir } from 'os' import { afterEach, describe, expect, it, vi } from 'vitest' -vi.mock('electron', () => ({ app: { getPath: () => '/unused' } })) -import { getModuleRegistry, installModulePackage, uninstallModulePackage, validateModuleManifest } from './moduleRegistry' +vi.mock('electron', () => ({ app: { getPath: () => '/unused', getAppPath: () => '/unused/app' } })) +import { getAvailableModulePackages, getModuleRegistry, installModulePackage, uninstallModulePackage, validateModuleManifest } from './moduleRegistry' const roots: string[] = [] async function temp(prefix: string): Promise { const root = await mkdtemp(join(tmpdir(), prefix)); roots.push(root); return root } @@ -15,6 +15,12 @@ afterEach(async () => { const { rm } = await import('fs/promises'); await Promis describe('external module registry', () => { it('starts empty', async () => expect(await getModuleRegistry(await temp('aurora-user-'))).toEqual([])) + it('lists discoverable packages for the module library', async () => { + const catalog = await temp('aurora-catalog-'); const source = join(catalog, 'sample-app'); await mkdir(source); await writeFile(join(source, 'manifest.json'), JSON.stringify(manifest)) + process.env.AURORA_MODULE_PATH = catalog + expect((await getAvailableModulePackages()).map((item) => item.manifest.id)).toContain('sample-app') + delete process.env.AURORA_MODULE_PATH + }) it('installs a valid local package and refreshes after install', async () => { const userData = await temp('aurora-user-'); const source = await packageDir() await installModulePackage(source, userData) diff --git a/src/main/moduleRegistry.ts b/src/main/moduleRegistry.ts index 840bd31..2acb92d 100644 --- a/src/main/moduleRegistry.ts +++ b/src/main/moduleRegistry.ts @@ -1,7 +1,7 @@ import { app } from 'electron' import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat } from 'fs/promises' import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path' -import type { AuroraModuleInstallResult, AuroraModuleManifest, AuroraModuleSetting } from '../shared/types' +import type { AuroraAvailableModule, AuroraModuleInstallResult, AuroraModuleManifest, AuroraModuleSetting } from '../shared/types' export const CORE_VERSION = '2.0.0-alpha.24' export const MODULE_API_VERSION = '1.0.0' @@ -78,6 +78,29 @@ export async function getModuleManifest(id: string): Promise { + const found = new Map() + for (const directory of catalogDirectories()) { + try { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const sourcePath = join(directory, entry.name) + try { + const manifest = validateModuleManifest(JSON.parse(await readFile(join(sourcePath, 'manifest.json'), 'utf8'))) + if (!found.has(manifest.id)) found.set(manifest.id, { manifest, sourcePath }) + } catch { /* unrelated or incompatible package */ } + } + } catch { /* optional catalog directory */ } + } + return [...found.values()].sort((a, b) => a.manifest.name.localeCompare(b.manifest.name)) +} + export async function installModulePackage(source: string, userData?: string): Promise { if (!isAbsolute(source)) throw new Error('Module package path must be absolute') if (!(await stat(source)).isDirectory()) throw new Error('Select an unpacked local module directory') diff --git a/src/preload/index.ts b/src/preload/index.ts index 6442ad8..cf2668f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import type { AuroraInstalledModule, + AuroraAvailableModule, AuroraModuleInstallResult, AuroraModuleManifest, AuroraProjectDetail, @@ -82,6 +83,7 @@ const api = { }, modules: { listRegistry: (): Promise => ipcRenderer.invoke('modules:listRegistry'), + listAvailable: (): Promise => ipcRenderer.invoke('modules:listAvailable'), listInstalled: (name: string): Promise => ipcRenderer.invoke('modules:listInstalled', name), pickAndInstallPackage: (): Promise => diff --git a/src/renderer/src/components/create/CreateProjectModal.tsx b/src/renderer/src/components/create/CreateProjectModal.tsx index 3aedb5d..ded7ffb 100644 --- a/src/renderer/src/components/create/CreateProjectModal.tsx +++ b/src/renderer/src/components/create/CreateProjectModal.tsx @@ -12,7 +12,7 @@ import { } from 'lucide-react' import { useCreateProject } from '../../hooks/useCreateProject' import { useAppStore } from '../../stores/appStore' -import { useInstallModulePackage, useModuleRegistry } from '../../hooks/useModules' +import { useAvailableModules, useInstallAvailableModule, useInstallModulePackage, useModuleRegistry } from '../../hooks/useModules' import { GenericSetup } from './types/GenericSetup' import { ExternalModuleSetup } from './types/ExternalModuleSetup' import type { TypeSetupHandle } from './types/shared' @@ -53,6 +53,8 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React. const setupRef = useRef(null) const { data: moduleRegistry = [] } = useModuleRegistry() const installPackage = useInstallModulePackage() + const installAvailable = useInstallAvailableModule() + const { data: availableModules = [], isLoading: availableLoading } = useAvailableModules() const applicationModules = moduleRegistry.filter((module) => module.category === 'application') const selectedModule = applicationModules.find((module) => module.id === projectType) const getTypeLabel = (type: string): string => applicationModules.find((module) => module.id === type)?.name ?? 'project' @@ -262,7 +264,15 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React. ) })} - {applicationModules.length === 0 &&

No application modules installed

Install a compatible local Aurora module package to create a project.

} + {applicationModules.length === 0 &&
+

Available application modules

+

Choose a module to add to Aurora.

+
+ {availableModules.filter((item) => item.manifest.category === 'application').map((item) =>

{item.manifest.name}

{item.manifest.description} · v{item.manifest.version}

)} + {!availableLoading && availableModules.filter((item) => item.manifest.category === 'application').length === 0 &&

No packages were found beside Aurora.

} +
+ +
} diff --git a/src/renderer/src/hooks/useModules.ts b/src/renderer/src/hooks/useModules.ts index 6a7b094..a5be6af 100644 --- a/src/renderer/src/hooks/useModules.ts +++ b/src/renderer/src/hooks/useModules.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient, type UseMutationResult, type UseQueryResult } from '@tanstack/react-query' -import type { AuroraInstalledModule, AuroraModuleManifest } from '@shared/types' +import type { AuroraAvailableModule, AuroraInstalledModule, AuroraModuleManifest } from '@shared/types' import { useTerminalStore } from '../stores/terminalStore' import { useStatusStore } from '../stores/statusStore' @@ -9,6 +9,10 @@ export function useModuleRegistry(): UseQueryResult window.api.modules.listRegistry() }) } +export function useAvailableModules(): UseQueryResult { + return useQuery({ queryKey: ['modules', 'available'], queryFn: () => window.api.modules.listAvailable() }) +} + export function useInstallModulePackage(): UseMutationResult { const queryClient = useQueryClient() return useMutation({ @@ -17,6 +21,17 @@ export function useInstallModulePackage(): UseMutationResult }) } +export function useInstallAvailableModule(): UseMutationResult { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (sourcePath) => { await window.api.modules.installPackage(sourcePath) }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['modules', 'registry'] }) + await queryClient.invalidateQueries({ queryKey: ['modules', 'available'] }) + } + }) +} + export function useUninstallModulePackage(): UseMutationResult { const queryClient = useQueryClient() return useMutation({ diff --git a/src/shared/types.ts b/src/shared/types.ts index de40509..18cf252 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -171,6 +171,11 @@ export interface AuroraModuleInstallResult { installedPath: string } +export interface AuroraAvailableModule { + manifest: AuroraModuleManifest + sourcePath: string +} + export interface AuroraAddonRegistryEntry { title: string github_url: string