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 { dialog, ipcMain } from 'electron'
import { getProjectRoot, listInstalledModules, listModules, scaffoldApplicationModule, setModule } from '../auroraEngine' import { getProjectRoot, listInstalledModules, listModules, scaffoldApplicationModule, setModule } from '../auroraEngine'
import { runCommandStreamed } from '../commandRunner' import { runCommandStreamed } from '../commandRunner'
import { installModulePackage, uninstallModulePackage } from '../moduleRegistry' import { getAvailableModulePackages, installModulePackage, uninstallModulePackage } from '../moduleRegistry'
export function registerModulesIpc(): void { export function registerModulesIpc(): void {
ipcMain.handle('modules:listRegistry', () => listModules()) ipcMain.handle('modules:listRegistry', () => listModules())
ipcMain.handle('modules:listAvailable', () => getAvailableModulePackages())
ipcMain.handle('modules:pickAndInstallPackage', async () => { ipcMain.handle('modules:pickAndInstallPackage', async () => {
const picked = await dialog.showOpenDialog({ properties: ['openDirectory'] }) const picked = await dialog.showOpenDialog({ properties: ['openDirectory'] })
if (picked.canceled || !picked.filePaths[0]) return null 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 { tmpdir } from 'os'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({ app: { getPath: () => '/unused' } })) vi.mock('electron', () => ({ app: { getPath: () => '/unused', getAppPath: () => '/unused/app' } }))
import { getModuleRegistry, installModulePackage, uninstallModulePackage, validateModuleManifest } from './moduleRegistry' import { getAvailableModulePackages, getModuleRegistry, installModulePackage, uninstallModulePackage, validateModuleManifest } from './moduleRegistry'
const roots: string[] = [] const roots: string[] = []
async function temp(prefix: string): Promise<string> { const root = await mkdtemp(join(tmpdir(), prefix)); roots.push(root); return root } 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', () => { describe('external module registry', () => {
it('starts empty', async () => expect(await getModuleRegistry(await temp('aurora-user-'))).toEqual([])) 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 () => { it('installs a valid local package and refreshes after install', async () => {
const userData = await temp('aurora-user-'); const source = await packageDir() const userData = await temp('aurora-user-'); const source = await packageDir()
await installModulePackage(source, userData) await installModulePackage(source, userData)
+24 -1
View File
@@ -1,7 +1,7 @@
import { app } from 'electron' import { app } from 'electron'
import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat } from 'fs/promises' import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat } from 'fs/promises'
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path' 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 CORE_VERSION = '2.0.0-alpha.24'
export const MODULE_API_VERSION = '1.0.0' 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 } 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> { export async function installModulePackage(source: string, userData?: string): Promise<AuroraModuleInstallResult> {
if (!isAbsolute(source)) throw new Error('Module package path must be absolute') 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') 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 { electronAPI } from '@electron-toolkit/preload'
import type { import type {
AuroraInstalledModule, AuroraInstalledModule,
AuroraAvailableModule,
AuroraModuleInstallResult, AuroraModuleInstallResult,
AuroraModuleManifest, AuroraModuleManifest,
AuroraProjectDetail, AuroraProjectDetail,
@@ -82,6 +83,7 @@ const api = {
}, },
modules: { modules: {
listRegistry: (): Promise<AuroraModuleManifest[]> => ipcRenderer.invoke('modules:listRegistry'), listRegistry: (): Promise<AuroraModuleManifest[]> => ipcRenderer.invoke('modules:listRegistry'),
listAvailable: (): Promise<AuroraAvailableModule[]> => ipcRenderer.invoke('modules:listAvailable'),
listInstalled: (name: string): Promise<AuroraInstalledModule[]> => listInstalled: (name: string): Promise<AuroraInstalledModule[]> =>
ipcRenderer.invoke('modules:listInstalled', name), ipcRenderer.invoke('modules:listInstalled', name),
pickAndInstallPackage: (): Promise<AuroraModuleInstallResult | null> => pickAndInstallPackage: (): Promise<AuroraModuleInstallResult | null> =>
@@ -12,7 +12,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useCreateProject } from '../../hooks/useCreateProject' import { useCreateProject } from '../../hooks/useCreateProject'
import { useAppStore } from '../../stores/appStore' 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 { GenericSetup } from './types/GenericSetup'
import { ExternalModuleSetup } from './types/ExternalModuleSetup' import { ExternalModuleSetup } from './types/ExternalModuleSetup'
import type { TypeSetupHandle } from './types/shared' import type { TypeSetupHandle } from './types/shared'
@@ -53,6 +53,8 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
const setupRef = useRef<TypeSetupHandle>(null) const setupRef = useRef<TypeSetupHandle>(null)
const { data: moduleRegistry = [] } = useModuleRegistry() const { data: moduleRegistry = [] } = useModuleRegistry()
const installPackage = useInstallModulePackage() const installPackage = useInstallModulePackage()
const installAvailable = useInstallAvailableModule()
const { data: availableModules = [], isLoading: availableLoading } = useAvailableModules()
const applicationModules = moduleRegistry.filter((module) => module.category === 'application') const applicationModules = moduleRegistry.filter((module) => module.category === 'application')
const selectedModule = applicationModules.find((module) => module.id === projectType) const selectedModule = applicationModules.find((module) => module.id === projectType)
const getTypeLabel = (type: string): string => applicationModules.find((module) => module.id === type)?.name ?? 'project' 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> </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>
</div> </div>
+16 -1
View File
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient, type UseMutationResult, type UseQueryResult } from '@tanstack/react-query' 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 { useTerminalStore } from '../stores/terminalStore'
import { useStatusStore } from '../stores/statusStore' 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() }) 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> { export function useInstallModulePackage(): UseMutationResult<void, Error, void> {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ 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> { export function useUninstallModulePackage(): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
+5
View File
@@ -171,6 +171,11 @@ export interface AuroraModuleInstallResult {
installedPath: string installedPath: string
} }
export interface AuroraAvailableModule {
manifest: AuroraModuleManifest
sourcePath: string
}
export interface AuroraAddonRegistryEntry { export interface AuroraAddonRegistryEntry {
title: string title: string
github_url: string github_url: string