feat: list discoverable modules in creation wizard
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user