mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
docs: add package-sources documentation and update copilot-instructions for 6-level permissions
This commit is contained in:
21
.github/copilot-instructions.md
vendored
21
.github/copilot-instructions.md
vendored
@@ -4,10 +4,11 @@
|
||||
|
||||
MetaBuilder is a **data-driven, multi-tenant platform** with 95% functionality in JSON/Lua, not TypeScript. The system combines:
|
||||
|
||||
- **5-Level Permission System**: Public → User → Admin → God → Supergod access hierarchies
|
||||
- **6-Level Permission System**: Public → User → Moderator → Admin → God → Supergod access hierarchies
|
||||
- **DBAL (Database Abstraction Layer)**: TypeScript SDK + C++ daemon, language-agnostic via YAML contracts
|
||||
- **Declarative Components**: Render complex UIs from JSON configuration using `RenderComponent`
|
||||
- **Package System**: Self-contained modules in `/packages/{name}/seed/` with metadata, components, scripts
|
||||
- **Multi-Source Package Repos**: Support for local and remote package registries via `PackageSourceManager`
|
||||
- **Multi-Tenancy**: All data queries filter by `tenantId`; each tenant has isolated configurations
|
||||
|
||||
## 0-kickstart Operating Rules
|
||||
@@ -56,7 +57,7 @@ Each package auto-loads on init:
|
||||
```
|
||||
packages/{name}/
|
||||
├── seed/
|
||||
│ ├── metadata.json # Package info, exports, dependencies
|
||||
│ ├── metadata.json # Package info, exports, dependencies, minLevel
|
||||
│ ├── components.json # Component definitions
|
||||
│ ├── scripts/ # Lua scripts organized by function
|
||||
│ └── index.ts # Exports packageSeed object
|
||||
@@ -65,6 +66,22 @@ packages/{name}/
|
||||
```
|
||||
Loaded by `initializePackageSystem()` → `buildPackageRegistry()` → `exportAllPackagesForSeed()`
|
||||
|
||||
### 3a. Multi-Source Package Repositories
|
||||
Packages can come from multiple sources:
|
||||
```typescript
|
||||
import { createPackageSourceManager, LocalPackageSource, RemotePackageSource } from '@/lib/packages/package-glue'
|
||||
|
||||
const manager = createPackageSourceManager({
|
||||
enableRemote: true,
|
||||
remoteUrl: 'https://registry.metabuilder.dev/api/v1',
|
||||
conflictResolution: 'priority' // or 'latest-version', 'local-first', 'remote-first'
|
||||
})
|
||||
|
||||
const packages = await manager.fetchMergedIndex()
|
||||
const pkg = await manager.loadPackage('dashboard')
|
||||
```
|
||||
See: `docs/packages/package-sources.md`, `package-glue/sources/`
|
||||
|
||||
### 4. Database Helpers Pattern
|
||||
Always use `Database` class methods, never raw Prisma:
|
||||
```typescript
|
||||
|
||||
180
docs/packages/package-sources.md
Normal file
180
docs/packages/package-sources.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Package Source System
|
||||
|
||||
The MetaBuilder package system supports loading packages from multiple sources:
|
||||
|
||||
- **Local packages**: Packages bundled with the application in `/packages/`
|
||||
- **Remote packages**: Packages from a remote registry API
|
||||
- **Git packages**: Packages from Git repositories (future support)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ PackageSourceManager │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ LocalSource │ │ RemoteSource │ │ GitSource │ │
|
||||
│ │ (priority 0)│ │ (priority 10│ │ (priority 20)│ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────┬────┴────────┬────────┘ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ Merged Package Index │ │
|
||||
│ │ (conflict resolution) │ │
|
||||
│ └─────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `NEXT_PUBLIC_ENABLE_REMOTE_PACKAGES=true` - Enable remote package sources
|
||||
- `PACKAGE_REGISTRY_AUTH_TOKEN=xxx` - Authentication token for remote registry
|
||||
|
||||
### Programmatic Configuration
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createPackageSourceManager,
|
||||
LocalPackageSource,
|
||||
RemotePackageSource
|
||||
} from '@/lib/packages/package-glue'
|
||||
|
||||
// Simple setup with local only
|
||||
const manager = createPackageSourceManager()
|
||||
|
||||
// With remote source
|
||||
const managerWithRemote = createPackageSourceManager({
|
||||
enableRemote: true,
|
||||
remoteUrl: 'https://registry.metabuilder.dev/api/v1',
|
||||
remoteAuthToken: 'your-token',
|
||||
conflictResolution: 'priority'
|
||||
})
|
||||
|
||||
// Custom configuration
|
||||
const customManager = new PackageSourceManager({
|
||||
conflictResolution: 'latest-version'
|
||||
})
|
||||
customManager.addSource(new LocalPackageSource())
|
||||
customManager.addSource(new RemotePackageSource({
|
||||
id: 'custom-registry',
|
||||
name: 'Custom Registry',
|
||||
type: 'remote',
|
||||
url: 'https://custom.registry.com/api',
|
||||
priority: 5,
|
||||
enabled: true,
|
||||
authToken: 'token'
|
||||
}))
|
||||
```
|
||||
|
||||
## Conflict Resolution
|
||||
|
||||
When the same package exists in multiple sources, the system resolves conflicts using one of these strategies:
|
||||
|
||||
| Strategy | Description |
|
||||
|----------|-------------|
|
||||
| `priority` | Lower priority number wins (default) |
|
||||
| `latest-version` | Highest semver version wins |
|
||||
| `local-first` | Always prefer local packages |
|
||||
| `remote-first` | Always prefer remote packages |
|
||||
|
||||
## Package Source Interface
|
||||
|
||||
All package sources implement:
|
||||
|
||||
```typescript
|
||||
interface PackageSource {
|
||||
getConfig(): PackageSourceConfig
|
||||
fetchIndex(): Promise<PackageIndexEntry[]>
|
||||
loadPackage(packageId: string): Promise<PackageData | null>
|
||||
hasPackage(packageId: string): Promise<boolean>
|
||||
getVersions(packageId: string): Promise<string[]>
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GET /api/packages/index
|
||||
|
||||
Returns the local package index:
|
||||
|
||||
```json
|
||||
{
|
||||
"packages": [
|
||||
{
|
||||
"packageId": "dashboard",
|
||||
"name": "Dashboard",
|
||||
"version": "1.0.0",
|
||||
"minLevel": 2,
|
||||
"dependencies": ["ui_core"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Remote Registry API (Expected Format)
|
||||
|
||||
The remote registry should implement:
|
||||
|
||||
```
|
||||
GET /api/v1/packages - List all packages
|
||||
GET /api/v1/packages/:id - Get package details
|
||||
GET /api/v1/packages/:id/versions - List versions
|
||||
GET /api/v1/packages/search?q=query - Search packages
|
||||
```
|
||||
|
||||
## Example Usage
|
||||
|
||||
```typescript
|
||||
// Get merged package list
|
||||
const packages = await manager.fetchMergedIndex()
|
||||
|
||||
// Load a specific package
|
||||
const dashboard = await manager.loadPackage('dashboard')
|
||||
|
||||
// Check package availability
|
||||
const exists = await manager.hasPackage('some-package')
|
||||
|
||||
// Get all versions across sources
|
||||
const versions = await manager.getAllVersions('dashboard')
|
||||
// Map { 'local' => ['1.0.0'], 'remote' => ['1.0.0', '1.1.0', '2.0.0'] }
|
||||
|
||||
// Load from specific source
|
||||
const remotePkg = await manager.loadPackageFromSource('dashboard', 'remote')
|
||||
```
|
||||
|
||||
## Permission Integration
|
||||
|
||||
Packages specify a `minLevel` that integrates with the 6-level permission system:
|
||||
|
||||
| Level | Name | Example Packages |
|
||||
|-------|------|------------------|
|
||||
| 1 | Public | ui_pages, landing components |
|
||||
| 2 | User | dashboard, data_table |
|
||||
| 3 | Moderator | forum_forge moderation tools |
|
||||
| 4 | Admin | admin_dialog, system settings |
|
||||
| 5 | God | advanced configuration |
|
||||
| 6 | Supergod | ui_level6, tenant management |
|
||||
|
||||
```typescript
|
||||
import { getAccessiblePackages } from '@/lib/packages/package-glue'
|
||||
|
||||
// Filter packages by user level
|
||||
const userPackages = getAccessiblePackages(registry, userLevel)
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
- Local source: Caches index and packages in memory
|
||||
- Remote source: 5-minute cache with stale-while-revalidate
|
||||
- Manager: Caches merged index until `clearAllCaches()` called
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Git source**: Clone packages from Git repos
|
||||
2. **S3 source**: Load from S3 buckets
|
||||
3. **Package publishing**: Push local packages to registry
|
||||
4. **Version pinning**: Lock package versions
|
||||
5. **Dependency resolution**: Auto-resolve compatible versions
|
||||
@@ -5,6 +5,7 @@ import type { LuaScript } from '@/lib/level-types'
|
||||
import { executeLuaScriptWithProfile } from '@/lib/lua/execute-lua-script-with-profile'
|
||||
import type { LuaExecutionResult } from '@/lib/lua-engine'
|
||||
import { securityScanner, type SecurityScanResult } from '@/lib/security-scanner'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
interface UseLuaEditorLogicProps {
|
||||
scripts: LuaScript[]
|
||||
@@ -19,7 +20,7 @@ export const useLuaEditorLogic = ({ scripts, onScriptsChange }: UseLuaEditorLogi
|
||||
scripts.length > 0 ? scripts[0].id : null
|
||||
)
|
||||
const [testOutput, setTestOutput] = useState<LuaExecutionResult | null>(null)
|
||||
const [testInputs, setTestInputs] = useState<Record<string, any>>({})
|
||||
const [testInputs, setTestInputs] = useState<Record<string, JsonValue>>({})
|
||||
const [isExecuting, setIsExecuting] = useState(false)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [showSnippetLibrary, setShowSnippetLibrary] = useState(false)
|
||||
@@ -37,7 +38,7 @@ export const useLuaEditorLogic = ({ scripts, onScriptsChange }: UseLuaEditorLogi
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentScript) return
|
||||
const inputs: Record<string, any> = {}
|
||||
const inputs: Record<string, JsonValue> = {}
|
||||
currentScript.parameters.forEach(param => {
|
||||
inputs[param.name] = param.type === 'number' ? 0 : param.type === 'boolean' ? false : ''
|
||||
})
|
||||
@@ -86,7 +87,7 @@ export const useLuaEditorLogic = ({ scripts, onScriptsChange }: UseLuaEditorLogi
|
||||
handleUpdateScript({
|
||||
parameters: currentScript.parameters.map((p, i) => (i === index ? { ...p, ...updates } : p)),
|
||||
})
|
||||
const handleTestInputChange = (paramName: string, value: any) =>
|
||||
const handleTestInputChange = (paramName: string, value: JsonValue) =>
|
||||
setTestInputs({ ...testInputs, [paramName]: value })
|
||||
|
||||
const executeScript = async () => {
|
||||
@@ -94,7 +95,7 @@ export const useLuaEditorLogic = ({ scripts, onScriptsChange }: UseLuaEditorLogi
|
||||
setIsExecuting(true)
|
||||
setTestOutput(null)
|
||||
try {
|
||||
const contextData: any = {}
|
||||
const contextData: Record<string, JsonValue> = {}
|
||||
currentScript.parameters.forEach(param => {
|
||||
contextData[param.name] = testInputs[param.name]
|
||||
})
|
||||
@@ -103,7 +104,7 @@ export const useLuaEditorLogic = ({ scripts, onScriptsChange }: UseLuaEditorLogi
|
||||
{
|
||||
data: contextData,
|
||||
user: { username: 'test_user', role: 'god' },
|
||||
log: (...args: any[]) => console.log('[Lua]', ...args),
|
||||
log: (...args: JsonValue[]) => console.log('[Lua]', ...args),
|
||||
},
|
||||
currentScript
|
||||
)
|
||||
|
||||
@@ -8,16 +8,19 @@ import { Label } from '@/components/ui'
|
||||
import type { FieldSchema, SchemaConfig } from '@/lib/schema-types'
|
||||
import { findModel, getFieldLabel, getHelpText, getModelLabel } from '@/lib/schema-utils'
|
||||
import { getRecordsKey } from '@/lib/schema-utils'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
interface FieldRendererProps {
|
||||
field: FieldSchema
|
||||
value: any
|
||||
onChange: (value: any) => void
|
||||
value: JsonValue
|
||||
onChange: (value: JsonValue) => void
|
||||
error?: string
|
||||
schema: SchemaConfig
|
||||
currentApp: string
|
||||
}
|
||||
|
||||
type RelatedRecord = Record<string, JsonValue> & { id: string }
|
||||
|
||||
export function FieldRenderer({
|
||||
field,
|
||||
value,
|
||||
@@ -32,7 +35,7 @@ export function FieldRenderer({
|
||||
const relatedRecordsKey = field.relatedModel
|
||||
? getRecordsKey(currentApp, field.relatedModel)
|
||||
: 'dummy'
|
||||
const [relatedModelRecords] = useKV<any[]>(relatedRecordsKey, [])
|
||||
const [relatedModelRecords] = useKV<RelatedRecord[]>(relatedRecordsKey, [])
|
||||
|
||||
const relatedModel =
|
||||
field.type === 'relation' && field.relatedModel
|
||||
@@ -158,7 +161,7 @@ export function FieldRenderer({
|
||||
<SelectValue placeholder={`Select ${label}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{relatedModelRecords.map((record: any) => (
|
||||
{relatedModelRecords.map(record => (
|
||||
<SelectItem key={record.id} value={record.id}>
|
||||
{record[displayField] || record.id}
|
||||
</SelectItem>
|
||||
|
||||
@@ -5,13 +5,14 @@ import { CssClassBuilder } from '@/components/CssClassBuilder'
|
||||
import { Button, Separator } from '@/components/ui'
|
||||
import { componentCatalog } from '@/lib/component-catalog'
|
||||
import { Database, DropdownConfig } from '@/lib/database'
|
||||
import type { ComponentInstance } from '@/lib/types/builder-types'
|
||||
import type { ComponentInstance, ComponentProps } from '@/lib/types/builder-types'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
import { PropertyPanels } from './components/PropertyPanels'
|
||||
|
||||
interface PropertyInspectorProps {
|
||||
component: ComponentInstance | null
|
||||
onUpdate: (id: string, props: any) => void
|
||||
onUpdate: (id: string, props: ComponentProps) => void
|
||||
onDelete: (id: string) => void
|
||||
onCodeEdit: () => void
|
||||
}
|
||||
@@ -45,7 +46,7 @@ export function PropertyInspector({
|
||||
|
||||
const componentDef = componentCatalog.find(c => c.type === component.type)
|
||||
|
||||
const handlePropChange = (propName: string, value: any) => {
|
||||
const handlePropChange = (propName: string, value: JsonValue) => {
|
||||
onUpdate(component.id, {
|
||||
...component.props,
|
||||
[propName]: value,
|
||||
@@ -90,7 +91,11 @@ export function PropertyInspector({
|
||||
<CssClassBuilder
|
||||
open={cssBuilderOpen}
|
||||
onClose={() => setCssBuilderOpen(false)}
|
||||
initialValue={component.props[cssBuilderPropName] || ''}
|
||||
initialValue={
|
||||
typeof component.props[cssBuilderPropName] === 'string'
|
||||
? component.props[cssBuilderPropName]
|
||||
: ''
|
||||
}
|
||||
onSave={handleCssClassSave}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -12,11 +12,12 @@ import {
|
||||
} from '@/components/ui'
|
||||
import type { DropdownConfig } from '@/lib/database'
|
||||
import type { PropDefinition } from '@/lib/types/builder-types'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
interface FieldTypesProps {
|
||||
propDef: PropDefinition
|
||||
value: any
|
||||
onChange: (value: any) => void
|
||||
value: JsonValue
|
||||
onChange: (value: JsonValue) => void
|
||||
dynamicDropdown?: DropdownConfig | null
|
||||
onOpenCssBuilder?: () => void
|
||||
}
|
||||
@@ -69,9 +70,15 @@ export function FieldTypes({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
case 'select':
|
||||
case 'select': {
|
||||
const selectValue =
|
||||
typeof value === 'string'
|
||||
? value
|
||||
: typeof propDef.defaultValue === 'string'
|
||||
? propDef.defaultValue
|
||||
: ''
|
||||
return (
|
||||
<Select value={value || propDef.defaultValue} onValueChange={val => onChange(val)}>
|
||||
<Select value={selectValue} onValueChange={val => onChange(val)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -84,6 +91,7 @@ export function FieldTypes({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
case 'dynamic-select':
|
||||
return (
|
||||
<Select value={value || ''} onValueChange={val => onChange(val)}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Code, PaintBrush } from '@phosphor-icons/react'
|
||||
import { Button, ScrollArea, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'
|
||||
import type { DropdownConfig } from '@/lib/database'
|
||||
import type { ComponentDefinition, ComponentInstance } from '@/lib/types/builder-types'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
import { FieldTypes } from './FieldTypes'
|
||||
|
||||
@@ -10,7 +11,7 @@ interface PropertyPanelsProps {
|
||||
component: ComponentInstance
|
||||
componentDef?: ComponentDefinition
|
||||
dynamicDropdowns: DropdownConfig[]
|
||||
onPropChange: (propName: string, value: any) => void
|
||||
onPropChange: (propName: string, value: JsonValue) => void
|
||||
onCodeEdit: () => void
|
||||
onOpenCssBuilder: (propName: string) => void
|
||||
}
|
||||
|
||||
@@ -9,15 +9,15 @@ import { TextField } from './functions/text-field'
|
||||
* This is a convenience wrapper. Prefer importing individual functions.
|
||||
*/
|
||||
export class TabsUtils {
|
||||
static SchemaTabs(...args: any[]) {
|
||||
return SchemaTabs(...(args as any))
|
||||
static SchemaTabs(...args: Parameters<typeof SchemaTabs>) {
|
||||
return SchemaTabs(...args)
|
||||
}
|
||||
|
||||
static FieldCard(...args: any[]) {
|
||||
return FieldCard(...(args as any))
|
||||
static FieldCard(...args: Parameters<typeof FieldCard>) {
|
||||
return FieldCard(...args)
|
||||
}
|
||||
|
||||
static TextField(...args: any[]) {
|
||||
return TextField(...(args as any))
|
||||
static TextField(...args: Parameters<typeof TextField>) {
|
||||
return TextField(...args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
|
||||
import type { LuaScript, Workflow, WorkflowNode } from '@/lib/level-types'
|
||||
import type { WorkflowExecutionResult } from '@/lib/workflow/engine/workflow-engine'
|
||||
import { createWorkflowEngine } from '@/lib/workflow/engine/workflow-engine'
|
||||
import type { JsonValue } from '@/types/utility-types'
|
||||
|
||||
import type { WorkflowActionHandlers, WorkflowStateSetters } from './types'
|
||||
|
||||
@@ -89,9 +90,9 @@ export const createActionHandlers = ({
|
||||
setTestOutput(null)
|
||||
|
||||
try {
|
||||
let parsedData: any
|
||||
let parsedData: JsonValue
|
||||
try {
|
||||
parsedData = JSON.parse(testData)
|
||||
parsedData = JSON.parse(testData) as JsonValue
|
||||
} catch {
|
||||
parsedData = testData
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
DEFAULT_PACKAGE_REPO_CONFIG,
|
||||
DEVELOPMENT_PACKAGE_REPO_CONFIG,
|
||||
PRODUCTION_PACKAGE_REPO_CONFIG,
|
||||
getPackageRepoConfig,
|
||||
validatePackageRepoConfig,
|
||||
} from '../package-repo-config'
|
||||
import type { PackageRepoConfig } from '../package-repo-config'
|
||||
|
||||
describe('package-repo-config', () => {
|
||||
describe('DEFAULT_PACKAGE_REPO_CONFIG', () => {
|
||||
it('should have priority conflict resolution', () => {
|
||||
expect(DEFAULT_PACKAGE_REPO_CONFIG.conflictResolution).toBe('priority')
|
||||
})
|
||||
|
||||
it('should have one local source', () => {
|
||||
expect(DEFAULT_PACKAGE_REPO_CONFIG.sources).toHaveLength(1)
|
||||
expect(DEFAULT_PACKAGE_REPO_CONFIG.sources[0].type).toBe('local')
|
||||
})
|
||||
|
||||
it('should have local source enabled', () => {
|
||||
expect(DEFAULT_PACKAGE_REPO_CONFIG.sources[0].enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DEVELOPMENT_PACKAGE_REPO_CONFIG', () => {
|
||||
it('should use local-first conflict resolution', () => {
|
||||
expect(DEVELOPMENT_PACKAGE_REPO_CONFIG.conflictResolution).toBe('local-first')
|
||||
})
|
||||
|
||||
it('should have two sources', () => {
|
||||
expect(DEVELOPMENT_PACKAGE_REPO_CONFIG.sources).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should have local source enabled', () => {
|
||||
const local = DEVELOPMENT_PACKAGE_REPO_CONFIG.sources.find((s) => s.id === 'local')
|
||||
expect(local?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should have staging source disabled by default', () => {
|
||||
const staging = DEVELOPMENT_PACKAGE_REPO_CONFIG.sources.find((s) => s.id === 'staging')
|
||||
expect(staging?.enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PRODUCTION_PACKAGE_REPO_CONFIG', () => {
|
||||
it('should use latest-version conflict resolution', () => {
|
||||
expect(PRODUCTION_PACKAGE_REPO_CONFIG.conflictResolution).toBe('latest-version')
|
||||
})
|
||||
|
||||
it('should have local with higher priority than remote', () => {
|
||||
const local = PRODUCTION_PACKAGE_REPO_CONFIG.sources.find((s) => s.id === 'local')
|
||||
const production = PRODUCTION_PACKAGE_REPO_CONFIG.sources.find((s) => s.id === 'production')
|
||||
expect(local?.priority).toBeGreaterThan(production?.priority ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePackageRepoConfig', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'valid config',
|
||||
config: DEFAULT_PACKAGE_REPO_CONFIG,
|
||||
expectedErrors: 0,
|
||||
},
|
||||
{
|
||||
name: 'empty sources',
|
||||
config: { conflictResolution: 'priority', sources: [] } as PackageRepoConfig,
|
||||
expectedErrors: 2, // No sources + no enabled sources
|
||||
},
|
||||
{
|
||||
name: 'duplicate IDs',
|
||||
config: {
|
||||
conflictResolution: 'priority',
|
||||
sources: [
|
||||
{ id: 'dup', name: 'Dup 1', type: 'local', url: '/a', priority: 0, enabled: true },
|
||||
{ id: 'dup', name: 'Dup 2', type: 'local', url: '/b', priority: 1, enabled: true },
|
||||
],
|
||||
} as PackageRepoConfig,
|
||||
expectedErrors: 1,
|
||||
},
|
||||
{
|
||||
name: 'remote with non-http URL',
|
||||
config: {
|
||||
conflictResolution: 'priority',
|
||||
sources: [
|
||||
{ id: 'bad', name: 'Bad', type: 'remote', url: '/local/path', priority: 0, enabled: true },
|
||||
],
|
||||
} as PackageRepoConfig,
|
||||
expectedErrors: 1,
|
||||
},
|
||||
{
|
||||
name: 'all sources disabled',
|
||||
config: {
|
||||
conflictResolution: 'priority',
|
||||
sources: [
|
||||
{ id: 'off1', name: 'Off 1', type: 'local', url: '/a', priority: 0, enabled: false },
|
||||
{ id: 'off2', name: 'Off 2', type: 'remote', url: 'https://x', priority: 1, enabled: false },
|
||||
],
|
||||
} as PackageRepoConfig,
|
||||
expectedErrors: 1,
|
||||
},
|
||||
])('should validate $name', ({ config, expectedErrors }) => {
|
||||
const errors = validatePackageRepoConfig(config)
|
||||
expect(errors).toHaveLength(expectedErrors)
|
||||
})
|
||||
|
||||
it('should catch missing source ID', () => {
|
||||
const config: PackageRepoConfig = {
|
||||
conflictResolution: 'priority',
|
||||
sources: [
|
||||
{ id: '', name: 'No ID', type: 'local', url: '/test', priority: 0, enabled: true },
|
||||
],
|
||||
}
|
||||
const errors = validatePackageRepoConfig(config)
|
||||
expect(errors.some((e) => e.includes('ID'))).toBe(true)
|
||||
})
|
||||
|
||||
it('should catch missing URL', () => {
|
||||
const config: PackageRepoConfig = {
|
||||
conflictResolution: 'priority',
|
||||
sources: [
|
||||
{ id: 'no-url', name: 'No URL', type: 'local', url: '', priority: 0, enabled: true },
|
||||
],
|
||||
}
|
||||
const errors = validatePackageRepoConfig(config)
|
||||
expect(errors.some((e) => e.includes('URL'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPackageRepoConfig', () => {
|
||||
// Skip environment-dependent tests in this context
|
||||
// These would need proper mocking setup for process.env
|
||||
|
||||
it('should return a valid config', () => {
|
||||
const config = getPackageRepoConfig()
|
||||
const errors = validatePackageRepoConfig(config)
|
||||
expect(errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should have at least one source', () => {
|
||||
const config = getPackageRepoConfig()
|
||||
expect(config.sources.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have at least one enabled source', () => {
|
||||
const config = getPackageRepoConfig()
|
||||
const enabledSources = config.sources.filter((s) => s.enabled)
|
||||
expect(enabledSources.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user