feat: list discoverable modules in creation wizard
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -171,6 +171,11 @@ export interface AuroraModuleInstallResult {
|
||||
installedPath: string
|
||||
}
|
||||
|
||||
export interface AuroraAvailableModule {
|
||||
manifest: AuroraModuleManifest
|
||||
sourcePath: string
|
||||
}
|
||||
|
||||
export interface AuroraAddonRegistryEntry {
|
||||
title: string
|
||||
github_url: string
|
||||
|
||||
Reference in New Issue
Block a user