Files
metabuilder/scripts/generate-package.ts
2025-12-30 23:28:47 +00:00

584 lines
18 KiB
JavaScript

#!/usr/bin/env node
/**
* Package Template Generator CLI
*
* Usage:
* npx ts-node scripts/generate-package.ts <package_id> [options]
*
* Examples:
* npx ts-node scripts/generate-package.ts my_package
* npx ts-node scripts/generate-package.ts my_forum --with-schema --entities ForumThread,ForumPost
* npx ts-node scripts/generate-package.ts my_widget --dependency --category ui
*/
import * as fs from 'fs'
import * as path from 'path'
interface PackageConfig {
packageId: string
name: string
description: string
author: string
category: string
minLevel: number
primary: boolean
withSchema: boolean
withTests: boolean
withComponents: boolean
entities: string[]
components: string[]
dependencies: string[]
}
interface GeneratedFile {
path: string
content: string
}
const CATEGORIES = [
'ui', 'editors', 'tools', 'social', 'media', 'gaming',
'admin', 'config', 'core', 'demo', 'development', 'managers'
]
function parseArgs(args: string[]): { packageId: string; options: Partial<PackageConfig> } {
const options: Partial<PackageConfig> = {
category: 'ui',
minLevel: 2,
primary: true,
withSchema: false,
withTests: true,
withComponents: false,
entities: [],
components: [],
dependencies: []
}
let packageId = ''
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (!arg.startsWith('--') && !packageId) {
packageId = arg
} else if (arg === '--name') {
options.name = args[++i]
} else if (arg === '--description') {
options.description = args[++i]
} else if (arg === '--category') {
options.category = args[++i]
} else if (arg === '--min-level') {
options.minLevel = parseInt(args[++i], 10)
} else if (arg === '--primary') {
options.primary = true
} else if (arg === '--dependency') {
options.primary = false
} else if (arg === '--with-schema') {
options.withSchema = true
} else if (arg === '--entities') {
options.entities = args[++i].split(',').map(e => e.trim())
} else if (arg === '--with-components') {
options.withComponents = true
} else if (arg === '--components') {
options.components = args[++i].split(',').map(c => c.trim())
} else if (arg === '--deps') {
options.dependencies = args[++i].split(',').map(d => d.trim())
}
}
return { packageId, options }
}
function toPascalCase(str: string): string {
return str.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join('')
}
function toDisplayName(packageId: string): string {
return packageId.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
function generateMetadata(config: PackageConfig): string {
const prefix = config.packageId.replace(/_/g, '.')
const permissions: Record<string, { minLevel: number; description: string }> = {
[`${prefix}.view`]: {
minLevel: config.minLevel,
description: `View ${config.name}`
}
}
if (config.primary) {
permissions[`${prefix}.edit`] = {
minLevel: config.minLevel,
description: `Edit ${config.name} content`
}
}
if (config.withSchema && config.entities.length > 0) {
for (const entity of config.entities) {
const entityLower = entity.toLowerCase()
permissions[`${prefix}.${entityLower}.create`] = {
minLevel: config.minLevel,
description: `Create ${entity}`
}
permissions[`${prefix}.${entityLower}.update`] = {
minLevel: config.minLevel,
description: `Update ${entity}`
}
permissions[`${prefix}.${entityLower}.delete`] = {
minLevel: Math.min(config.minLevel + 1, 6),
description: `Delete ${entity}`
}
}
}
const metadata = {
packageId: config.packageId,
name: config.name,
version: '1.0.0',
description: config.description,
icon: 'static_content/icon.svg',
author: config.author,
category: config.category,
primary: config.primary,
dependencies: config.dependencies,
devDependencies: ['lua_test'],
exports: {
components: config.components,
scripts: ['init']
},
tests: {
scripts: ['tests/metadata.test.lua', 'tests/components.test.lua'],
cases: ['tests/metadata.cases.json', 'tests/components.cases.json']
},
minLevel: config.minLevel,
...(config.withSchema && config.entities.length > 0 ? {
schema: {
entities: config.entities,
path: 'schema/entities.yaml'
}
} : {}),
permissions
}
return JSON.stringify(metadata, null, 2)
}
function generateInitLua(config: PackageConfig): string {
return `--- ${config.name} initialization
--- @module init
local M = {}
---@class InstallContext
---@field version string
---@class InstallResult
---@field message string
---@field version string
---Called when package is installed
---@param context InstallContext
---@return InstallResult
function M.on_install(context)
return {
message = "${config.name} installed successfully",
version = context.version
}
end
---Called when package is uninstalled
---@return table
function M.on_uninstall()
return { message = "${config.name} removed" }
end
return M
`
}
function generateTestLua(testName: string, config: PackageConfig): string {
return `-- ${testName} tests for ${config.packageId}
describe("${config.name} - ${testName}", function()
it("should pass basic validation", function()
expect(true).toBe(true)
end)
it("should have required fields", function()
-- TODO: Add specific tests
expect(true).toBe(true)
end)
end)
`
}
function generateTestCases(): string {
return JSON.stringify([
{ name: 'valid_input', input: {}, expected: { valid: true } },
{ name: 'invalid_input', input: { invalid: true }, expected: { valid: false } }
], null, 2)
}
function generateSchemaYaml(config: PackageConfig): string {
if (!config.entities.length) return '# No entities defined\n'
const lines = [
`# ${config.name} Entity Definitions`,
'# Auto-generated by package template generator',
''
]
for (const entity of config.entities) {
const prefixedEntity = `Pkg_${toPascalCase(config.packageId)}_${entity}`
lines.push(`${prefixedEntity}:`)
lines.push(` description: "${entity} entity for ${config.name}"`)
lines.push(' fields:')
lines.push(' id:')
lines.push(' type: string')
lines.push(' primary: true')
lines.push(' tenantId:')
lines.push(' type: string')
lines.push(' required: true')
lines.push(' index: true')
lines.push(' createdAt:')
lines.push(' type: datetime')
lines.push(' default: now')
lines.push(' updatedAt:')
lines.push(' type: datetime')
lines.push(' onUpdate: now')
lines.push(' # TODO: Add entity-specific fields')
lines.push('')
}
return lines.join('\n')
}
function generateDbOperations(config: PackageConfig): string {
const lines = [
`-- Database operations for ${config.name}`,
'-- Auto-generated by package template generator',
'',
'local M = {}',
''
]
for (const entity of config.entities) {
const entityLower = entity.toLowerCase()
lines.push(`-- ${entity} operations`)
lines.push('')
lines.push(`---List all ${entity} records`)
lines.push('---@param ctx DBALContext')
lines.push('---@return table[]')
lines.push(`function M.list_${entityLower}(ctx)`)
lines.push(' -- TODO: Implement list operation')
lines.push(' return {}')
lines.push('end')
lines.push('')
lines.push(`---Get a single ${entity} by ID`)
lines.push('---@param ctx DBALContext')
lines.push('---@param id string')
lines.push('---@return table|nil')
lines.push(`function M.get_${entityLower}(ctx, id)`)
lines.push(' -- TODO: Implement get operation')
lines.push(' return nil')
lines.push('end')
lines.push('')
lines.push(`---Create a new ${entity}`)
lines.push('---@param ctx DBALContext')
lines.push('---@param data table')
lines.push('---@return table')
lines.push(`function M.create_${entityLower}(ctx, data)`)
lines.push(' -- TODO: Implement create operation')
lines.push(' return data')
lines.push('end')
lines.push('')
lines.push(`---Update an existing ${entity}`)
lines.push('---@param ctx DBALContext')
lines.push('---@param id string')
lines.push('---@param data table')
lines.push('---@return table|nil')
lines.push(`function M.update_${entityLower}(ctx, id, data)`)
lines.push(' -- TODO: Implement update operation')
lines.push(' return nil')
lines.push('end')
lines.push('')
lines.push(`---Delete a ${entity}`)
lines.push('---@param ctx DBALContext')
lines.push('---@param id string')
lines.push('---@return boolean')
lines.push(`function M.delete_${entityLower}(ctx, id)`)
lines.push(' -- TODO: Implement delete operation')
lines.push(' return false')
lines.push('end')
lines.push('')
}
lines.push('return M')
return lines.join('\n')
}
function generateComponentsJson(config: PackageConfig): string {
const components = config.components.map(name => ({
id: `${config.packageId}_${name.toLowerCase()}`,
type: 'container',
name,
description: `${name} component for ${config.name}`,
props: {},
layout: { type: 'flex', props: { direction: 'column', gap: 2 } },
bindings: {}
}))
return JSON.stringify(components, null, 2)
}
function generateLayoutJson(config: PackageConfig): string {
const layout = {
id: `${config.packageId}_layout`,
name: `${config.name} Layout`,
type: 'page',
props: { title: config.name, minLevel: config.minLevel },
children: [
{
id: `${config.packageId}_header`,
type: 'container',
props: { variant: 'header' },
children: [
{ id: `${config.packageId}_title`, type: 'text', props: { variant: 'h1', content: config.name } },
{ id: `${config.packageId}_description`, type: 'text', props: { variant: 'body1', content: config.description } }
]
},
{
id: `${config.packageId}_content`,
type: 'container',
props: { variant: 'main' },
children: [
{ id: `${config.packageId}_placeholder`, type: 'text', props: { content: 'Add your components here' } }
]
}
]
}
return JSON.stringify(layout, null, 2)
}
function generateIconSvg(config: PackageConfig): string {
const letter = config.name.charAt(0).toUpperCase()
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<text x="12" y="16" text-anchor="middle" font-size="12" fill="currentColor" stroke="none">${letter}</text>
</svg>`
}
function generateReadme(config: PackageConfig): string {
const lines = [
`# ${config.name}`,
'',
config.description,
'',
'## Installation',
'',
'This package is part of the MetaBuilder platform and is installed automatically.',
'',
'## Access Level',
'',
`Minimum level required: **${config.minLevel}**`,
'',
config.primary
? 'This is a **primary package** that can own routes.'
: 'This is a **dependency package** that provides shared functionality.',
''
]
if (config.withSchema && config.entities.length > 0) {
lines.push('## Entities')
lines.push('')
for (const entity of config.entities) {
lines.push(`- ${entity}`)
}
lines.push('')
}
if (config.components.length > 0) {
lines.push('## Components')
lines.push('')
for (const comp of config.components) {
lines.push(`- \`${comp}\``)
}
lines.push('')
}
lines.push('## Development')
lines.push('')
lines.push('```bash')
lines.push('# Run tests')
lines.push(`npm run test:package ${config.packageId}`)
lines.push('```')
lines.push('')
return lines.join('\n')
}
function generateIndexTs(config: PackageConfig): string {
return `// ${config.name} package exports
// Auto-generated by package template generator
import metadata from './metadata.json'
import components from './components.json'
import layout from './layout.json'
export const packageSeed = {
metadata,
components,
layout,
}
export default packageSeed
`
}
function generate(config: PackageConfig): GeneratedFile[] {
const files: GeneratedFile[] = []
// Core files
files.push({ path: 'seed/metadata.json', content: generateMetadata(config) })
files.push({ path: 'seed/components.json', content: generateComponentsJson(config) })
files.push({ path: 'seed/layout.json', content: generateLayoutJson(config) })
files.push({ path: 'seed/scripts/init.lua', content: generateInitLua(config) })
files.push({ path: 'seed/index.ts', content: generateIndexTs(config) })
// Schema files
if (config.withSchema && config.entities.length > 0) {
files.push({ path: 'seed/schema/entities.yaml', content: generateSchemaYaml(config) })
files.push({ path: 'seed/scripts/db/operations.lua', content: generateDbOperations(config) })
}
// Test files
if (config.withTests) {
files.push({ path: 'seed/scripts/tests/metadata.test.lua', content: generateTestLua('metadata', config) })
files.push({ path: 'seed/scripts/tests/components.test.lua', content: generateTestLua('components', config) })
files.push({ path: 'seed/scripts/tests/metadata.cases.json', content: generateTestCases() })
files.push({ path: 'seed/scripts/tests/components.cases.json', content: generateTestCases() })
}
// Static content
files.push({ path: 'static_content/icon.svg', content: generateIconSvg(config) })
files.push({ path: 'README.md', content: generateReadme(config) })
return files
}
function writeFiles(packagePath: string, files: GeneratedFile[]): void {
for (const file of files) {
const fullPath = path.join(packagePath, file.path)
const dir = path.dirname(fullPath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
fs.writeFileSync(fullPath, file.content)
console.log(` Created: ${file.path}`)
}
}
function printHelp(): void {
console.log(`
Package Template Generator
==========================
Usage: npx ts-node scripts/generate-package.ts <package_id> [options]
Options:
--name <name> Display name (default: derived from package_id)
--description <desc> Package description
--category <cat> Package category (default: ui)
--min-level <n> Minimum access level 0-6 (default: 2)
--primary Package can own routes (default)
--dependency Package is dependency-only
--with-schema Include database schema scaffolding
--entities <e1,e2> Entity names for schema (comma-separated, PascalCase)
--with-components Include component scaffolding
--components <c1,c2> Component names (comma-separated, PascalCase)
--deps <d1,d2> Package dependencies (comma-separated)
Categories: ${CATEGORIES.join(', ')}
Examples:
npx ts-node scripts/generate-package.ts my_package
npx ts-node scripts/generate-package.ts my_forum --with-schema --entities ForumThread,ForumPost
npx ts-node scripts/generate-package.ts my_widget --dependency --category ui
npx ts-node scripts/generate-package.ts my_dashboard --with-components --components StatCard,Chart
`)
}
// Main
const args = process.argv.slice(2)
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printHelp()
process.exit(0)
}
const { packageId, options } = parseArgs(args)
if (!packageId) {
console.error('Error: package_id is required')
process.exit(1)
}
if (!/^[a-z][a-z0-9_]*$/.test(packageId)) {
console.error('Error: package_id must be lowercase with underscores, starting with a letter')
process.exit(1)
}
if (options.category && !CATEGORIES.includes(options.category)) {
console.error(`Error: Invalid category. Must be one of: ${CATEGORIES.join(', ')}`)
process.exit(1)
}
const config: PackageConfig = {
packageId,
name: options.name || toDisplayName(packageId),
description: options.description || `${toDisplayName(packageId)} package for MetaBuilder`,
author: 'MetaBuilder',
category: options.category || 'ui',
minLevel: options.minLevel ?? 2,
primary: options.primary ?? true,
withSchema: options.withSchema || false,
withTests: options.withTests ?? true,
withComponents: options.withComponents || false,
entities: options.entities || [],
components: options.components || [],
dependencies: options.dependencies || []
}
const packagesDir = path.join(process.cwd(), 'packages')
const packagePath = path.join(packagesDir, packageId)
if (fs.existsSync(packagePath)) {
console.error(`Error: Package directory already exists: ${packagePath}`)
process.exit(1)
}
console.log(`\nGenerating package: ${config.name}`)
console.log(` Location: ${packagePath}`)
console.log(` Category: ${config.category}`)
console.log(` Level: ${config.minLevel}`)
console.log(` Type: ${config.primary ? 'Primary' : 'Dependency'}`)
if (config.withSchema) console.log(` Entities: ${config.entities.join(', ')}`)
if (config.components.length) console.log(` Components: ${config.components.join(', ')}`)
console.log('')
const files = generate(config)
writeFiles(packagePath, files)
console.log(`\n✅ Package '${packageId}' created successfully!`)
console.log(`\nNext steps:`)
console.log(` 1. Review generated files in packages/${packageId}/`)
console.log(` 2. Add package-specific logic to seed/scripts/`)
console.log(` 3. Update components.json with your component definitions`)
console.log(` 4. Run tests: npm run test:package ${packageId}`)