feat: list discoverable modules in creation wizard

This commit is contained in:
reaper
2026-08-14 12:19:50 -05:00
parent 31e6150ecd
commit f6c2bbfad6
7 changed files with 69 additions and 7 deletions
+2 -1
View File
@@ -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
+8 -2
View File
@@ -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<string> { 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)
+24 -1
View File
@@ -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<AuroraModuleManifes
}
export function invalidateModuleRegistry(): void { cache = null }
function catalogDirectories(): string[] {
const configured = (process.env.AURORA_MODULE_PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean)
const besideImage = process.env.APPIMAGE ? [join(dirname(process.env.APPIMAGE), 'modules')] : []
return [...configured, ...besideImage, join(process.cwd(), 'modules'), join(process.cwd(), 'packages'), join(app.getAppPath(), 'packages')]
}
export async function getAvailableModulePackages(): Promise<AuroraAvailableModule[]> {
const found = new Map<string, AuroraAvailableModule>()
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<AuroraModuleInstallResult> {
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')
+2
View File
@@ -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<AuroraModuleManifest[]> => ipcRenderer.invoke('modules:listRegistry'),
listAvailable: (): Promise<AuroraAvailableModule[]> => ipcRenderer.invoke('modules:listAvailable'),
listInstalled: (name: string): Promise<AuroraInstalledModule[]> =>
ipcRenderer.invoke('modules:listInstalled', name),
pickAndInstallPackage: (): Promise<AuroraModuleInstallResult | null> =>
@@ -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<TypeSetupHandle>(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.
</button>
)
})}
{applicationModules.length === 0 && <div className="col-span-full rounded-xl border border-dashed border-amber-300 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-100"><p className="font-semibold">No application modules installed</p><p className="mt-1">Install a compatible local Aurora module package to create a project.</p><button type="button" onClick={() => installPackage.mutate()} className="mt-3 rounded-lg bg-cyan-600 px-3 py-2 font-semibold text-white">Install local module</button></div>}
{applicationModules.length === 0 && <div className="col-span-full rounded-xl border border-dashed border-amber-300 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-100">
<p className="font-semibold">Available application modules</p>
<p className="mt-1">Choose a module to add to Aurora.</p>
<div className="mt-3 grid gap-2">
{availableModules.filter((item) => item.manifest.category === 'application').map((item) => <div key={item.manifest.id} className="flex items-center justify-between gap-3 rounded-lg border border-amber-200 bg-white/70 p-3 dark:border-white/10 dark:bg-neutral-950/40"><div><p className="font-semibold">{item.manifest.name}</p><p className="text-xs opacity-75">{item.manifest.description} · v{item.manifest.version}</p></div><button type="button" disabled={installAvailable.isPending} onClick={() => installAvailable.mutate(item.sourcePath)} className="rounded-lg bg-cyan-600 px-3 py-2 font-semibold text-white disabled:opacity-50">{installAvailable.isPending ? 'Installing…' : 'Install'}</button></div>)}
{!availableLoading && availableModules.filter((item) => item.manifest.category === 'application').length === 0 && <p className="rounded-lg bg-white/60 p-3 text-xs dark:bg-neutral-950/40">No packages were found beside Aurora.</p>}
</div>
<button type="button" onClick={() => installPackage.mutate()} className="mt-3 text-xs font-semibold underline underline-offset-2">Install from another folder</button>
</div>}
</div>
</div>
+16 -1
View File
@@ -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<AuroraModuleManifest[], Erro
return useQuery({ queryKey: ['modules', 'registry'], queryFn: () => window.api.modules.listRegistry() })
}
export function useAvailableModules(): UseQueryResult<AuroraAvailableModule[], Error> {
return useQuery({ queryKey: ['modules', 'available'], queryFn: () => window.api.modules.listAvailable() })
}
export function useInstallModulePackage(): UseMutationResult<void, Error, void> {
const queryClient = useQueryClient()
return useMutation({
@@ -17,6 +21,17 @@ export function useInstallModulePackage(): UseMutationResult<void, Error, void>
})
}
export function useInstallAvailableModule(): UseMutationResult<void, Error, string> {
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<void, Error, string> {
const queryClient = useQueryClient()
return useMutation({
+5
View File
@@ -171,6 +171,11 @@ export interface AuroraModuleInstallResult {
installedPath: string
}
export interface AuroraAvailableModule {
manifest: AuroraModuleManifest
sourcePath: string
}
export interface AuroraAddonRegistryEntry {
title: string
github_url: string