mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-29 00:05:01 +00:00
Merge branch 'main' into copilot/update-dependencies-dashboard
This commit is contained in:
File diff suppressed because it is too large
Load Diff
200
frontends/nextjs/src/components/editors/lua/blocks/BlockList.tsx
Normal file
200
frontends/nextjs/src/components/editors/lua/blocks/BlockList.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { MouseEvent } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Add as AddIcon,
|
||||
ArrowDownward,
|
||||
ArrowUpward,
|
||||
ContentCopy,
|
||||
Delete as DeleteIcon,
|
||||
} from '@mui/icons-material'
|
||||
import type { BlockDefinition, BlockSlot, LuaBlock, LuaBlockType } from '../types'
|
||||
import styles from '../LuaBlocksEditor.module.scss'
|
||||
|
||||
interface BlockListProps {
|
||||
blocks: LuaBlock[]
|
||||
blockDefinitionMap: Map<LuaBlockType, BlockDefinition>
|
||||
onRequestAddBlock: (
|
||||
event: MouseEvent<HTMLElement>,
|
||||
target: { parentId: string | null; slot: BlockSlot }
|
||||
) => void
|
||||
onMoveBlock: (blockId: string, direction: 'up' | 'down') => void
|
||||
onDuplicateBlock: (blockId: string) => void
|
||||
onRemoveBlock: (blockId: string) => void
|
||||
onUpdateField: (blockId: string, fieldName: string, value: string) => void
|
||||
}
|
||||
|
||||
const renderBlockFields = (
|
||||
block: LuaBlock,
|
||||
definition: BlockDefinition,
|
||||
onUpdateField: (blockId: string, fieldName: string, value: string) => void
|
||||
) => {
|
||||
if (definition.fields.length === 0) return null
|
||||
|
||||
return (
|
||||
<Box className={styles.blockFields}>
|
||||
{definition.fields.map((field) => (
|
||||
<Box key={field.name}>
|
||||
<Typography className={styles.blockFieldLabel}>{field.label}</Typography>
|
||||
{field.type === 'select' ? (
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
value={block.fields[field.name]}
|
||||
onChange={(event) => onUpdateField(block.id, field.name, event.target.value)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
InputProps={{
|
||||
sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
|
||||
}}
|
||||
>
|
||||
{field.options?.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
value={block.fields[field.name]}
|
||||
onChange={(event) => onUpdateField(block.id, field.name, event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
InputProps={{
|
||||
sx: { backgroundColor: 'rgba(255,255,255,0.95)' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const renderBlockSection = (
|
||||
title: string,
|
||||
blocks: LuaBlock[] | undefined,
|
||||
parentId: string | null,
|
||||
slot: BlockSlot,
|
||||
onRequestAddBlock: (
|
||||
event: MouseEvent<HTMLElement>,
|
||||
target: { parentId: string | null; slot: BlockSlot }
|
||||
) => void,
|
||||
renderBlockCard: (block: LuaBlock, index: number, total: number) => JSX.Element | null
|
||||
) => (
|
||||
<Box className={styles.blockSection}>
|
||||
<Box className={styles.blockSectionHeader}>
|
||||
<Typography className={styles.blockSectionTitle}>{title}</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={(event) => onRequestAddBlock(event, { parentId, slot })}
|
||||
startIcon={<AddIcon fontSize="small" />}
|
||||
>
|
||||
Add block
|
||||
</Button>
|
||||
</Box>
|
||||
<Box className={styles.blockSectionBody}>
|
||||
{blocks && blocks.length > 0 ? (
|
||||
blocks.map((child, index) => renderBlockCard(child, index, blocks.length))
|
||||
) : (
|
||||
<Box className={styles.blockEmpty}>Drop blocks here to build this section.</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
export const BlockList = ({
|
||||
blocks,
|
||||
blockDefinitionMap,
|
||||
onRequestAddBlock,
|
||||
onMoveBlock,
|
||||
onDuplicateBlock,
|
||||
onRemoveBlock,
|
||||
onUpdateField,
|
||||
}: BlockListProps) => {
|
||||
const renderBlockCard = (block: LuaBlock, index: number, total: number) => {
|
||||
const definition = blockDefinitionMap.get(block.type)
|
||||
if (!definition) return null
|
||||
|
||||
return (
|
||||
<Box key={block.id} className={styles.blockCard} data-category={definition.category}>
|
||||
<Box className={styles.blockHeader}>
|
||||
<Typography className={styles.blockTitle}>{definition.label}</Typography>
|
||||
<Box className={styles.blockActions}>
|
||||
<Tooltip title="Move up">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onMoveBlock(block.id, 'up')}
|
||||
disabled={index === 0}
|
||||
sx={{ color: 'rgba(255,255,255,0.85)' }}
|
||||
>
|
||||
<ArrowUpward fontSize="inherit" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Move down">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onMoveBlock(block.id, 'down')}
|
||||
disabled={index === total - 1}
|
||||
sx={{ color: 'rgba(255,255,255,0.85)' }}
|
||||
>
|
||||
<ArrowDownward fontSize="inherit" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Duplicate block">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onDuplicateBlock(block.id)}
|
||||
sx={{ color: 'rgba(255,255,255,0.85)' }}
|
||||
>
|
||||
<ContentCopy fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete block">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRemoveBlock(block.id)}
|
||||
sx={{ color: 'rgba(255,255,255,0.85)' }}
|
||||
>
|
||||
<DeleteIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
{renderBlockFields(block, definition, onUpdateField)}
|
||||
{definition.hasChildren &&
|
||||
renderBlockSection('Then', block.children, block.id, 'children', onRequestAddBlock, renderBlockCard)}
|
||||
{definition.hasElseChildren &&
|
||||
renderBlockSection(
|
||||
'Else',
|
||||
block.elseChildren,
|
||||
block.id,
|
||||
'elseChildren',
|
||||
onRequestAddBlock,
|
||||
renderBlockCard
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={styles.blockStack}>
|
||||
{blocks.map((block, index) => renderBlockCard(block, index, blocks.length))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Box, Menu, MenuItem, Typography } from '@mui/material'
|
||||
import type { BlockDefinition } from '../types'
|
||||
import styles from '../LuaBlocksEditor.module.scss'
|
||||
|
||||
interface BlockMenuProps {
|
||||
anchorEl: HTMLElement | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
blocks: BlockDefinition[]
|
||||
onSelect: (type: BlockDefinition['type']) => void
|
||||
}
|
||||
|
||||
export const BlockMenu = ({ anchorEl, open, onClose, blocks, onSelect }: BlockMenuProps) => (
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={onClose} PaperProps={{ sx: { minWidth: 280 } }}>
|
||||
{blocks.map((definition) => (
|
||||
<MenuItem key={definition.type} onClick={() => onSelect(definition.type)}>
|
||||
<Box className={styles.menuSwatch} data-category={definition.category} sx={{ mr: 1 }} />
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{definition.label}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{definition.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
)
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import type { BlockCategory, BlockDefinition, LuaBlock, LuaBlockType } from '../types'
|
||||
|
||||
const BLOCKS_METADATA_PREFIX = '--@blocks '
|
||||
|
||||
const BLOCK_DEFINITIONS: BlockDefinition[] = [
|
||||
{
|
||||
type: 'log',
|
||||
label: 'Log message',
|
||||
description: 'Send a message to the Lua console',
|
||||
category: 'Basics',
|
||||
fields: [
|
||||
{
|
||||
name: 'message',
|
||||
label: 'Message',
|
||||
placeholder: '"Hello from Lua"',
|
||||
type: 'text',
|
||||
defaultValue: '"Hello from Lua"',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'set_variable',
|
||||
label: 'Set variable',
|
||||
description: 'Create or update a variable',
|
||||
category: 'Data',
|
||||
fields: [
|
||||
{
|
||||
name: 'scope',
|
||||
label: 'Scope',
|
||||
type: 'select',
|
||||
defaultValue: 'local',
|
||||
options: [
|
||||
{ label: 'local', value: 'local' },
|
||||
{ label: 'global', value: 'global' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Variable name',
|
||||
placeholder: 'count',
|
||||
type: 'text',
|
||||
defaultValue: 'count',
|
||||
},
|
||||
{
|
||||
name: 'value',
|
||||
label: 'Value',
|
||||
placeholder: '0',
|
||||
type: 'text',
|
||||
defaultValue: '0',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'if',
|
||||
label: 'If',
|
||||
description: 'Run blocks when a condition is true',
|
||||
category: 'Logic',
|
||||
fields: [
|
||||
{
|
||||
name: 'condition',
|
||||
label: 'Condition',
|
||||
placeholder: 'context.data.isActive',
|
||||
type: 'text',
|
||||
defaultValue: 'context.data.isActive',
|
||||
},
|
||||
],
|
||||
hasChildren: true,
|
||||
},
|
||||
{
|
||||
type: 'if_else',
|
||||
label: 'If / Else',
|
||||
description: 'Branch execution with else fallback',
|
||||
category: 'Logic',
|
||||
fields: [
|
||||
{
|
||||
name: 'condition',
|
||||
label: 'Condition',
|
||||
placeholder: 'context.data.count > 5',
|
||||
type: 'text',
|
||||
defaultValue: 'context.data.count > 5',
|
||||
},
|
||||
],
|
||||
hasChildren: true,
|
||||
hasElseChildren: true,
|
||||
},
|
||||
{
|
||||
type: 'repeat',
|
||||
label: 'Repeat loop',
|
||||
description: 'Run nested blocks multiple times',
|
||||
category: 'Loops',
|
||||
fields: [
|
||||
{
|
||||
name: 'iterator',
|
||||
label: 'Iterator',
|
||||
placeholder: 'i',
|
||||
type: 'text',
|
||||
defaultValue: 'i',
|
||||
},
|
||||
{
|
||||
name: 'count',
|
||||
label: 'Times',
|
||||
placeholder: '3',
|
||||
type: 'number',
|
||||
defaultValue: '3',
|
||||
},
|
||||
],
|
||||
hasChildren: true,
|
||||
},
|
||||
{
|
||||
type: 'call',
|
||||
label: 'Call function',
|
||||
description: 'Invoke a Lua function',
|
||||
category: 'Functions',
|
||||
fields: [
|
||||
{
|
||||
name: 'function',
|
||||
label: 'Function name',
|
||||
placeholder: 'my_function',
|
||||
type: 'text',
|
||||
defaultValue: 'my_function',
|
||||
},
|
||||
{
|
||||
name: 'args',
|
||||
label: 'Arguments',
|
||||
placeholder: 'context.data',
|
||||
type: 'text',
|
||||
defaultValue: 'context.data',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'return',
|
||||
label: 'Return',
|
||||
description: 'Return a value from the script',
|
||||
category: 'Basics',
|
||||
fields: [
|
||||
{
|
||||
name: 'value',
|
||||
label: 'Value',
|
||||
placeholder: 'true',
|
||||
type: 'text',
|
||||
defaultValue: 'true',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'comment',
|
||||
label: 'Comment',
|
||||
description: 'Add a comment to explain a step',
|
||||
category: 'Basics',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
label: 'Comment',
|
||||
placeholder: 'Explain what happens here',
|
||||
type: 'text',
|
||||
defaultValue: 'Explain what happens here',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const createBlockId = () => `block_${Date.now()}_${Math.random().toString(16).slice(2)}`
|
||||
|
||||
const indent = (depth: number) => ' '.repeat(depth)
|
||||
|
||||
const renderBlocks = (blocks: LuaBlock[], depth: number, renderBlock: (block: LuaBlock, depth: number) => string) =>
|
||||
blocks
|
||||
.map((block) => renderBlock(block, depth))
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
export function useBlockDefinitions() {
|
||||
const blockDefinitionMap = useMemo(
|
||||
() => new Map<LuaBlockType, BlockDefinition>(BLOCK_DEFINITIONS.map((definition) => [definition.type, definition])),
|
||||
[]
|
||||
)
|
||||
|
||||
const blocksByCategory = useMemo<Record<BlockCategory, BlockDefinition[]>>(() => {
|
||||
const initial: Record<BlockCategory, BlockDefinition[]> = {
|
||||
Basics: [],
|
||||
Logic: [],
|
||||
Loops: [],
|
||||
Data: [],
|
||||
Functions: [],
|
||||
}
|
||||
|
||||
return BLOCK_DEFINITIONS.reduce((acc, definition) => {
|
||||
acc[definition.category] = [...(acc[definition.category] || []), definition]
|
||||
return acc
|
||||
}, initial)
|
||||
}, [])
|
||||
|
||||
const createBlock = useCallback(
|
||||
(type: LuaBlockType): LuaBlock => {
|
||||
const definition = blockDefinitionMap.get(type)
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown block type: ${type}`)
|
||||
}
|
||||
|
||||
const fields = definition.fields.reduce<Record<string, string>>((acc, field) => {
|
||||
acc[field.name] = field.defaultValue
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return {
|
||||
id: createBlockId(),
|
||||
type,
|
||||
fields,
|
||||
children: definition.hasChildren ? [] : undefined,
|
||||
elseChildren: definition.hasElseChildren ? [] : undefined,
|
||||
}
|
||||
},
|
||||
[blockDefinitionMap]
|
||||
)
|
||||
|
||||
const cloneBlock = useCallback(
|
||||
(block: LuaBlock): LuaBlock => ({
|
||||
...block,
|
||||
id: createBlockId(),
|
||||
fields: { ...block.fields },
|
||||
children: block.children ? block.children.map(cloneBlock) : undefined,
|
||||
elseChildren: block.elseChildren ? block.elseChildren.map(cloneBlock) : undefined,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const getFieldValue = useCallback((block: LuaBlock, fieldName: string, fallback: string) => {
|
||||
const value = block.fields[fieldName]
|
||||
if (value === undefined || value === null) return fallback
|
||||
const normalized = String(value).trim()
|
||||
return normalized.length > 0 ? normalized : fallback
|
||||
}, [])
|
||||
|
||||
const renderChildBlocks = useCallback(
|
||||
(blocks: LuaBlock[] | undefined, depth: number, renderBlock: (block: LuaBlock, depth: number) => string) => {
|
||||
if (!blocks || blocks.length === 0) {
|
||||
return `${indent(depth)}-- add blocks here`
|
||||
}
|
||||
return renderBlocks(blocks, depth, renderBlock)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const buildLuaFromBlocks = useCallback(
|
||||
(blocks: LuaBlock[]) => {
|
||||
const renderBlock = (block: LuaBlock, depth: number): string => {
|
||||
switch (block.type) {
|
||||
case 'log': {
|
||||
const message = getFieldValue(block, 'message', '""')
|
||||
return `${indent(depth)}log(${message})`
|
||||
}
|
||||
case 'set_variable': {
|
||||
const scope = getFieldValue(block, 'scope', 'local')
|
||||
const name = getFieldValue(block, 'name', 'value')
|
||||
const value = getFieldValue(block, 'value', 'nil')
|
||||
const keyword = scope === 'local' ? 'local ' : ''
|
||||
return `${indent(depth)}${keyword}${name} = ${value}`
|
||||
}
|
||||
case 'if': {
|
||||
const condition = getFieldValue(block, 'condition', 'true')
|
||||
const body = renderChildBlocks(block.children, depth + 1, renderBlock)
|
||||
return `${indent(depth)}if ${condition} then\n${body}\n${indent(depth)}end`
|
||||
}
|
||||
case 'if_else': {
|
||||
const condition = getFieldValue(block, 'condition', 'true')
|
||||
const thenBody = renderChildBlocks(block.children, depth + 1, renderBlock)
|
||||
const elseBody = renderChildBlocks(block.elseChildren, depth + 1, renderBlock)
|
||||
return `${indent(depth)}if ${condition} then\n${thenBody}\n${indent(depth)}else\n${elseBody}\n${indent(depth)}end`
|
||||
}
|
||||
case 'repeat': {
|
||||
const iterator = getFieldValue(block, 'iterator', 'i')
|
||||
const count = getFieldValue(block, 'count', '1')
|
||||
const body = renderChildBlocks(block.children, depth + 1, renderBlock)
|
||||
return `${indent(depth)}for ${iterator} = 1, ${count} do\n${body}\n${indent(depth)}end`
|
||||
}
|
||||
case 'return': {
|
||||
const value = getFieldValue(block, 'value', 'nil')
|
||||
return `${indent(depth)}return ${value}`
|
||||
}
|
||||
case 'call': {
|
||||
const functionName = getFieldValue(block, 'function', 'my_function')
|
||||
const args = getFieldValue(block, 'args', '')
|
||||
const argsSection = args ? args : ''
|
||||
return `${indent(depth)}${functionName}(${argsSection})`
|
||||
}
|
||||
case 'comment': {
|
||||
const text = getFieldValue(block, 'text', '')
|
||||
return `${indent(depth)}-- ${text}`
|
||||
}
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = `${BLOCKS_METADATA_PREFIX}${JSON.stringify({ version: 1, blocks })}`
|
||||
const body = renderBlocks(blocks, 0, renderBlock)
|
||||
if (!body.trim()) {
|
||||
return `${metadata}\n-- empty block workspace\n`
|
||||
}
|
||||
return `${metadata}\n${body}\n`
|
||||
},
|
||||
[getFieldValue, renderChildBlocks]
|
||||
)
|
||||
|
||||
const decodeBlocksMetadata = useCallback((code: string): LuaBlock[] | null => {
|
||||
const metadataLine = code
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith(BLOCKS_METADATA_PREFIX))
|
||||
|
||||
if (!metadataLine) return null
|
||||
|
||||
const json = metadataLine.slice(BLOCKS_METADATA_PREFIX.length)
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
if (!parsed || !Array.isArray(parsed.blocks)) return null
|
||||
return parsed.blocks as LuaBlock[]
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
blockDefinitions: BLOCK_DEFINITIONS,
|
||||
blockDefinitionMap,
|
||||
blocksByCategory,
|
||||
createBlock,
|
||||
cloneBlock,
|
||||
buildLuaFromBlocks,
|
||||
decodeBlocksMetadata,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useEffect, useMemo, useState, type MouseEvent } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { LuaScript } from '@/lib/level-types'
|
||||
import type { BlockSlot, LuaBlock, LuaBlockType } from '../types'
|
||||
|
||||
interface UseLuaBlocksStateProps {
|
||||
scripts: LuaScript[]
|
||||
onScriptsChange: (scripts: LuaScript[]) => void
|
||||
buildLuaFromBlocks: (blocks: LuaBlock[]) => string
|
||||
createBlock: (type: LuaBlockType) => LuaBlock
|
||||
cloneBlock: (block: LuaBlock) => LuaBlock
|
||||
decodeBlocksMetadata: (code: string) => LuaBlock[] | null
|
||||
}
|
||||
|
||||
interface MenuTarget {
|
||||
parentId: string | null
|
||||
slot: BlockSlot
|
||||
}
|
||||
|
||||
const addBlockToTree = (
|
||||
blocks: LuaBlock[],
|
||||
parentId: string | null,
|
||||
slot: BlockSlot,
|
||||
newBlock: LuaBlock
|
||||
): LuaBlock[] => {
|
||||
if (slot === 'root' || !parentId) {
|
||||
return [...blocks, newBlock]
|
||||
}
|
||||
|
||||
return blocks.map((block) => {
|
||||
if (block.id === parentId) {
|
||||
const current = slot === 'children' ? block.children ?? [] : block.elseChildren ?? []
|
||||
const updated = [...current, newBlock]
|
||||
if (slot === 'children') {
|
||||
return { ...block, children: updated }
|
||||
}
|
||||
return { ...block, elseChildren: updated }
|
||||
}
|
||||
|
||||
const children = block.children ? addBlockToTree(block.children, parentId, slot, newBlock) : block.children
|
||||
const elseChildren = block.elseChildren
|
||||
? addBlockToTree(block.elseChildren, parentId, slot, newBlock)
|
||||
: block.elseChildren
|
||||
|
||||
if (children !== block.children || elseChildren !== block.elseChildren) {
|
||||
return { ...block, children, elseChildren }
|
||||
}
|
||||
|
||||
return block
|
||||
})
|
||||
}
|
||||
|
||||
const updateBlockInTree = (
|
||||
blocks: LuaBlock[],
|
||||
blockId: string,
|
||||
updater: (block: LuaBlock) => LuaBlock
|
||||
): LuaBlock[] =>
|
||||
blocks.map((block) => {
|
||||
if (block.id === blockId) {
|
||||
return updater(block)
|
||||
}
|
||||
|
||||
const children = block.children ? updateBlockInTree(block.children, blockId, updater) : block.children
|
||||
const elseChildren = block.elseChildren
|
||||
? updateBlockInTree(block.elseChildren, blockId, updater)
|
||||
: block.elseChildren
|
||||
|
||||
if (children !== block.children || elseChildren !== block.elseChildren) {
|
||||
return { ...block, children, elseChildren }
|
||||
}
|
||||
|
||||
return block
|
||||
})
|
||||
|
||||
const removeBlockFromTree = (blocks: LuaBlock[], blockId: string): LuaBlock[] =>
|
||||
blocks
|
||||
.filter((block) => block.id !== blockId)
|
||||
.map((block) => {
|
||||
const children = block.children ? removeBlockFromTree(block.children, blockId) : block.children
|
||||
const elseChildren = block.elseChildren
|
||||
? removeBlockFromTree(block.elseChildren, blockId)
|
||||
: block.elseChildren
|
||||
|
||||
if (children !== block.children || elseChildren !== block.elseChildren) {
|
||||
return { ...block, children, elseChildren }
|
||||
}
|
||||
|
||||
return block
|
||||
})
|
||||
|
||||
const moveBlockInTree = (blocks: LuaBlock[], blockId: string, direction: 'up' | 'down'): LuaBlock[] => {
|
||||
const index = blocks.findIndex((block) => block.id === blockId)
|
||||
if (index !== -1) {
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1
|
||||
if (targetIndex < 0 || targetIndex >= blocks.length) return blocks
|
||||
|
||||
const updated = [...blocks]
|
||||
const [moved] = updated.splice(index, 1)
|
||||
updated.splice(targetIndex, 0, moved)
|
||||
return updated
|
||||
}
|
||||
|
||||
return blocks.map((block) => {
|
||||
const children = block.children ? moveBlockInTree(block.children, blockId, direction) : block.children
|
||||
const elseChildren = block.elseChildren
|
||||
? moveBlockInTree(block.elseChildren, blockId, direction)
|
||||
: block.elseChildren
|
||||
|
||||
if (children !== block.children || elseChildren !== block.elseChildren) {
|
||||
return { ...block, children, elseChildren }
|
||||
}
|
||||
|
||||
return block
|
||||
})
|
||||
}
|
||||
|
||||
export function useLuaBlocksState({
|
||||
scripts,
|
||||
onScriptsChange,
|
||||
buildLuaFromBlocks,
|
||||
createBlock,
|
||||
cloneBlock,
|
||||
decodeBlocksMetadata,
|
||||
}: UseLuaBlocksStateProps) {
|
||||
const [selectedScriptId, setSelectedScriptId] = useState<string | null>(
|
||||
scripts.length > 0 ? scripts[0].id : null
|
||||
)
|
||||
const [blocksByScript, setBlocksByScript] = useState<Record<string, LuaBlock[]>>({})
|
||||
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null)
|
||||
const [menuTarget, setMenuTarget] = useState<MenuTarget | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (scripts.length === 0) {
|
||||
setSelectedScriptId(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedScriptId || !scripts.find((script) => script.id === selectedScriptId)) {
|
||||
setSelectedScriptId(scripts[0].id)
|
||||
}
|
||||
}, [scripts, selectedScriptId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedScriptId) return
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(blocksByScript, selectedScriptId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const script = scripts.find((item) => item.id === selectedScriptId)
|
||||
const parsedBlocks = script ? decodeBlocksMetadata(script.code) : null
|
||||
|
||||
setBlocksByScript((prev) => ({
|
||||
...prev,
|
||||
[selectedScriptId]: parsedBlocks ?? [],
|
||||
}))
|
||||
}, [blocksByScript, decodeBlocksMetadata, scripts, selectedScriptId])
|
||||
|
||||
const selectedScript = scripts.find((script) => script.id === selectedScriptId) || null
|
||||
const activeBlocks = selectedScriptId ? blocksByScript[selectedScriptId] || [] : []
|
||||
const generatedCode = useMemo(() => buildLuaFromBlocks(activeBlocks), [activeBlocks, buildLuaFromBlocks])
|
||||
|
||||
const handleAddScript = () => {
|
||||
const starterBlocks = [createBlock('log')]
|
||||
const newScript: LuaScript = {
|
||||
id: `lua_${Date.now()}`,
|
||||
name: 'Block Script',
|
||||
description: 'Built with Lua blocks',
|
||||
code: buildLuaFromBlocks(starterBlocks),
|
||||
parameters: [],
|
||||
}
|
||||
|
||||
onScriptsChange([...scripts, newScript])
|
||||
setBlocksByScript((prev) => ({ ...prev, [newScript.id]: starterBlocks }))
|
||||
setSelectedScriptId(newScript.id)
|
||||
toast.success('Block script created')
|
||||
}
|
||||
|
||||
const handleDeleteScript = (scriptId: string) => {
|
||||
const remaining = scripts.filter((script) => script.id !== scriptId)
|
||||
onScriptsChange(remaining)
|
||||
|
||||
setBlocksByScript((prev) => {
|
||||
const { [scriptId]: _, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
|
||||
if (selectedScriptId === scriptId) {
|
||||
setSelectedScriptId(remaining.length > 0 ? remaining[0].id : null)
|
||||
}
|
||||
|
||||
toast.success('Script deleted')
|
||||
}
|
||||
|
||||
const handleUpdateScript = (updates: Partial<LuaScript>) => {
|
||||
if (!selectedScript) return
|
||||
onScriptsChange(
|
||||
scripts.map((script) => (script.id === selectedScript.id ? { ...script, ...updates } : script))
|
||||
)
|
||||
}
|
||||
|
||||
const handleApplyCode = () => {
|
||||
if (!selectedScript) return
|
||||
handleUpdateScript({ code: generatedCode })
|
||||
toast.success('Lua code updated from blocks')
|
||||
}
|
||||
|
||||
const handleCopyCode = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedCode)
|
||||
toast.success('Lua code copied to clipboard')
|
||||
} catch (error) {
|
||||
toast.error('Unable to copy code')
|
||||
}
|
||||
}
|
||||
|
||||
const handleReloadFromCode = () => {
|
||||
if (!selectedScript) return
|
||||
const parsed = decodeBlocksMetadata(selectedScript.code)
|
||||
if (!parsed) {
|
||||
toast.warning('No block metadata found in this script')
|
||||
return
|
||||
}
|
||||
setBlocksByScript((prev) => ({ ...prev, [selectedScript.id]: parsed }))
|
||||
toast.success('Blocks loaded from script')
|
||||
}
|
||||
|
||||
const handleRequestAddBlock = (
|
||||
event: MouseEvent<HTMLElement>,
|
||||
target: { parentId: string | null; slot: BlockSlot }
|
||||
) => {
|
||||
setMenuAnchor(event.currentTarget)
|
||||
setMenuTarget(target)
|
||||
}
|
||||
|
||||
const handleAddBlock = (type: LuaBlockType, target?: { parentId: string | null; slot: BlockSlot }) => {
|
||||
const resolvedTarget = target ?? menuTarget
|
||||
if (!selectedScriptId || !resolvedTarget) return
|
||||
|
||||
const newBlock = createBlock(type)
|
||||
setBlocksByScript((prev) => ({
|
||||
...prev,
|
||||
[selectedScriptId]: addBlockToTree(
|
||||
prev[selectedScriptId] || [],
|
||||
resolvedTarget.parentId,
|
||||
resolvedTarget.slot,
|
||||
newBlock
|
||||
),
|
||||
}))
|
||||
|
||||
setMenuAnchor(null)
|
||||
setMenuTarget(null)
|
||||
}
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setMenuAnchor(null)
|
||||
setMenuTarget(null)
|
||||
}
|
||||
|
||||
const handleUpdateField = (blockId: string, fieldName: string, value: string) => {
|
||||
if (!selectedScriptId) return
|
||||
setBlocksByScript((prev) => ({
|
||||
...prev,
|
||||
[selectedScriptId]: updateBlockInTree(prev[selectedScriptId] || [], blockId, (block) => ({
|
||||
...block,
|
||||
fields: {
|
||||
...block.fields,
|
||||
[fieldName]: value,
|
||||
},
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleRemoveBlock = (blockId: string) => {
|
||||
if (!selectedScriptId) return
|
||||
setBlocksByScript((prev) => ({
|
||||
...prev,
|
||||
[selectedScriptId]: removeBlockFromTree(prev[selectedScriptId] || [], blockId),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleDuplicateBlock = (blockId: string) => {
|
||||
if (!selectedScriptId) return
|
||||
|
||||
setBlocksByScript((prev) => {
|
||||
const blocks = prev[selectedScriptId] || []
|
||||
let duplicated: LuaBlock | null = null
|
||||
|
||||
const updated = updateBlockInTree(blocks, blockId, (block) => {
|
||||
duplicated = cloneBlock(block)
|
||||
return block
|
||||
})
|
||||
|
||||
if (!duplicated) return prev
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[selectedScriptId]: addBlockToTree(updated, null, 'root', duplicated),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleMoveBlock = (blockId: string, direction: 'up' | 'down') => {
|
||||
if (!selectedScriptId) return
|
||||
setBlocksByScript((prev) => ({
|
||||
...prev,
|
||||
[selectedScriptId]: moveBlockInTree(prev[selectedScriptId] || [], blockId, direction),
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
activeBlocks,
|
||||
generatedCode,
|
||||
handleAddBlock,
|
||||
handleAddScript,
|
||||
handleApplyCode,
|
||||
handleCloseMenu,
|
||||
handleCopyCode,
|
||||
handleDeleteScript,
|
||||
handleDuplicateBlock,
|
||||
handleMoveBlock,
|
||||
handleReloadFromCode,
|
||||
handleRemoveBlock,
|
||||
handleRequestAddBlock,
|
||||
handleUpdateField,
|
||||
handleUpdateScript,
|
||||
menuAnchor,
|
||||
menuTarget,
|
||||
selectedScript,
|
||||
selectedScriptId,
|
||||
setSelectedScriptId,
|
||||
}
|
||||
}
|
||||
42
frontends/nextjs/src/components/editors/lua/types.ts
Normal file
42
frontends/nextjs/src/components/editors/lua/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type LuaBlockType =
|
||||
| 'log'
|
||||
| 'set_variable'
|
||||
| 'if'
|
||||
| 'if_else'
|
||||
| 'repeat'
|
||||
| 'return'
|
||||
| 'call'
|
||||
| 'comment'
|
||||
|
||||
export type BlockSlot = 'root' | 'children' | 'elseChildren'
|
||||
|
||||
export type BlockCategory = 'Basics' | 'Logic' | 'Loops' | 'Data' | 'Functions'
|
||||
|
||||
export type BlockFieldType = 'text' | 'number' | 'select'
|
||||
|
||||
export interface BlockFieldDefinition {
|
||||
name: string
|
||||
label: string
|
||||
placeholder?: string
|
||||
type?: BlockFieldType
|
||||
defaultValue: string
|
||||
options?: Array<{ label: string; value: string }>
|
||||
}
|
||||
|
||||
export interface BlockDefinition {
|
||||
type: LuaBlockType
|
||||
label: string
|
||||
description: string
|
||||
category: BlockCategory
|
||||
fields: BlockFieldDefinition[]
|
||||
hasChildren?: boolean
|
||||
hasElseChildren?: boolean
|
||||
}
|
||||
|
||||
export interface LuaBlock {
|
||||
id: string
|
||||
type: LuaBlockType
|
||||
fields: Record<string, string>
|
||||
children?: LuaBlock[]
|
||||
elseChildren?: LuaBlock[]
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
import { Separator } from '@/components/ui'
|
||||
import { toast } from 'sonner'
|
||||
import { PACKAGE_CATALOG } from '@/lib/packages/core/package-catalog'
|
||||
import type { PackageManifest, PackageContent, InstalledPackage } from '@/lib/package-types'
|
||||
import { PACKAGE_CATALOG, type PackageCatalogData } from '@/lib/packages/core/package-catalog'
|
||||
import type { PackageManifest, InstalledPackage } from '@/lib/package-types'
|
||||
import { installPackage, listInstalledPackages, togglePackageEnabled, uninstallPackage } from '@/lib/api/packages'
|
||||
import { Package, Download, Trash, Power, MagnifyingGlass, Star, Tag, User, TrendUp, Funnel, Export, ArrowSquareIn } from '@phosphor-icons/react'
|
||||
import { PackageImportExport } from './PackageImportExport'
|
||||
@@ -22,7 +22,7 @@ interface PackageManagerProps {
|
||||
export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
const [packages, setPackages] = useState<PackageManifest[]>([])
|
||||
const [installedPackages, setInstalledPackages] = useState<InstalledPackage[]>([])
|
||||
const [selectedPackage, setSelectedPackage] = useState<{ manifest: PackageManifest; content: PackageContent } | null>(null)
|
||||
const [selectedPackage, setSelectedPackage] = useState<PackageCatalogData | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||||
const [sortBy, setSortBy] = useState<'name' | 'downloads' | 'rating'>('downloads')
|
||||
@@ -39,10 +39,14 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
const installed = await listInstalledPackages()
|
||||
setInstalledPackages(installed)
|
||||
|
||||
const allPackages = Object.values(PACKAGE_CATALOG).map(pkg => ({
|
||||
...pkg.manifest,
|
||||
installed: installed.some(ip => ip.packageId === pkg.manifest.id),
|
||||
}))
|
||||
const allPackages = Object.values(PACKAGE_CATALOG).map(pkg => {
|
||||
const packageData = pkg()
|
||||
|
||||
return {
|
||||
...packageData.manifest,
|
||||
installed: installed.some(ip => ip.packageId === packageData.manifest.id),
|
||||
}
|
||||
})
|
||||
|
||||
setPackages(allPackages)
|
||||
}
|
||||
@@ -50,7 +54,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
const handleInstallPackage = async (packageId: string) => {
|
||||
setInstalling(true)
|
||||
try {
|
||||
const packageEntry = PACKAGE_CATALOG[packageId]
|
||||
const packageEntry = PACKAGE_CATALOG[packageId]?.()
|
||||
if (!packageEntry) {
|
||||
toast.error('Package not found')
|
||||
return
|
||||
@@ -71,7 +75,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
|
||||
const handleUninstallPackage = async (packageId: string) => {
|
||||
try {
|
||||
const packageEntry = PACKAGE_CATALOG[packageId]
|
||||
const packageEntry = PACKAGE_CATALOG[packageId]?.()
|
||||
if (!packageEntry) {
|
||||
toast.error('Package not found')
|
||||
return
|
||||
@@ -227,7 +231,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
isInstalled={pkg.installed}
|
||||
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
|
||||
onViewDetails={() => {
|
||||
setSelectedPackage(PACKAGE_CATALOG[pkg.id])
|
||||
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
|
||||
setShowDetails(true)
|
||||
}}
|
||||
onToggle={handleTogglePackage}
|
||||
@@ -253,7 +257,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
isInstalled={true}
|
||||
installedPackage={installedPackages.find(ip => ip.packageId === pkg.id)}
|
||||
onViewDetails={() => {
|
||||
setSelectedPackage(PACKAGE_CATALOG[pkg.id])
|
||||
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
|
||||
setShowDetails(true)
|
||||
}}
|
||||
onToggle={handleTogglePackage}
|
||||
@@ -274,7 +278,7 @@ export function PackageManager({ onClose }: PackageManagerProps) {
|
||||
isInstalled={false}
|
||||
installedPackage={undefined}
|
||||
onViewDetails={() => {
|
||||
setSelectedPackage(PACKAGE_CATALOG[pkg.id])
|
||||
setSelectedPackage(PACKAGE_CATALOG[pkg.id]?.() ?? null)
|
||||
setShowDetails(true)
|
||||
}}
|
||||
onToggle={handleTogglePackage}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { formatWorkflowLogAnalysis, summarizeWorkflowLogs } from '@/lib/github/analyze-workflow-logs'
|
||||
|
||||
import { Job, RepoInfo, WorkflowRun } from '../types'
|
||||
|
||||
interface UseWorkflowLogAnalysisOptions {
|
||||
repoInfo: RepoInfo | null
|
||||
onAnalysisStart?: () => void
|
||||
onAnalysisComplete?: (report: string | null) => void
|
||||
}
|
||||
|
||||
export function useWorkflowLogAnalysis({
|
||||
repoInfo,
|
||||
onAnalysisStart,
|
||||
onAnalysisComplete,
|
||||
}: UseWorkflowLogAnalysisOptions) {
|
||||
const [selectedRunId, setSelectedRunId] = useState<number | null>(null)
|
||||
const [runJobs, setRunJobs] = useState<Job[]>([])
|
||||
const [runLogs, setRunLogs] = useState<string | null>(null)
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
|
||||
|
||||
const downloadRunLogs = useCallback(
|
||||
async (runId: number, runName: string) => {
|
||||
setIsLoadingLogs(true)
|
||||
setSelectedRunId(runId)
|
||||
setRunLogs(null)
|
||||
setRunJobs([])
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
runName,
|
||||
includeLogs: 'true',
|
||||
jobLimit: '20',
|
||||
})
|
||||
if (repoInfo) {
|
||||
query.set('owner', repoInfo.owner)
|
||||
query.set('repo', repoInfo.repo)
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/github/actions/runs/${runId}/logs?${query.toString()}`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
let payload: {
|
||||
jobs?: Job[]
|
||||
logsText?: string | null
|
||||
truncated?: boolean
|
||||
requiresAuth?: boolean
|
||||
error?: string
|
||||
} | null = null
|
||||
|
||||
try {
|
||||
payload = await response.json()
|
||||
} catch {
|
||||
payload = null
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (payload?.requiresAuth) {
|
||||
toast.error('GitHub API requires authentication for logs')
|
||||
}
|
||||
const message = payload?.error || `Failed to download logs (${response.status})`
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const logsText = payload?.logsText ?? null
|
||||
setRunJobs(payload?.jobs ?? [])
|
||||
setRunLogs(logsText)
|
||||
|
||||
if (logsText) {
|
||||
const blob = new Blob([logsText], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `workflow-logs-${runId}-${new Date().toISOString()}.txt`
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
document.body.removeChild(anchor)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
if (payload?.truncated) {
|
||||
toast.info('Downloaded logs are truncated. Increase the job limit for more.')
|
||||
}
|
||||
|
||||
toast.success('Workflow logs downloaded successfully')
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to download logs'
|
||||
toast.error(errorMessage)
|
||||
setRunLogs(`Error fetching logs: ${errorMessage}`)
|
||||
} finally {
|
||||
setIsLoadingLogs(false)
|
||||
}
|
||||
},
|
||||
[repoInfo],
|
||||
)
|
||||
|
||||
const analyzeRunLogs = useCallback(
|
||||
async (runs: WorkflowRun[] | null) => {
|
||||
if (!runLogs || !selectedRunId) {
|
||||
toast.error('No logs to analyze')
|
||||
return
|
||||
}
|
||||
|
||||
onAnalysisStart?.()
|
||||
try {
|
||||
const selectedRun = runs?.find(r => r.id === selectedRunId)
|
||||
const summary = summarizeWorkflowLogs(runLogs)
|
||||
const report = formatWorkflowLogAnalysis(summary, {
|
||||
runName: selectedRun?.name,
|
||||
runId: selectedRunId,
|
||||
})
|
||||
onAnalysisComplete?.(report)
|
||||
toast.success('Log analysis complete')
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Analysis failed'
|
||||
toast.error(errorMessage)
|
||||
onAnalysisComplete?.(null)
|
||||
}
|
||||
},
|
||||
[onAnalysisComplete, onAnalysisStart, runLogs, selectedRunId],
|
||||
)
|
||||
|
||||
return {
|
||||
analyzeRunLogs,
|
||||
downloadRunLogs,
|
||||
isLoadingLogs,
|
||||
runJobs,
|
||||
runLogs,
|
||||
selectedRunId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { WorkflowRun, RepoInfo } from '../types'
|
||||
|
||||
const DEFAULT_REPO_LABEL = 'johndoe6345789/metabuilder'
|
||||
|
||||
export function useWorkflowRuns() {
|
||||
const [runs, setRuns] = useState<WorkflowRun[] | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastFetched, setLastFetched] = useState<Date | null>(null)
|
||||
const [needsAuth, setNeedsAuth] = useState(false)
|
||||
const [repoInfo, setRepoInfo] = useState<RepoInfo | null>(null)
|
||||
const [secondsUntilRefresh, setSecondsUntilRefresh] = useState(30)
|
||||
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true)
|
||||
|
||||
const repoLabel = repoInfo ? `${repoInfo.owner}/${repoInfo.repo}` : DEFAULT_REPO_LABEL
|
||||
|
||||
const fetchRuns = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setNeedsAuth(false)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/github/actions/runs', { cache: 'no-store' })
|
||||
let payload: {
|
||||
owner?: string
|
||||
repo?: string
|
||||
runs?: WorkflowRun[]
|
||||
fetchedAt?: string
|
||||
requiresAuth?: boolean
|
||||
error?: string
|
||||
} | null = null
|
||||
|
||||
try {
|
||||
payload = await response.json()
|
||||
} catch {
|
||||
payload = null
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (payload?.requiresAuth) {
|
||||
setNeedsAuth(true)
|
||||
}
|
||||
const message = payload?.error || `Failed to fetch workflow runs (${response.status})`
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const retrievedRuns = payload?.runs || []
|
||||
setRuns(retrievedRuns)
|
||||
if (payload?.owner && payload?.repo) {
|
||||
setRepoInfo({ owner: payload.owner, repo: payload.repo })
|
||||
}
|
||||
setLastFetched(payload?.fetchedAt ? new Date(payload.fetchedAt) : new Date())
|
||||
setSecondsUntilRefresh(30)
|
||||
toast.success(`Fetched ${retrievedRuns.length} workflow runs`)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
setError(errorMessage)
|
||||
toast.error(`Failed to fetch: ${errorMessage}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchRuns()
|
||||
}, [fetchRuns])
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefreshEnabled) return
|
||||
|
||||
const countdownInterval = setInterval(() => {
|
||||
setSecondsUntilRefresh((prev) => {
|
||||
if (prev <= 1) {
|
||||
fetchRuns()
|
||||
return 30
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(countdownInterval)
|
||||
}, [autoRefreshEnabled, fetchRuns])
|
||||
|
||||
const toggleAutoRefresh = () => setAutoRefreshEnabled((prev) => !prev)
|
||||
|
||||
const getStatusColor = (status: string, conclusion: string | null) => {
|
||||
if (status === 'completed') {
|
||||
if (conclusion === 'success') return 'success.main'
|
||||
if (conclusion === 'failure') return 'error.main'
|
||||
if (conclusion === 'cancelled') return 'text.secondary'
|
||||
}
|
||||
return 'warning.main'
|
||||
}
|
||||
|
||||
const conclusion = useMemo(() => {
|
||||
if (!runs || runs.length === 0) return null
|
||||
|
||||
const total = runs.length
|
||||
const completed = runs.filter(r => r.status === 'completed').length
|
||||
const successful = runs.filter(r => r.status === 'completed' && r.conclusion === 'success').length
|
||||
const failed = runs.filter(r => r.status === 'completed' && r.conclusion === 'failure').length
|
||||
const cancelled = runs.filter(r => r.status === 'completed' && r.conclusion === 'cancelled').length
|
||||
const inProgress = runs.filter(r => r.status !== 'completed').length
|
||||
|
||||
const mostRecent = runs[0]
|
||||
const mostRecentTimestamp = new Date(mostRecent.updated_at).getTime()
|
||||
const timeThreshold = 5 * 60 * 1000
|
||||
const recentWorkflows = runs.filter((run) => {
|
||||
const runTimestamp = new Date(run.updated_at).getTime()
|
||||
return mostRecentTimestamp - runTimestamp <= timeThreshold
|
||||
})
|
||||
|
||||
const mostRecentPassed = recentWorkflows.every(
|
||||
(run) => run.status === 'completed' && run.conclusion === 'success',
|
||||
)
|
||||
const mostRecentFailed = recentWorkflows.some(
|
||||
(run) => run.status === 'completed' && run.conclusion === 'failure',
|
||||
)
|
||||
const mostRecentRunning = recentWorkflows.some((run) => run.status !== 'completed')
|
||||
|
||||
const successRate = total > 0 ? Math.round((successful / total) * 100) : 0
|
||||
let health: 'healthy' | 'warning' | 'critical' = 'healthy'
|
||||
if (failed / total > 0.3 || successRate < 60) {
|
||||
health = 'critical'
|
||||
} else if (failed > 0 || inProgress > 0) {
|
||||
health = 'warning'
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
completed,
|
||||
successful,
|
||||
failed,
|
||||
cancelled,
|
||||
inProgress,
|
||||
successRate,
|
||||
health,
|
||||
recentWorkflows,
|
||||
mostRecentPassed,
|
||||
mostRecentFailed,
|
||||
mostRecentRunning,
|
||||
}
|
||||
}, [runs])
|
||||
|
||||
const summaryTone = useMemo(() => {
|
||||
if (!conclusion) return 'warning'
|
||||
if (conclusion.mostRecentPassed) return 'success'
|
||||
if (conclusion.mostRecentFailed) return 'error'
|
||||
return 'warning'
|
||||
}, [conclusion])
|
||||
|
||||
return {
|
||||
runs,
|
||||
isLoading,
|
||||
error,
|
||||
lastFetched,
|
||||
needsAuth,
|
||||
repoInfo,
|
||||
repoLabel,
|
||||
secondsUntilRefresh,
|
||||
autoRefreshEnabled,
|
||||
toggleAutoRefresh,
|
||||
fetchRuns,
|
||||
getStatusColor,
|
||||
conclusion,
|
||||
summaryTone,
|
||||
}
|
||||
}
|
||||
36
frontends/nextjs/src/components/misc/github/types.ts
Normal file
36
frontends/nextjs/src/components/misc/github/types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface WorkflowRun {
|
||||
id: number
|
||||
name: string
|
||||
status: string
|
||||
conclusion: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
html_url: string
|
||||
head_branch: string
|
||||
event: string
|
||||
jobs_url?: string
|
||||
}
|
||||
|
||||
export interface JobStep {
|
||||
name: string
|
||||
status: string
|
||||
conclusion: string | null
|
||||
number: number
|
||||
started_at?: string | null
|
||||
completed_at?: string | null
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: number
|
||||
name: string
|
||||
status: string
|
||||
conclusion: string | null
|
||||
started_at: string
|
||||
completed_at: string | null
|
||||
steps: JobStep[]
|
||||
}
|
||||
|
||||
export interface RepoInfo {
|
||||
owner: string
|
||||
repo: string
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Box, Stack } from '@mui/material'
|
||||
import { Info as InfoIcon, SmartToy as RobotIcon } from '@mui/icons-material'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Skeleton } from '@/components/ui'
|
||||
|
||||
interface AnalysisPanelProps {
|
||||
analysis: string | null
|
||||
isAnalyzing: boolean
|
||||
runLogs: string | null
|
||||
onAnalyzeWorkflows: () => void
|
||||
onAnalyzeLogs?: () => void
|
||||
}
|
||||
|
||||
export function AnalysisPanel({ analysis, isAnalyzing, runLogs, onAnalyzeLogs, onAnalyzeWorkflows }: AnalysisPanelProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<RobotIcon sx={{ fontSize: 24 }} />
|
||||
<CardTitle>AI-Powered Workflow Analysis</CardTitle>
|
||||
</Stack>
|
||||
<CardDescription>
|
||||
{runLogs
|
||||
? 'Deep analysis of downloaded workflow logs using GPT-4'
|
||||
: 'Deep analysis of your CI/CD pipeline using GPT-4'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Stack spacing={3}>
|
||||
{runLogs ? (
|
||||
<Button
|
||||
onClick={onAnalyzeLogs}
|
||||
disabled={isAnalyzing}
|
||||
size="lg"
|
||||
fullWidth
|
||||
startIcon={<RobotIcon sx={{ fontSize: 20 }} />}
|
||||
>
|
||||
{isAnalyzing ? 'Analyzing Logs...' : 'Analyze Downloaded Logs with AI'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onAnalyzeWorkflows}
|
||||
disabled={isAnalyzing}
|
||||
size="lg"
|
||||
fullWidth
|
||||
startIcon={<RobotIcon sx={{ fontSize: 20 }} />}
|
||||
>
|
||||
{isAnalyzing ? 'Analyzing...' : 'Analyze Workflows with AI'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isAnalyzing && (
|
||||
<Stack spacing={2}>
|
||||
<Skeleton sx={{ height: 128 }} />
|
||||
<Skeleton sx={{ height: 128 }} />
|
||||
<Skeleton sx={{ height: 128 }} />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{analysis && !isAnalyzing && (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'action.hover',
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{analysis}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!analysis && !isAnalyzing && (
|
||||
<Alert>
|
||||
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
||||
<InfoIcon sx={{ color: 'info.main', fontSize: 20 }} />
|
||||
<Box>
|
||||
<AlertTitle>No Analysis Yet</AlertTitle>
|
||||
<AlertDescription>
|
||||
{runLogs
|
||||
? 'Click the button above to run an AI analysis of the downloaded logs. The AI will identify errors, provide root cause analysis, and suggest fixes.'
|
||||
: 'Download logs from a specific workflow run using the "Download Logs" button, or click above to analyze overall workflow patterns.'}
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
100
frontends/nextjs/src/components/misc/github/views/RunDetails.tsx
Normal file
100
frontends/nextjs/src/components/misc/github/views/RunDetails.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Box, Stack, Typography } from '@mui/material'
|
||||
import { Description as FileTextIcon, SmartToy as RobotIcon } from '@mui/icons-material'
|
||||
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, ScrollArea } from '@/components/ui'
|
||||
|
||||
import { Job } from '../types'
|
||||
|
||||
interface RunDetailsProps {
|
||||
runLogs: string | null
|
||||
runJobs: Job[]
|
||||
selectedRunId: number | null
|
||||
onAnalyzeLogs: () => void
|
||||
isAnalyzing: boolean
|
||||
}
|
||||
|
||||
export function RunDetails({ runLogs, runJobs, selectedRunId, onAnalyzeLogs, isAnalyzing }: RunDetailsProps) {
|
||||
if (!runLogs) return null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<FileTextIcon sx={{ fontSize: 24 }} />
|
||||
<CardTitle>Workflow Logs</CardTitle>
|
||||
{selectedRunId && (
|
||||
<Badge variant="secondary" sx={{ fontSize: '0.75rem' }}>
|
||||
Run #{selectedRunId}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
<CardDescription>Complete logs from workflow run including all jobs and steps</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Stack spacing={3}>
|
||||
{runJobs.length > 0 && (
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Jobs Summary</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{runJobs.map((job) => (
|
||||
<Badge
|
||||
key={job.id}
|
||||
variant={
|
||||
job.conclusion === 'success'
|
||||
? 'default'
|
||||
: job.conclusion === 'failure'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
sx={{ fontSize: '0.75rem' }}
|
||||
>
|
||||
{job.name}: {job.conclusion || job.status}
|
||||
</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<ScrollArea
|
||||
sx={{
|
||||
height: 600,
|
||||
width: '100%',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
m: 0,
|
||||
p: 2,
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{runLogs}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!runLogs) return
|
||||
navigator.clipboard.writeText(runLogs)
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Copy to Clipboard
|
||||
</Button>
|
||||
<Button onClick={onAnalyzeLogs} disabled={isAnalyzing} startIcon={<RobotIcon sx={{ fontSize: 20 }} />}>
|
||||
{isAnalyzing ? 'Analyzing Logs...' : 'Analyze Logs with AI'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
432
frontends/nextjs/src/components/misc/github/views/RunList.tsx
Normal file
432
frontends/nextjs/src/components/misc/github/views/RunList.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import { Box, Stack, Typography } from '@mui/material'
|
||||
import { alpha } from '@mui/material/styles'
|
||||
import {
|
||||
Autorenew as RunningIcon,
|
||||
Cancel as FailureIcon,
|
||||
CheckCircle as SuccessIcon,
|
||||
Download as DownloadIcon,
|
||||
OpenInNew as OpenInNewIcon,
|
||||
Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Skeleton } from '@/components/ui'
|
||||
|
||||
import { WorkflowRun } from '../types'
|
||||
|
||||
const spinSx = {
|
||||
animation: 'spin 1s linear infinite',
|
||||
'@keyframes spin': {
|
||||
from: { transform: 'rotate(0deg)' },
|
||||
to: { transform: 'rotate(360deg)' },
|
||||
},
|
||||
}
|
||||
|
||||
interface PipelineSummary {
|
||||
cancelled: number
|
||||
completed: number
|
||||
failed: number
|
||||
health: 'healthy' | 'warning' | 'critical'
|
||||
inProgress: number
|
||||
mostRecentFailed: boolean
|
||||
mostRecentPassed: boolean
|
||||
mostRecentRunning: boolean
|
||||
recentWorkflows: WorkflowRun[]
|
||||
successRate: number
|
||||
successful: number
|
||||
total: number
|
||||
}
|
||||
|
||||
interface RunListProps {
|
||||
runs: WorkflowRun[] | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
needsAuth: boolean
|
||||
repoLabel: string
|
||||
lastFetched: Date | null
|
||||
autoRefreshEnabled: boolean
|
||||
secondsUntilRefresh: number
|
||||
onToggleAutoRefresh: () => void
|
||||
onRefresh: () => void
|
||||
getStatusColor: (status: string, conclusion: string | null) => string
|
||||
onDownloadLogs: (runId: number, runName: string) => void
|
||||
onDownloadJson: () => void
|
||||
isLoadingLogs: boolean
|
||||
conclusion: PipelineSummary | null
|
||||
summaryTone: 'success' | 'error' | 'warning'
|
||||
selectedRunId: number | null
|
||||
}
|
||||
|
||||
export function RunList({
|
||||
runs,
|
||||
isLoading,
|
||||
error,
|
||||
needsAuth,
|
||||
repoLabel,
|
||||
lastFetched,
|
||||
autoRefreshEnabled,
|
||||
secondsUntilRefresh,
|
||||
onToggleAutoRefresh,
|
||||
onRefresh,
|
||||
getStatusColor,
|
||||
onDownloadLogs,
|
||||
onDownloadJson,
|
||||
isLoadingLogs,
|
||||
conclusion,
|
||||
summaryTone,
|
||||
selectedRunId,
|
||||
}: RunListProps) {
|
||||
return (
|
||||
<Card sx={{ borderWidth: 2, borderColor: 'divider' }}>
|
||||
<CardHeader>
|
||||
<Stack
|
||||
direction={{ xs: 'column', lg: 'row' }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: 'flex-start', lg: 'center' }}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h4" fontWeight={700}>
|
||||
GitHub Actions Monitor
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Repository:{' '}
|
||||
<Box
|
||||
component="code"
|
||||
sx={{
|
||||
ml: 1,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'action.hover',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{repoLabel}
|
||||
</Box>
|
||||
</Typography>
|
||||
{lastFetched && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Last fetched: {lastFetched.toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: 'flex-start', md: 'center' }}
|
||||
>
|
||||
<Stack spacing={1} alignItems={{ xs: 'flex-start', md: 'flex-end' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Badge
|
||||
variant={autoRefreshEnabled ? 'default' : 'outline'}
|
||||
sx={{ fontSize: '0.75rem' }}
|
||||
>
|
||||
Auto-refresh {autoRefreshEnabled ? 'ON' : 'OFF'}
|
||||
</Badge>
|
||||
{autoRefreshEnabled && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
|
||||
Next refresh: {secondsUntilRefresh}s
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<Button onClick={onToggleAutoRefresh} variant="outline" size="sm">
|
||||
{autoRefreshEnabled ? 'Disable' : 'Enable'} Auto-refresh
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
onClick={onDownloadJson}
|
||||
disabled={!runs || runs.length === 0}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
startIcon={<DownloadIcon sx={{ fontSize: 18 }} />}
|
||||
>
|
||||
Download JSON
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
size="lg"
|
||||
startIcon={<RefreshIcon sx={isLoading ? spinSx : undefined} />}
|
||||
>
|
||||
{isLoading ? 'Fetching...' : 'Refresh'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{error && (
|
||||
<Alert variant="destructive" sx={{ mb: 2 }}>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{needsAuth && (
|
||||
<Alert variant="warning" sx={{ mb: 2 }}>
|
||||
<AlertTitle>Authentication Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
GitHub API requires authentication for this request. Please configure credentials and retry.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{conclusion && (
|
||||
<Alert
|
||||
sx={(theme) => ({
|
||||
borderWidth: 2,
|
||||
borderColor: theme.palette[summaryTone].main,
|
||||
bgcolor: alpha(theme.palette[summaryTone].main, 0.08),
|
||||
alignItems: 'flex-start',
|
||||
mb: 2,
|
||||
})}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start">
|
||||
{summaryTone === 'success' && (
|
||||
<SuccessIcon sx={{ color: 'success.main', fontSize: 48 }} />
|
||||
)}
|
||||
{summaryTone === 'error' && (
|
||||
<FailureIcon sx={{ color: 'error.main', fontSize: 48 }} />
|
||||
)}
|
||||
{summaryTone === 'warning' && (
|
||||
<RunningIcon sx={{ color: 'warning.main', fontSize: 48, ...spinSx }} />
|
||||
)}
|
||||
<Box flex={1}>
|
||||
<AlertTitle>
|
||||
<Box sx={{ fontSize: '1.25rem', fontWeight: 700, mb: 1 }}>
|
||||
{conclusion.mostRecentPassed && 'Most Recent Builds: ALL PASSED'}
|
||||
{conclusion.mostRecentFailed && 'Most Recent Builds: FAILURES DETECTED'}
|
||||
{conclusion.mostRecentRunning && 'Most Recent Builds: RUNNING'}
|
||||
</Box>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="body2">
|
||||
{conclusion.recentWorkflows.length > 1
|
||||
? `Showing ${conclusion.recentWorkflows.length} workflows from the most recent run:`
|
||||
: 'Most recent workflow:'}
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
{conclusion.recentWorkflows.map((workflow: WorkflowRun) => {
|
||||
const statusLabel = workflow.status === 'completed'
|
||||
? workflow.conclusion
|
||||
: workflow.status
|
||||
const badgeVariant = workflow.conclusion === 'success'
|
||||
? 'default'
|
||||
: workflow.conclusion === 'failure'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={workflow.id}
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
boxShadow: 1,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
{workflow.status === 'completed' && workflow.conclusion === 'success' && (
|
||||
<SuccessIcon sx={{ color: 'success.main', fontSize: 20 }} />
|
||||
)}
|
||||
{workflow.status === 'completed' && workflow.conclusion === 'failure' && (
|
||||
<FailureIcon sx={{ color: 'error.main', fontSize: 20 }} />
|
||||
)}
|
||||
{workflow.status !== 'completed' && (
|
||||
<RunningIcon sx={{ color: 'warning.main', fontSize: 20, ...spinSx }} />
|
||||
)}
|
||||
<Typography fontWeight={600}>{workflow.name}</Typography>
|
||||
<Badge variant={badgeVariant} sx={{ fontSize: '0.75rem' }}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
flexWrap="wrap"
|
||||
sx={{ color: 'text.secondary', fontSize: '0.75rem' }}
|
||||
>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<Typography fontWeight={600}>Branch:</Typography>
|
||||
<Box
|
||||
component="code"
|
||||
sx={{
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 1,
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{workflow.head_branch}
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<Typography fontWeight={600}>Updated:</Typography>
|
||||
<Typography>{new Date(workflow.updated_at).toLocaleString()}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
<Box>
|
||||
<Button
|
||||
variant={conclusion.mostRecentPassed ? 'default' : 'destructive'}
|
||||
size="sm"
|
||||
component="a"
|
||||
href="https://github.com/johndoe6345789/metabuilder/actions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
endIcon={<OpenInNewIcon sx={{ fontSize: 18 }} />}
|
||||
>
|
||||
View All Workflows on GitHub
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card sx={{ borderWidth: 2, borderColor: 'divider' }}>
|
||||
<CardHeader>
|
||||
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<SuccessIcon sx={{ color: 'success.main', fontSize: 24 }} />
|
||||
<CardTitle>Recent Workflow Runs</CardTitle>
|
||||
</Stack>
|
||||
{isLoading && <Skeleton sx={{ width: 120, height: 12 }} />}
|
||||
</Stack>
|
||||
<CardDescription>Latest GitHub Actions runs with status and controls</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isLoading && !runs && (
|
||||
<Stack spacing={2}>
|
||||
<Skeleton sx={{ height: 96 }} />
|
||||
<Skeleton sx={{ height: 96 }} />
|
||||
<Skeleton sx={{ height: 96 }} />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{runs && runs.length > 0 ? (
|
||||
<Stack spacing={2}>
|
||||
{runs.map((run) => {
|
||||
const statusIcon = getStatusColor(run.status, run.conclusion)
|
||||
return (
|
||||
<Card key={run.id} variant="outlined" sx={{ borderColor: 'divider' }}>
|
||||
<CardContent>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} justifyContent="space-between">
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
bgcolor: statusIcon,
|
||||
}}
|
||||
/>
|
||||
<Typography fontWeight={600}>{run.name}</Typography>
|
||||
<Badge variant="outline" sx={{ textTransform: 'capitalize' }}>
|
||||
{run.event}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" sx={{ color: 'text.secondary' }}>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<Typography fontWeight={600}>Branch:</Typography>
|
||||
<Box
|
||||
component="code"
|
||||
sx={{
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 1,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{run.head_branch}
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<Typography fontWeight={600}>Event:</Typography>
|
||||
<Typography>{run.event}</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<Typography fontWeight={600}>Status:</Typography>
|
||||
<Typography sx={{ color: getStatusColor(run.status, run.conclusion) }}>
|
||||
{run.status === 'completed' ? run.conclusion : run.status}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Updated: {new Date(run.updated_at).toLocaleString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1} alignItems={{ xs: 'flex-start', md: 'flex-end' }}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDownloadLogs(run.id, run.name)}
|
||||
disabled={isLoadingLogs && selectedRunId === run.id}
|
||||
startIcon={
|
||||
isLoadingLogs && selectedRunId === run.id
|
||||
? <RunningIcon sx={{ fontSize: 16, ...spinSx }} />
|
||||
: <DownloadIcon sx={{ fontSize: 16 }} />
|
||||
}
|
||||
>
|
||||
{isLoadingLogs && selectedRunId === run.id ? 'Loading...' : 'Download Logs'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
component="a"
|
||||
href={run.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
endIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
<Box sx={{ textAlign: 'center', pt: 2 }}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (!runs) return
|
||||
const jsonData = JSON.stringify(runs, null, 2)
|
||||
navigator.clipboard.writeText(jsonData)
|
||||
}}
|
||||
>
|
||||
Copy All as JSON
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 6, color: 'text.secondary' }}>
|
||||
{isLoading ? 'Loading workflow runs...' : 'No workflow runs found. Click refresh to fetch data.'}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
export { forumClassicPackage } from './set-a/forum-classic'
|
||||
export { guestbookRetroPackage } from './set-a/guestbook-retro'
|
||||
export { youtubeClonePackage } from './set-a/youtube-clone'
|
||||
export { spotifyClonePackage } from './set-a/spotify-clone'
|
||||
export { retroGamesPackage } from './set-b/retro-games'
|
||||
export { ecommerceBasicPackage } from './set-b/ecommerce-basic'
|
||||
export { ircWebchatPackage } from './set-b/irc-webchat'
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { PackageContent, PackageManifest } from '../../package-types'
|
||||
|
||||
export const forumClassicPackage = (): { manifest: PackageManifest; content: PackageContent } => ({
|
||||
manifest: {
|
||||
id: 'forum-classic',
|
||||
name: 'Classic Forum',
|
||||
version: '1.0.0',
|
||||
description: 'Full-featured discussion forum with threads, categories, user profiles, and moderation tools. Perfect for building community discussions.',
|
||||
author: 'MetaBuilder Team',
|
||||
category: 'social',
|
||||
icon: '💬',
|
||||
screenshots: [],
|
||||
tags: ['forum', 'discussion', 'community', 'threads'],
|
||||
dependencies: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
downloadCount: 1247,
|
||||
rating: 4.7,
|
||||
installed: false,
|
||||
},
|
||||
content: {
|
||||
schemas: [
|
||||
{
|
||||
name: 'ForumCategory',
|
||||
displayName: 'Forum Category',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'name', type: 'string', label: 'Category Name', required: true },
|
||||
{ name: 'description', type: 'text', label: 'Description', required: false },
|
||||
{ name: 'order', type: 'number', label: 'Display Order', required: true, defaultValue: 0 },
|
||||
{ name: 'icon', type: 'string', label: 'Icon', required: false },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ForumThread',
|
||||
displayName: 'Forum Thread',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'categoryId', type: 'string', label: 'Category ID', required: true },
|
||||
{ name: 'title', type: 'string', label: 'Thread Title', required: true },
|
||||
{ name: 'authorId', type: 'string', label: 'Author ID', required: true },
|
||||
{ name: 'content', type: 'text', label: 'Content', required: true },
|
||||
{ name: 'isPinned', type: 'boolean', label: 'Pinned', required: false, defaultValue: false },
|
||||
{ name: 'isLocked', type: 'boolean', label: 'Locked', required: false, defaultValue: false },
|
||||
{ name: 'views', type: 'number', label: 'View Count', required: true, defaultValue: 0 },
|
||||
{ name: 'replyCount', type: 'number', label: 'Reply Count', required: true, defaultValue: 0 },
|
||||
{ name: 'lastReplyAt', type: 'number', label: 'Last Reply At', required: false },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
{ name: 'updatedAt', type: 'number', label: 'Updated At', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ForumPost',
|
||||
displayName: 'Forum Post',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'threadId', type: 'string', label: 'Thread ID', required: true },
|
||||
{ name: 'authorId', type: 'string', label: 'Author ID', required: true },
|
||||
{ name: 'content', type: 'text', label: 'Content', required: true },
|
||||
{ name: 'likes', type: 'number', label: 'Like Count', required: true, defaultValue: 0 },
|
||||
{ name: 'isEdited', type: 'boolean', label: 'Edited', required: false, defaultValue: false },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
{ name: 'updatedAt', type: 'number', label: 'Updated At', required: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
pages: [
|
||||
{
|
||||
id: 'page_forum_home',
|
||||
path: '/forum',
|
||||
title: 'Forum Home',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: true,
|
||||
requiredRole: 'user',
|
||||
},
|
||||
{
|
||||
id: 'page_forum_category',
|
||||
path: '/forum/category/:id',
|
||||
title: 'Forum Category',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: true,
|
||||
requiredRole: 'user',
|
||||
},
|
||||
{
|
||||
id: 'page_forum_thread',
|
||||
path: '/forum/thread/:id',
|
||||
title: 'Forum Thread',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: true,
|
||||
requiredRole: 'user',
|
||||
},
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
id: 'workflow_create_thread',
|
||||
name: 'Create Forum Thread',
|
||||
description: 'Workflow for creating a new forum thread',
|
||||
nodes: [],
|
||||
edges: [],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'workflow_post_reply',
|
||||
name: 'Post Forum Reply',
|
||||
description: 'Workflow for posting a reply to a thread',
|
||||
nodes: [],
|
||||
edges: [],
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
luaScripts: [
|
||||
{
|
||||
id: 'lua_forum_thread_count',
|
||||
name: 'Get Thread Count',
|
||||
description: 'Count threads in a category',
|
||||
code: 'function countThreads(categoryId)\n return 0\nend\nreturn countThreads',
|
||||
parameters: [{ name: 'categoryId', type: 'string' }],
|
||||
returnType: 'number',
|
||||
},
|
||||
],
|
||||
componentHierarchy: {},
|
||||
componentConfigs: {},
|
||||
seedData: {
|
||||
ForumCategory: [
|
||||
{ id: 'cat_1', name: 'General Discussion', description: 'Talk about anything', order: 1, icon: '💭', createdAt: Date.now() },
|
||||
{ id: 'cat_2', name: 'Announcements', description: 'Official announcements', order: 0, icon: '📢', createdAt: Date.now() },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { PackageContent, PackageManifest } from '../../package-types'
|
||||
|
||||
export const guestbookRetroPackage = (): { manifest: PackageManifest; content: PackageContent } => ({
|
||||
manifest: {
|
||||
id: 'guestbook-retro',
|
||||
name: 'Retro Guestbook',
|
||||
version: '1.0.0',
|
||||
description: 'Nostalgic 90s-style guestbook with animated GIFs, custom backgrounds, and visitor messages. Perfect for retro-themed websites.',
|
||||
author: 'MetaBuilder Team',
|
||||
category: 'content',
|
||||
icon: '📖',
|
||||
screenshots: [],
|
||||
tags: ['guestbook', 'retro', '90s', 'nostalgia'],
|
||||
dependencies: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
downloadCount: 892,
|
||||
rating: 4.5,
|
||||
installed: false,
|
||||
},
|
||||
content: {
|
||||
schemas: [
|
||||
{
|
||||
name: 'GuestbookEntry',
|
||||
displayName: 'Guestbook Entry',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'authorName', type: 'string', label: 'Name', required: true },
|
||||
{ name: 'authorEmail', type: 'string', label: 'Email', required: false },
|
||||
{ name: 'authorWebsite', type: 'string', label: 'Website', required: false },
|
||||
{ name: 'message', type: 'text', label: 'Message', required: true },
|
||||
{ name: 'backgroundColor', type: 'string', label: 'Background Color', required: false },
|
||||
{ name: 'textColor', type: 'string', label: 'Text Color', required: false },
|
||||
{ name: 'gifUrl', type: 'string', label: 'GIF URL', required: false },
|
||||
{ name: 'approved', type: 'boolean', label: 'Approved', required: true, defaultValue: false },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
pages: [
|
||||
{
|
||||
id: 'page_guestbook',
|
||||
path: '/guestbook',
|
||||
title: 'Guestbook',
|
||||
level: 1,
|
||||
componentTree: [],
|
||||
requiresAuth: false,
|
||||
},
|
||||
],
|
||||
workflows: [],
|
||||
luaScripts: [],
|
||||
componentHierarchy: {},
|
||||
componentConfigs: {},
|
||||
seedData: {
|
||||
GuestbookEntry: [
|
||||
{
|
||||
id: 'entry_1',
|
||||
authorName: 'WebMaster99',
|
||||
authorWebsite: 'http://coolsite.net',
|
||||
message: 'Cool site! Check out mine too!',
|
||||
backgroundColor: '#FF00FF',
|
||||
textColor: '#00FF00',
|
||||
approved: true,
|
||||
createdAt: Date.now() - 86400000
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { PackageContent, PackageManifest } from '../../package-types'
|
||||
|
||||
export const spotifyClonePackage = (): { manifest: PackageManifest; content: PackageContent } => ({
|
||||
manifest: {
|
||||
id: 'spotify-clone',
|
||||
name: 'Music Streaming Platform',
|
||||
version: '1.0.0',
|
||||
description: 'Full music streaming service with playlists, albums, artists, search, and playback controls. Create your own Spotify!',
|
||||
author: 'MetaBuilder Team',
|
||||
category: 'entertainment',
|
||||
icon: '🎵',
|
||||
screenshots: [],
|
||||
tags: ['music', 'streaming', 'audio', 'spotify'],
|
||||
dependencies: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
downloadCount: 1823,
|
||||
rating: 4.6,
|
||||
installed: false,
|
||||
},
|
||||
content: {
|
||||
schemas: [
|
||||
{
|
||||
name: 'Artist',
|
||||
displayName: 'Artist',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'name', type: 'string', label: 'Name', required: true },
|
||||
{ name: 'bio', type: 'text', label: 'Biography', required: false },
|
||||
{ name: 'imageUrl', type: 'string', label: 'Image URL', required: false },
|
||||
{ name: 'genre', type: 'string', label: 'Genre', required: false },
|
||||
{ name: 'verified', type: 'boolean', label: 'Verified', required: true, defaultValue: false },
|
||||
{ name: 'followers', type: 'number', label: 'Followers', required: true, defaultValue: 0 },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Album',
|
||||
displayName: 'Album',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'title', type: 'string', label: 'Title', required: true },
|
||||
{ name: 'artistId', type: 'string', label: 'Artist ID', required: true },
|
||||
{ name: 'coverUrl', type: 'string', label: 'Cover URL', required: false },
|
||||
{ name: 'releaseDate', type: 'number', label: 'Release Date', required: false },
|
||||
{ name: 'genre', type: 'string', label: 'Genre', required: false },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Track',
|
||||
displayName: 'Track',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'title', type: 'string', label: 'Title', required: true },
|
||||
{ name: 'artistId', type: 'string', label: 'Artist ID', required: true },
|
||||
{ name: 'albumId', type: 'string', label: 'Album ID', required: false },
|
||||
{ name: 'audioUrl', type: 'string', label: 'Audio URL', required: true },
|
||||
{ name: 'duration', type: 'number', label: 'Duration (seconds)', required: true },
|
||||
{ name: 'trackNumber', type: 'number', label: 'Track Number', required: false },
|
||||
{ name: 'plays', type: 'number', label: 'Play Count', required: true, defaultValue: 0 },
|
||||
{ name: 'likes', type: 'number', label: 'Likes', required: true, defaultValue: 0 },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'MusicPlaylist',
|
||||
displayName: 'Playlist',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'name', type: 'string', label: 'Name', required: true },
|
||||
{ name: 'description', type: 'text', label: 'Description', required: false },
|
||||
{ name: 'ownerId', type: 'string', label: 'Owner ID', required: true },
|
||||
{ name: 'coverUrl', type: 'string', label: 'Cover URL', required: false },
|
||||
{ name: 'trackIds', type: 'json', label: 'Track IDs', required: true },
|
||||
{ name: 'isPublic', type: 'boolean', label: 'Public', required: true, defaultValue: true },
|
||||
{ name: 'followers', type: 'number', label: 'Followers', required: true, defaultValue: 0 },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
pages: [
|
||||
{
|
||||
id: 'page_music_home',
|
||||
path: '/music',
|
||||
title: 'Music Home',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: false,
|
||||
},
|
||||
{
|
||||
id: 'page_music_search',
|
||||
path: '/search',
|
||||
title: 'Search Music',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: false,
|
||||
},
|
||||
{
|
||||
id: 'page_music_artist',
|
||||
path: '/artist/:id',
|
||||
title: 'Artist',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: false,
|
||||
},
|
||||
{
|
||||
id: 'page_music_album',
|
||||
path: '/album/:id',
|
||||
title: 'Album',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: false,
|
||||
},
|
||||
{
|
||||
id: 'page_music_playlist',
|
||||
path: '/playlist/:id',
|
||||
title: 'Playlist',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: false,
|
||||
},
|
||||
],
|
||||
workflows: [],
|
||||
luaScripts: [],
|
||||
componentHierarchy: {},
|
||||
componentConfigs: {},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { PackageContent, PackageManifest } from '../../package-types'
|
||||
|
||||
export const youtubeClonePackage = (): { manifest: PackageManifest; content: PackageContent } => ({
|
||||
manifest: {
|
||||
id: 'youtube-clone',
|
||||
name: 'Video Platform',
|
||||
version: '1.0.0',
|
||||
description: 'Complete video sharing platform with upload, streaming, comments, likes, subscriptions, and playlists. Build your own YouTube!',
|
||||
author: 'MetaBuilder Team',
|
||||
category: 'entertainment',
|
||||
icon: '🎥',
|
||||
screenshots: [],
|
||||
tags: ['video', 'streaming', 'media', 'youtube'],
|
||||
dependencies: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
downloadCount: 2156,
|
||||
rating: 4.8,
|
||||
installed: false,
|
||||
},
|
||||
content: {
|
||||
schemas: [
|
||||
{
|
||||
name: 'Video',
|
||||
displayName: 'Video',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'title', type: 'string', label: 'Title', required: true },
|
||||
{ name: 'description', type: 'text', label: 'Description', required: false },
|
||||
{ name: 'uploaderId', type: 'string', label: 'Uploader ID', required: true },
|
||||
{ name: 'videoUrl', type: 'string', label: 'Video URL', required: true },
|
||||
{ name: 'thumbnailUrl', type: 'string', label: 'Thumbnail URL', required: false },
|
||||
{ name: 'duration', type: 'number', label: 'Duration (seconds)', required: true },
|
||||
{ name: 'views', type: 'number', label: 'Views', required: true, defaultValue: 0 },
|
||||
{ name: 'likes', type: 'number', label: 'Likes', required: true, defaultValue: 0 },
|
||||
{ name: 'dislikes', type: 'number', label: 'Dislikes', required: true, defaultValue: 0 },
|
||||
{ name: 'category', type: 'string', label: 'Category', required: false },
|
||||
{ name: 'tags', type: 'json', label: 'Tags', required: false },
|
||||
{ name: 'published', type: 'boolean', label: 'Published', required: true, defaultValue: false },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'VideoComment',
|
||||
displayName: 'Video Comment',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'videoId', type: 'string', label: 'Video ID', required: true },
|
||||
{ name: 'userId', type: 'string', label: 'User ID', required: true },
|
||||
{ name: 'content', type: 'text', label: 'Content', required: true },
|
||||
{ name: 'likes', type: 'number', label: 'Likes', required: true, defaultValue: 0 },
|
||||
{ name: 'parentId', type: 'string', label: 'Parent Comment ID', required: false },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Subscription',
|
||||
displayName: 'Subscription',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'subscriberId', type: 'string', label: 'Subscriber ID', required: true },
|
||||
{ name: 'channelId', type: 'string', label: 'Channel ID', required: true },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Playlist',
|
||||
displayName: 'Playlist',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'name', type: 'string', label: 'Name', required: true },
|
||||
{ name: 'description', type: 'text', label: 'Description', required: false },
|
||||
{ name: 'ownerId', type: 'string', label: 'Owner ID', required: true },
|
||||
{ name: 'videoIds', type: 'json', label: 'Video IDs', required: true },
|
||||
{ name: 'isPublic', type: 'boolean', label: 'Public', required: true, defaultValue: true },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
pages: [
|
||||
{
|
||||
id: 'page_video_home',
|
||||
path: '/videos',
|
||||
title: 'Video Home',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: false,
|
||||
},
|
||||
{
|
||||
id: 'page_video_watch',
|
||||
path: '/watch/:id',
|
||||
title: 'Watch Video',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: false,
|
||||
},
|
||||
{
|
||||
id: 'page_video_upload',
|
||||
path: '/upload',
|
||||
title: 'Upload Video',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: true,
|
||||
requiredRole: 'user',
|
||||
},
|
||||
{
|
||||
id: 'page_channel',
|
||||
path: '/channel/:id',
|
||||
title: 'Channel',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: false,
|
||||
},
|
||||
],
|
||||
workflows: [],
|
||||
luaScripts: [],
|
||||
componentHierarchy: {},
|
||||
componentConfigs: {},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { PackageContent, PackageManifest } from '../../package-types'
|
||||
|
||||
export const ecommerceBasicPackage = (): { manifest: PackageManifest; content: PackageContent } => ({
|
||||
manifest: {
|
||||
id: 'ecommerce-basic',
|
||||
name: 'E-Commerce Store',
|
||||
version: '1.0.0',
|
||||
description: 'Complete online store with products, shopping cart, checkout, orders, and inventory management. Start selling online!',
|
||||
author: 'MetaBuilder Team',
|
||||
category: 'ecommerce',
|
||||
icon: '🛒',
|
||||
screenshots: [],
|
||||
tags: ['ecommerce', 'shop', 'store', 'products'],
|
||||
dependencies: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
downloadCount: 2341,
|
||||
rating: 4.7,
|
||||
installed: false,
|
||||
},
|
||||
content: {
|
||||
schemas: [
|
||||
{
|
||||
name: 'Product',
|
||||
displayName: 'Product',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'name', type: 'string', label: 'Name', required: true },
|
||||
{ name: 'description', type: 'text', label: 'Description', required: false },
|
||||
{ name: 'price', type: 'number', label: 'Price', required: true },
|
||||
{ name: 'salePrice', type: 'number', label: 'Sale Price', required: false },
|
||||
{ name: 'imageUrl', type: 'string', label: 'Image URL', required: false },
|
||||
{ name: 'category', type: 'string', label: 'Category', required: false },
|
||||
{ name: 'stock', type: 'number', label: 'Stock Quantity', required: true, defaultValue: 0 },
|
||||
{ name: 'sku', type: 'string', label: 'SKU', required: false },
|
||||
{ name: 'featured', type: 'boolean', label: 'Featured', required: true, defaultValue: false },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Cart',
|
||||
displayName: 'Shopping Cart',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'userId', type: 'string', label: 'User ID', required: true },
|
||||
{ name: 'items', type: 'json', label: 'Items', required: true },
|
||||
{ name: 'totalAmount', type: 'number', label: 'Total Amount', required: true, defaultValue: 0 },
|
||||
{ name: 'updatedAt', type: 'number', label: 'Updated At', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Order',
|
||||
displayName: 'Order',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'userId', type: 'string', label: 'User ID', required: true },
|
||||
{ name: 'items', type: 'json', label: 'Items', required: true },
|
||||
{ name: 'totalAmount', type: 'number', label: 'Total Amount', required: true },
|
||||
{ name: 'status', type: 'string', label: 'Status', required: true },
|
||||
{ name: 'shippingAddress', type: 'json', label: 'Shipping Address', required: true },
|
||||
{ name: 'paymentMethod', type: 'string', label: 'Payment Method', required: false },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
pages: [
|
||||
{
|
||||
id: 'page_shop_home',
|
||||
path: '/shop',
|
||||
title: 'Shop',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: false,
|
||||
},
|
||||
{
|
||||
id: 'page_product_detail',
|
||||
path: '/product/:id',
|
||||
title: 'Product Details',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: false,
|
||||
},
|
||||
{
|
||||
id: 'page_cart',
|
||||
path: '/cart',
|
||||
title: 'Shopping Cart',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: true,
|
||||
requiredRole: 'user',
|
||||
},
|
||||
{
|
||||
id: 'page_checkout',
|
||||
path: '/checkout',
|
||||
title: 'Checkout',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: true,
|
||||
requiredRole: 'user',
|
||||
},
|
||||
],
|
||||
workflows: [],
|
||||
luaScripts: [],
|
||||
componentHierarchy: {},
|
||||
componentConfigs: {},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,509 @@
|
||||
import type { PackageContent, PackageManifest } from '../../package-types'
|
||||
|
||||
export const ircWebchatPackage = (): { manifest: PackageManifest; content: PackageContent } => ({
|
||||
manifest: {
|
||||
id: 'irc-webchat',
|
||||
name: 'IRC-Style Webchat',
|
||||
version: '1.0.0',
|
||||
description: 'Classic IRC-style webchat with channels, commands, online users, and real-time messaging. Perfect for community chat rooms.',
|
||||
author: 'MetaBuilder Team',
|
||||
category: 'social',
|
||||
icon: '💬',
|
||||
screenshots: [],
|
||||
tags: ['chat', 'irc', 'messaging', 'realtime'],
|
||||
dependencies: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
downloadCount: 1543,
|
||||
rating: 4.8,
|
||||
installed: false,
|
||||
},
|
||||
content: {
|
||||
schemas: [
|
||||
{
|
||||
name: 'ChatChannel',
|
||||
displayName: 'Chat Channel',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'name', type: 'string', label: 'Channel Name', required: true },
|
||||
{ name: 'description', type: 'text', label: 'Description', required: false },
|
||||
{ name: 'topic', type: 'string', label: 'Channel Topic', required: false },
|
||||
{ name: 'isPrivate', type: 'boolean', label: 'Private', required: false, defaultValue: false },
|
||||
{ name: 'createdBy', type: 'string', label: 'Created By', required: true },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ChatMessage',
|
||||
displayName: 'Chat Message',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'channelId', type: 'string', label: 'Channel ID', required: true },
|
||||
{ name: 'username', type: 'string', label: 'Username', required: true },
|
||||
{ name: 'userId', type: 'string', label: 'User ID', required: true },
|
||||
{ name: 'message', type: 'text', label: 'Message', required: true },
|
||||
{ name: 'type', type: 'string', label: 'Message Type', required: true },
|
||||
{ name: 'timestamp', type: 'number', label: 'Timestamp', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ChatUser',
|
||||
displayName: 'Chat User',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'channelId', type: 'string', label: 'Channel ID', required: true },
|
||||
{ name: 'username', type: 'string', label: 'Username', required: true },
|
||||
{ name: 'userId', type: 'string', label: 'User ID', required: true },
|
||||
{ name: 'joinedAt', type: 'number', label: 'Joined At', required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
pages: [
|
||||
{
|
||||
id: 'page_chat',
|
||||
path: '/chat',
|
||||
title: 'IRC Webchat',
|
||||
level: 2,
|
||||
componentTree: [
|
||||
{
|
||||
id: 'comp_chat_root',
|
||||
type: 'IRCWebchat',
|
||||
props: {
|
||||
channelName: 'general',
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
requiresAuth: true,
|
||||
requiredRole: 'user',
|
||||
},
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
id: 'workflow_send_message',
|
||||
name: 'Send Chat Message',
|
||||
description: 'Workflow for sending a chat message',
|
||||
nodes: [],
|
||||
edges: [],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'workflow_join_channel',
|
||||
name: 'Join Channel',
|
||||
description: 'Workflow for joining a chat channel',
|
||||
nodes: [],
|
||||
edges: [],
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
luaScripts: [
|
||||
{
|
||||
id: 'lua_irc_send_message',
|
||||
name: 'Send IRC Message',
|
||||
description: 'Sends a message to the chat channel',
|
||||
code: `-- Send IRC Message
|
||||
function sendMessage(channelId, username, userId, message)
|
||||
local msgId = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999)
|
||||
local msg = {
|
||||
id = msgId,
|
||||
channelId = channelId,
|
||||
username = username,
|
||||
userId = userId,
|
||||
message = message,
|
||||
type = "message",
|
||||
timestamp = os.time() * 1000
|
||||
}
|
||||
log("Sending message: " .. message)
|
||||
return msg
|
||||
end
|
||||
|
||||
return sendMessage`,
|
||||
parameters: [
|
||||
{ name: 'channelId', type: 'string' },
|
||||
{ name: 'username', type: 'string' },
|
||||
{ name: 'userId', type: 'string' },
|
||||
{ name: 'message', type: 'string' },
|
||||
],
|
||||
returnType: 'table',
|
||||
},
|
||||
{
|
||||
id: 'lua_irc_handle_command',
|
||||
name: 'Handle IRC Command',
|
||||
description: 'Processes IRC commands like /help, /users, etc',
|
||||
code: `-- Handle IRC Command
|
||||
function handleCommand(command, channelId, username, onlineUsers)
|
||||
local parts = {}
|
||||
for part in string.gmatch(command, "%S+") do
|
||||
table.insert(parts, part)
|
||||
end
|
||||
|
||||
local cmd = parts[1]:lower()
|
||||
local response = {
|
||||
id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
|
||||
username = "System",
|
||||
userId = "system",
|
||||
type = "system",
|
||||
timestamp = os.time() * 1000,
|
||||
channelId = channelId
|
||||
}
|
||||
|
||||
if cmd == "/help" then
|
||||
response.message = "Available commands: /help, /users, /clear, /me <action>"
|
||||
elseif cmd == "/users" then
|
||||
local userCount = #onlineUsers
|
||||
local userList = table.concat(onlineUsers, ", ")
|
||||
response.message = "Online users (" .. userCount .. "): " .. userList
|
||||
elseif cmd == "/clear" then
|
||||
response.message = "CLEAR_MESSAGES"
|
||||
response.type = "command"
|
||||
elseif cmd == "/me" then
|
||||
if #parts > 1 then
|
||||
local action = table.concat(parts, " ", 2)
|
||||
response.message = action
|
||||
response.username = username
|
||||
response.userId = username
|
||||
response.type = "system"
|
||||
else
|
||||
response.message = "Usage: /me <action>"
|
||||
end
|
||||
else
|
||||
response.message = "Unknown command: " .. cmd .. ". Type /help for available commands."
|
||||
end
|
||||
|
||||
return response
|
||||
end
|
||||
|
||||
return handleCommand`,
|
||||
parameters: [
|
||||
{ name: 'command', type: 'string' },
|
||||
{ name: 'channelId', type: 'string' },
|
||||
{ name: 'username', type: 'string' },
|
||||
{ name: 'onlineUsers', type: 'table' },
|
||||
],
|
||||
returnType: 'table',
|
||||
},
|
||||
{
|
||||
id: 'lua_irc_format_time',
|
||||
name: 'Format Timestamp',
|
||||
description: 'Formats a timestamp for display',
|
||||
code: `-- Format Timestamp
|
||||
function formatTime(timestamp)
|
||||
local date = os.date("*t", timestamp / 1000)
|
||||
local hour = date.hour
|
||||
local ampm = "AM"
|
||||
|
||||
if hour >= 12 then
|
||||
ampm = "PM"
|
||||
if hour > 12 then
|
||||
hour = hour - 12
|
||||
end
|
||||
end
|
||||
|
||||
if hour == 0 then
|
||||
hour = 12
|
||||
end
|
||||
|
||||
return string.format("%02d:%02d %s", hour, date.min, ampm)
|
||||
end
|
||||
|
||||
return formatTime`,
|
||||
parameters: [
|
||||
{ name: 'timestamp', type: 'number' },
|
||||
],
|
||||
returnType: 'string',
|
||||
},
|
||||
{
|
||||
id: 'lua_irc_user_join',
|
||||
name: 'User Join Channel',
|
||||
description: 'Handles user joining a channel',
|
||||
code: `-- User Join Channel
|
||||
function userJoin(channelId, username, userId)
|
||||
local joinMsg = {
|
||||
id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
|
||||
channelId = channelId,
|
||||
username = "System",
|
||||
userId = "system",
|
||||
message = username .. " has joined the channel",
|
||||
type = "join",
|
||||
timestamp = os.time() * 1000
|
||||
}
|
||||
|
||||
log(username .. " joined channel " .. channelId)
|
||||
return joinMsg
|
||||
end
|
||||
|
||||
return userJoin`,
|
||||
parameters: [
|
||||
{ name: 'channelId', type: 'string' },
|
||||
{ name: 'username', type: 'string' },
|
||||
{ name: 'userId', type: 'string' },
|
||||
],
|
||||
returnType: 'table',
|
||||
},
|
||||
{
|
||||
id: 'lua_irc_user_leave',
|
||||
name: 'User Leave Channel',
|
||||
description: 'Handles user leaving a channel',
|
||||
code: `-- User Leave Channel
|
||||
function userLeave(channelId, username, userId)
|
||||
local leaveMsg = {
|
||||
id = "msg_" .. tostring(os.time()) .. "_" .. math.random(1000, 9999),
|
||||
channelId = channelId,
|
||||
username = "System",
|
||||
userId = "system",
|
||||
message = username .. " has left the channel",
|
||||
type = "leave",
|
||||
timestamp = os.time() * 1000
|
||||
}
|
||||
|
||||
log(username .. " left channel " .. channelId)
|
||||
return leaveMsg
|
||||
end
|
||||
|
||||
return userLeave`,
|
||||
parameters: [
|
||||
{ name: 'channelId', type: 'string' },
|
||||
{ name: 'username', type: 'string' },
|
||||
{ name: 'userId', type: 'string' },
|
||||
],
|
||||
returnType: 'table',
|
||||
},
|
||||
],
|
||||
componentHierarchy: {
|
||||
page_chat: {
|
||||
id: 'comp_chat_root',
|
||||
type: 'IRCWebchat',
|
||||
props: {},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
componentConfigs: {
|
||||
IRCWebchat: {
|
||||
type: 'IRCWebchat',
|
||||
category: 'social',
|
||||
label: 'IRC Webchat',
|
||||
description: 'IRC-style chat component with channels and commands',
|
||||
icon: '💬',
|
||||
props: [
|
||||
{
|
||||
name: 'channelName',
|
||||
type: 'string',
|
||||
label: 'Channel Name',
|
||||
defaultValue: 'general',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'showSettings',
|
||||
type: 'boolean',
|
||||
label: 'Show Settings',
|
||||
defaultValue: false,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'height',
|
||||
type: 'string',
|
||||
label: 'Height',
|
||||
defaultValue: '600px',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
config: {
|
||||
layout: 'Card',
|
||||
styling: {
|
||||
className: 'h-[600px] flex flex-col',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: 'header',
|
||||
type: 'CardHeader',
|
||||
props: {
|
||||
className: 'border-b border-border pb-3',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: 'title_container',
|
||||
type: 'Flex',
|
||||
props: {
|
||||
className: 'flex items-center justify-between',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: 'title',
|
||||
type: 'CardTitle',
|
||||
props: {
|
||||
className: 'flex items-center gap-2 text-lg',
|
||||
content: '#{channelName}',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
type: 'Flex',
|
||||
props: {
|
||||
className: 'flex items-center gap-2',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: 'user_badge',
|
||||
type: 'Badge',
|
||||
props: {
|
||||
variant: 'secondary',
|
||||
className: 'gap-1.5',
|
||||
icon: 'Users',
|
||||
content: '{onlineUsersCount}',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'settings_button',
|
||||
type: 'Button',
|
||||
props: {
|
||||
size: 'sm',
|
||||
variant: 'ghost',
|
||||
icon: 'Gear',
|
||||
onClick: 'toggleSettings',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
type: 'CardContent',
|
||||
props: {
|
||||
className: 'flex-1 flex flex-col p-0 overflow-hidden',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: 'main_area',
|
||||
type: 'Flex',
|
||||
props: {
|
||||
className: 'flex flex-1 overflow-hidden',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: 'messages_area',
|
||||
type: 'ScrollArea',
|
||||
props: {
|
||||
className: 'flex-1 p-4',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: 'messages_container',
|
||||
type: 'MessageList',
|
||||
props: {
|
||||
className: 'space-y-2 font-mono text-sm',
|
||||
dataSource: 'messages',
|
||||
itemRenderer: 'renderMessage',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sidebar',
|
||||
type: 'Container',
|
||||
props: {
|
||||
className: 'w-48 border-l border-border p-4 bg-muted/20',
|
||||
conditional: 'showSettings',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: 'sidebar_title',
|
||||
type: 'Heading',
|
||||
props: {
|
||||
level: '4',
|
||||
className: 'font-semibold text-sm mb-3',
|
||||
content: 'Online Users',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'users_list',
|
||||
type: 'UserList',
|
||||
props: {
|
||||
className: 'space-y-1.5 text-sm',
|
||||
dataSource: 'onlineUsers',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'input_area',
|
||||
type: 'Container',
|
||||
props: {
|
||||
className: 'border-t border-border p-4',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: 'input_row',
|
||||
type: 'Flex',
|
||||
props: {
|
||||
className: 'flex gap-2',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: 'message_input',
|
||||
type: 'Input',
|
||||
props: {
|
||||
className: 'flex-1 font-mono',
|
||||
placeholder: 'Type a message... (/help for commands)',
|
||||
onKeyPress: 'handleKeyPress',
|
||||
value: '{inputMessage}',
|
||||
onChange: 'updateInputMessage',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'send_button',
|
||||
type: 'Button',
|
||||
props: {
|
||||
size: 'icon',
|
||||
icon: 'PaperPlaneTilt',
|
||||
onClick: 'handleSendMessage',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'help_text',
|
||||
type: 'Text',
|
||||
props: {
|
||||
className: 'text-xs text-muted-foreground mt-2',
|
||||
content: 'Press Enter to send. Type /help for commands.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
seedData: {
|
||||
ChatChannel: [
|
||||
{
|
||||
id: 'channel_general',
|
||||
name: 'general',
|
||||
description: 'General discussion',
|
||||
topic: 'Welcome to the general chat!',
|
||||
isPrivate: false,
|
||||
createdBy: 'system',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'channel_random',
|
||||
name: 'random',
|
||||
description: 'Random conversations',
|
||||
topic: 'Talk about anything here',
|
||||
isPrivate: false,
|
||||
createdBy: 'system',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { PackageContent, PackageManifest } from '../../package-types'
|
||||
|
||||
export const retroGamesPackage = (): { manifest: PackageManifest; content: PackageContent } => ({
|
||||
manifest: {
|
||||
id: 'retro-games',
|
||||
name: 'Retro Games Arcade',
|
||||
version: '1.0.0',
|
||||
description: 'Classic arcade games collection with high scores, leaderboards, and achievements. Includes Snake, Tetris, Pong, and more!',
|
||||
author: 'MetaBuilder Team',
|
||||
category: 'gaming',
|
||||
icon: '🕹️',
|
||||
screenshots: [],
|
||||
tags: ['games', 'arcade', 'retro', 'entertainment'],
|
||||
dependencies: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
downloadCount: 1567,
|
||||
rating: 4.9,
|
||||
installed: false,
|
||||
},
|
||||
content: {
|
||||
schemas: [
|
||||
{
|
||||
name: 'Game',
|
||||
displayName: 'Game',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'name', type: 'string', label: 'Name', required: true },
|
||||
{ name: 'description', type: 'text', label: 'Description', required: false },
|
||||
{ name: 'thumbnailUrl', type: 'string', label: 'Thumbnail URL', required: false },
|
||||
{ name: 'gameType', type: 'string', label: 'Game Type', required: true },
|
||||
{ name: 'difficulty', type: 'string', label: 'Difficulty', required: false },
|
||||
{ name: 'playCount', type: 'number', label: 'Play Count', required: true, defaultValue: 0 },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'HighScore',
|
||||
displayName: 'High Score',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'gameId', type: 'string', label: 'Game ID', required: true },
|
||||
{ name: 'userId', type: 'string', label: 'User ID', required: true },
|
||||
{ name: 'playerName', type: 'string', label: 'Player Name', required: true },
|
||||
{ name: 'score', type: 'number', label: 'Score', required: true },
|
||||
{ name: 'level', type: 'number', label: 'Level Reached', required: false },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Achievement',
|
||||
displayName: 'Achievement',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'name', type: 'string', label: 'Name', required: true },
|
||||
{ name: 'description', type: 'text', label: 'Description', required: false },
|
||||
{ name: 'gameId', type: 'string', label: 'Game ID', required: true },
|
||||
{ name: 'iconUrl', type: 'string', label: 'Icon URL', required: false },
|
||||
{ name: 'requirement', type: 'string', label: 'Requirement', required: true },
|
||||
{ name: 'points', type: 'number', label: 'Points', required: true, defaultValue: 10 },
|
||||
{ name: 'createdAt', type: 'number', label: 'Created At', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'UserAchievement',
|
||||
displayName: 'User Achievement',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', label: 'ID', required: true, primaryKey: true },
|
||||
{ name: 'userId', type: 'string', label: 'User ID', required: true },
|
||||
{ name: 'achievementId', type: 'string', label: 'Achievement ID', required: true },
|
||||
{ name: 'unlockedAt', type: 'number', label: 'Unlocked At', required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
pages: [
|
||||
{
|
||||
id: 'page_arcade_home',
|
||||
path: '/arcade',
|
||||
title: 'Arcade Home',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: false,
|
||||
},
|
||||
{
|
||||
id: 'page_game_play',
|
||||
path: '/arcade/play/:id',
|
||||
title: 'Play Game',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: false,
|
||||
},
|
||||
{
|
||||
id: 'page_leaderboard',
|
||||
path: '/arcade/leaderboard',
|
||||
title: 'Leaderboard',
|
||||
level: 2,
|
||||
componentTree: [],
|
||||
requiresAuth: false,
|
||||
},
|
||||
],
|
||||
workflows: [],
|
||||
luaScripts: [],
|
||||
componentHierarchy: {},
|
||||
componentConfigs: {},
|
||||
seedData: {
|
||||
Game: [
|
||||
{ id: 'game_snake', name: 'Snake', description: 'Classic snake game', gameType: 'snake', difficulty: 'medium', playCount: 0, createdAt: Date.now() },
|
||||
{ id: 'game_tetris', name: 'Tetris', description: 'Block-stacking puzzle', gameType: 'tetris', difficulty: 'medium', playCount: 0, createdAt: Date.now() },
|
||||
{ id: 'game_pong', name: 'Pong', description: 'Classic paddle game', gameType: 'pong', difficulty: 'easy', playCount: 0, createdAt: Date.now() },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -4,6 +4,8 @@ import { PACKAGE_CATALOG } from '../../package-lib/package-catalog'
|
||||
* Get the content of a package by its ID
|
||||
*/
|
||||
export function getPackageContent(packageId: string) {
|
||||
const pkg = PACKAGE_CATALOG[packageId]
|
||||
return pkg ? pkg.content : null
|
||||
const packageEntry = PACKAGE_CATALOG[packageId]
|
||||
const packageData = packageEntry?.()
|
||||
|
||||
return packageData ? packageData.content : null
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { PACKAGE_CATALOG } from '../../package-lib/package-catalog'
|
||||
* Get the manifest of a package by its ID
|
||||
*/
|
||||
export function getPackageManifest(packageId: string) {
|
||||
const pkg = PACKAGE_CATALOG[packageId]
|
||||
return pkg ? pkg.manifest : null
|
||||
const packageEntry = PACKAGE_CATALOG[packageId]
|
||||
const packageData = packageEntry?.()
|
||||
|
||||
return packageData ? packageData.manifest : null
|
||||
}
|
||||
|
||||
@@ -35,8 +35,10 @@ export async function initializePackageSystem(): Promise<void> {
|
||||
|
||||
// Load legacy packages from catalog for backward compatibility
|
||||
Object.values(PACKAGE_CATALOG).forEach((pkg) => {
|
||||
if (pkg.content) {
|
||||
loadPackageComponents(pkg.content)
|
||||
const packageData = pkg()
|
||||
|
||||
if (packageData.content) {
|
||||
loadPackageComponents(packageData.content)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import 'server-only'
|
||||
|
||||
import { PACKAGE_CATALOG } from '@/lib/package-catalog'
|
||||
import type { PackageContent, PackageManifest } from '@/lib/package-types'
|
||||
import { PACKAGE_CATALOG, type PackageCatalogData } from '@/lib/package-catalog'
|
||||
|
||||
export type PackageCatalogEntry = {
|
||||
manifest: PackageManifest
|
||||
content: PackageContent
|
||||
}
|
||||
|
||||
export function getPackageCatalogEntry(packageId: string): PackageCatalogEntry | null {
|
||||
export function getPackageCatalogEntry(packageId: string): PackageCatalogData | null {
|
||||
const entry = PACKAGE_CATALOG[packageId]
|
||||
if (!entry) return null
|
||||
return entry
|
||||
return entry()
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ Automated tools for refactoring large TypeScript and C++ files into modular lamb
|
||||
### 1. Generate Progress Report
|
||||
|
||||
```bash
|
||||
npx tsx tools/refactoring/refactor-to-lambda.ts
|
||||
npx tsx tools/refactoring/cli/refactor-to-lambda.ts
|
||||
```
|
||||
|
||||
This scans the codebase and generates `docs/todo/LAMBDA_REFACTOR_PROGRESS.md` with:
|
||||
@@ -29,10 +29,10 @@ Preview what would happen without modifying files:
|
||||
|
||||
```bash
|
||||
# Preview all high-priority files
|
||||
npx tsx tools/refactoring/orchestrate-refactor.ts --dry-run high
|
||||
npx tsx tools/refactoring/cli/orchestrate-refactor.ts --dry-run high
|
||||
|
||||
# Preview specific number of files
|
||||
npx tsx tools/refactoring/orchestrate-refactor.ts --dry-run high --limit=5
|
||||
npx tsx tools/refactoring/cli/orchestrate-refactor.ts --dry-run high --limit=5
|
||||
|
||||
# Preview a single file
|
||||
npx tsx tools/refactoring/ast-lambda-refactor.ts --dry-run -v frontends/nextjs/src/lib/rendering/page/page-definition-builder.ts
|
||||
@@ -44,13 +44,13 @@ Refactor files in bulk with automatic linting and import fixing:
|
||||
|
||||
```bash
|
||||
# Refactor all high-priority files (recommended start)
|
||||
npx tsx tools/refactoring/orchestrate-refactor.ts high
|
||||
npx tsx tools/refactoring/cli/orchestrate-refactor.ts high
|
||||
|
||||
# Refactor first 10 high-priority files
|
||||
npx tsx tools/refactoring/orchestrate-refactor.ts high --limit=10
|
||||
npx tsx tools/refactoring/cli/orchestrate-refactor.ts high --limit=10
|
||||
|
||||
# Refactor all pending files
|
||||
npx tsx tools/refactoring/orchestrate-refactor.ts all
|
||||
npx tsx tools/refactoring/cli/orchestrate-refactor.ts all
|
||||
```
|
||||
|
||||
The orchestrator will:
|
||||
@@ -67,7 +67,7 @@ The orchestrator will:
|
||||
Scans codebase and generates tracking report.
|
||||
|
||||
```bash
|
||||
npx tsx tools/refactoring/refactor-to-lambda.ts
|
||||
npx tsx tools/refactoring/cli/refactor-to-lambda.ts
|
||||
```
|
||||
|
||||
**Output:** `docs/todo/LAMBDA_REFACTOR_PROGRESS.md`
|
||||
@@ -134,7 +134,7 @@ npx tsx tools/refactoring/bulk-lambda-refactor.ts [options] <file>
|
||||
Refactor both TypeScript and C++ files with automatic language detection.
|
||||
|
||||
```bash
|
||||
npx tsx tools/refactoring/multi-lang-refactor.ts [options] <file>
|
||||
npx tsx tools/refactoring/cli/cli.ts [options] <file>
|
||||
|
||||
# Options:
|
||||
# -d, --dry-run Preview without writing
|
||||
@@ -145,13 +145,13 @@ npx tsx tools/refactoring/multi-lang-refactor.ts [options] <file>
|
||||
**Examples:**
|
||||
```bash
|
||||
# Refactor TypeScript file
|
||||
npx tsx tools/refactoring/multi-lang-refactor.ts --dry-run src/lib/utils.ts
|
||||
npx tsx tools/refactoring/cli/cli.ts --dry-run src/lib/utils.ts
|
||||
|
||||
# Refactor C++ file
|
||||
npx tsx tools/refactoring/multi-lang-refactor.ts --verbose dbal/src/adapter.cpp
|
||||
npx tsx tools/refactoring/cli/cli.ts --verbose dbal/src/adapter.cpp
|
||||
|
||||
# Multiple files
|
||||
npx tsx tools/refactoring/multi-lang-refactor.ts file1.ts file2.cpp
|
||||
npx tsx tools/refactoring/cli/cli.ts file1.ts file2.cpp
|
||||
```
|
||||
|
||||
### 5. `orchestrate-refactor.ts` - Master Orchestrator
|
||||
@@ -159,7 +159,7 @@ npx tsx tools/refactoring/multi-lang-refactor.ts file1.ts file2.cpp
|
||||
Complete automated workflow for bulk refactoring (TypeScript only).
|
||||
|
||||
```bash
|
||||
npx tsx tools/refactoring/orchestrate-refactor.ts [priority] [options]
|
||||
npx tsx tools/refactoring/cli/orchestrate-refactor.ts [priority] [options]
|
||||
|
||||
# Priority: high | medium | low | all
|
||||
# Options:
|
||||
@@ -172,13 +172,13 @@ npx tsx tools/refactoring/orchestrate-refactor.ts [priority] [options]
|
||||
**Examples:**
|
||||
```bash
|
||||
# Dry run for high-priority files
|
||||
npx tsx tools/refactoring/orchestrate-refactor.ts high --dry-run
|
||||
npx tsx tools/refactoring/cli/orchestrate-refactor.ts high --dry-run
|
||||
|
||||
# Refactor 5 high-priority files
|
||||
npx tsx tools/refactoring/orchestrate-refactor.ts high --limit=5
|
||||
npx tsx tools/refactoring/cli/orchestrate-refactor.ts high --limit=5
|
||||
|
||||
# Refactor all medium-priority files, skip tests
|
||||
npx tsx tools/refactoring/orchestrate-refactor.ts medium --skip-test
|
||||
npx tsx tools/refactoring/cli/orchestrate-refactor.ts medium --skip-test
|
||||
```
|
||||
|
||||
## Refactoring Pattern
|
||||
@@ -275,13 +275,13 @@ import { validateEmail } from '@/lib/utils/functions/validate-email'
|
||||
### Phase 1: High-Priority Files (Library & Tools - 20 files)
|
||||
```bash
|
||||
# 1. Generate report
|
||||
npx tsx tools/refactoring/refactor-to-lambda.ts
|
||||
npx tsx tools/refactoring/cli/refactor-to-lambda.ts
|
||||
|
||||
# 2. Dry run to preview
|
||||
npx tsx tools/refactoring/orchestrate-refactor.ts high --dry-run
|
||||
npx tsx tools/refactoring/cli/orchestrate-refactor.ts high --dry-run
|
||||
|
||||
# 3. Refactor in small batches
|
||||
npx tsx tools/refactoring/orchestrate-refactor.ts high --limit=5
|
||||
npx tsx tools/refactoring/cli/orchestrate-refactor.ts high --limit=5
|
||||
|
||||
# 4. Review, test, commit
|
||||
git diff
|
||||
@@ -289,7 +289,7 @@ npm run test:unit
|
||||
git add . && git commit -m "refactor: convert 5 library files to lambda-per-file"
|
||||
|
||||
# 5. Repeat for next batch
|
||||
npx tsx tools/refactoring/orchestrate-refactor.ts high --limit=5
|
||||
npx tsx tools/refactoring/cli/orchestrate-refactor.ts high --limit=5
|
||||
```
|
||||
|
||||
### Phase 2: Medium-Priority Files (DBAL & Components - 68 files)
|
||||
|
||||
118
tools/refactoring/cli/batch-refactor-all.ts
Normal file
118
tools/refactoring/cli/batch-refactor-all.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Batch Refactor All Large Files
|
||||
*
|
||||
* Processes all files from the tracking report in priority order
|
||||
*/
|
||||
|
||||
import { BulkLambdaRefactor } from '../bulk-lambda-refactor'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
|
||||
interface FileToRefactor {
|
||||
path: string
|
||||
lines: number
|
||||
category: string
|
||||
priority: 'high' | 'medium' | 'low'
|
||||
}
|
||||
|
||||
async function loadFilesFromReport(): Promise<FileToRefactor[]> {
|
||||
const reportPath = path.join(process.cwd(), 'docs/todo/LAMBDA_REFACTOR_PROGRESS.md')
|
||||
const content = await fs.readFile(reportPath, 'utf-8')
|
||||
|
||||
const files: FileToRefactor[] = []
|
||||
const lines = content.split('\n')
|
||||
|
||||
let currentPriority: 'high' | 'medium' | 'low' = 'high'
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('### High Priority')) currentPriority = 'high'
|
||||
else if (line.includes('### Medium Priority')) currentPriority = 'medium'
|
||||
else if (line.includes('### Low Priority')) currentPriority = 'low'
|
||||
else if (line.includes('### Skipped')) break
|
||||
|
||||
// Match checklist items: - [ ] `path/to/file.ts` (123 lines)
|
||||
const match = line.match(/- \[ \] `([^`]+)` \((\d+) lines\)/)
|
||||
if (match) {
|
||||
files.push({
|
||||
path: match[1],
|
||||
lines: parseInt(match[2], 10),
|
||||
category: currentPriority,
|
||||
priority: currentPriority,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
const dryRun = args.includes('--dry-run') || args.includes('-d')
|
||||
const verbose = args.includes('--verbose') || args.includes('-v')
|
||||
const priorityFilter = args.find(a => ['high', 'medium', 'low', 'all'].includes(a)) || 'high'
|
||||
const limit = parseInt(args.find(a => a.startsWith('--limit='))?.split('=')[1] || '999', 10)
|
||||
|
||||
console.log('📋 Loading files from tracking report...')
|
||||
const allFiles = await loadFilesFromReport()
|
||||
|
||||
let filesToProcess = allFiles
|
||||
if (priorityFilter !== 'all') {
|
||||
filesToProcess = allFiles.filter(f => f.priority === priorityFilter)
|
||||
}
|
||||
|
||||
filesToProcess = filesToProcess.slice(0, limit)
|
||||
|
||||
console.log(`\n📊 Plan:`)
|
||||
console.log(` Priority filter: ${priorityFilter}`)
|
||||
console.log(` Files to process: ${filesToProcess.length}`)
|
||||
console.log(` Mode: ${dryRun ? 'DRY RUN (preview only)' : 'LIVE (will modify files)'}`)
|
||||
|
||||
if (filesToProcess.length === 0) {
|
||||
console.log('\n⚠️ No files to process')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Show what will be processed
|
||||
console.log(`\n📝 Files queued:`)
|
||||
for (let i = 0; i < Math.min(10, filesToProcess.length); i++) {
|
||||
console.log(` ${i + 1}. ${filesToProcess[i].path} (${filesToProcess[i].lines} lines)`)
|
||||
}
|
||||
if (filesToProcess.length > 10) {
|
||||
console.log(` ... and ${filesToProcess.length - 10} more`)
|
||||
}
|
||||
|
||||
// Confirmation for live mode
|
||||
if (!dryRun) {
|
||||
console.log(`\n⚠️ WARNING: This will modify ${filesToProcess.length} files!`)
|
||||
console.log(` Press Ctrl+C to cancel, or wait 3 seconds to continue...`)
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
}
|
||||
|
||||
console.log('\n🚀 Starting refactoring...\n')
|
||||
|
||||
const refactor = new BulkLambdaRefactor({ dryRun, verbose })
|
||||
const filePaths = filesToProcess.map(f => f.path)
|
||||
|
||||
const results = await refactor.bulkRefactor(filePaths)
|
||||
|
||||
// Save results
|
||||
const resultsPath = path.join(process.cwd(), 'docs/todo/REFACTOR_RESULTS.json')
|
||||
await fs.writeFile(resultsPath, JSON.stringify(results, null, 2), 'utf-8')
|
||||
console.log(`\n💾 Results saved to: ${resultsPath}`)
|
||||
|
||||
// Update progress report
|
||||
console.log('\n📝 Updating progress report...')
|
||||
// TODO: Mark completed files in the report
|
||||
|
||||
console.log('\n✅ Batch refactoring complete!')
|
||||
console.log('\nNext steps:')
|
||||
console.log(' 1. Run: npm run lint:fix')
|
||||
console.log(' 2. Run: npm run typecheck')
|
||||
console.log(' 3. Run: npm run test:unit')
|
||||
console.log(' 4. Review changes and commit')
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(console.error)
|
||||
}
|
||||
43
tools/refactoring/cli/cli.ts
Normal file
43
tools/refactoring/cli/cli.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env tsx
|
||||
import { MultiLanguageLambdaRefactor } from '../multi-lang-refactor'
|
||||
|
||||
function printHelp() {
|
||||
console.log('Multi-Language Lambda Refactoring Tool\n')
|
||||
console.log('Supports: TypeScript (.ts, .tsx) and C++ (.cpp, .hpp, .cc, .h)\n')
|
||||
console.log('Usage: tsx tools/refactoring/cli/cli.ts [options] <file>')
|
||||
console.log('\nOptions:')
|
||||
console.log(' -d, --dry-run Preview without writing')
|
||||
console.log(' -v, --verbose Verbose output')
|
||||
console.log(' -h, --help Show help')
|
||||
console.log('\nExamples:')
|
||||
console.log(' tsx tools/refactoring/cli/cli.ts --dry-run src/utils.ts')
|
||||
console.log(' tsx tools/refactoring/cli/cli.ts --verbose dbal/src/adapter.cpp')
|
||||
}
|
||||
|
||||
export async function handler(argv: string[] = process.argv.slice(2)) {
|
||||
if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) {
|
||||
printHelp()
|
||||
return { status: 'help' }
|
||||
}
|
||||
|
||||
const dryRun = argv.includes('--dry-run') || argv.includes('-d')
|
||||
const verbose = argv.includes('--verbose') || argv.includes('-v')
|
||||
const files = argv.filter(a => !a.startsWith('-'))
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error('Error: Please provide file(s) to refactor')
|
||||
}
|
||||
|
||||
const refactor = new MultiLanguageLambdaRefactor({ dryRun, verbose })
|
||||
await refactor.bulkRefactor(files)
|
||||
|
||||
console.log('\n✨ Done!')
|
||||
return { status: 'ok' }
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
handler().catch(error => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
249
tools/refactoring/cli/orchestrate-refactor.ts
Normal file
249
tools/refactoring/cli/orchestrate-refactor.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Master Refactoring Orchestrator
|
||||
*
|
||||
* Orchestrates the complete lambda-per-file refactoring process:
|
||||
* 1. Loads files from tracking report
|
||||
* 2. Refactors in priority order
|
||||
* 3. Runs linter and fixes imports
|
||||
* 4. Runs type checking
|
||||
* 5. Updates progress report
|
||||
*/
|
||||
|
||||
import { ASTLambdaRefactor } from '../ast-lambda-refactor'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
interface FileToProcess {
|
||||
path: string
|
||||
lines: number
|
||||
priority: 'high' | 'medium' | 'low'
|
||||
status: 'pending' | 'completed' | 'failed' | 'skipped'
|
||||
error?: string
|
||||
}
|
||||
|
||||
async function loadFilesFromReport(): Promise<FileToProcess[]> {
|
||||
const reportPath = path.join(process.cwd(), 'docs/todo/LAMBDA_REFACTOR_PROGRESS.md')
|
||||
const content = await fs.readFile(reportPath, 'utf-8')
|
||||
|
||||
const files: FileToProcess[] = []
|
||||
const lines = content.split('\n')
|
||||
|
||||
let currentPriority: 'high' | 'medium' | 'low' = 'high'
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('### High Priority')) currentPriority = 'high'
|
||||
else if (line.includes('### Medium Priority')) currentPriority = 'medium'
|
||||
else if (line.includes('### Low Priority')) currentPriority = 'low'
|
||||
else if (line.includes('### Skipped')) break
|
||||
|
||||
const match = line.match(/- \[ \] `([^`]+)` \((\d+) lines\)/)
|
||||
if (match) {
|
||||
files.push({
|
||||
path: match[1],
|
||||
lines: parseInt(match[2], 10),
|
||||
priority: currentPriority,
|
||||
status: 'pending',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
async function runCommand(cmd: string, cwd: string = process.cwd()): Promise<{ stdout: string; stderr: string }> {
|
||||
try {
|
||||
return await execAsync(cmd, { cwd, maxBuffer: 10 * 1024 * 1024 })
|
||||
} catch (error: any) {
|
||||
return { stdout: error.stdout || '', stderr: error.stderr || error.message }
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
const dryRun = args.includes('--dry-run') || args.includes('-d')
|
||||
const priorityFilter = args.find(a => ['high', 'medium', 'low', 'all'].includes(a)) || 'all'
|
||||
const limitArg = args.find(a => a.startsWith('--limit='))
|
||||
const limit = limitArg ? parseInt(limitArg.split('=')[1], 10) : 999
|
||||
const skipLint = args.includes('--skip-lint')
|
||||
const skipTest = args.includes('--skip-test')
|
||||
|
||||
console.log('🚀 Lambda-per-File Refactoring Orchestrator\n')
|
||||
|
||||
// Load files
|
||||
console.log('📋 Loading files from tracking report...')
|
||||
let files = await loadFilesFromReport()
|
||||
|
||||
if (priorityFilter !== 'all') {
|
||||
files = files.filter(f => f.priority === priorityFilter)
|
||||
}
|
||||
|
||||
files = files.slice(0, limit)
|
||||
|
||||
console.log(`\n📊 Configuration:`)
|
||||
console.log(` Priority: ${priorityFilter}`)
|
||||
console.log(` Limit: ${limit}`)
|
||||
console.log(` Files to process: ${files.length}`)
|
||||
console.log(` Mode: ${dryRun ? '🔍 DRY RUN (preview only)' : '⚡ LIVE (will modify files)'}`)
|
||||
console.log(` Skip lint: ${skipLint}`)
|
||||
console.log(` Skip tests: ${skipTest}`)
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log('\n⚠️ No files to process')
|
||||
return
|
||||
}
|
||||
|
||||
// Show preview
|
||||
console.log(`\n📝 Files queued:`)
|
||||
const preview = files.slice(0, 10)
|
||||
preview.forEach((f, i) => {
|
||||
console.log(` ${i + 1}. [${f.priority.toUpperCase()}] ${f.path} (${f.lines} lines)`)
|
||||
})
|
||||
if (files.length > 10) {
|
||||
console.log(` ... and ${files.length - 10} more`)
|
||||
}
|
||||
|
||||
// Safety confirmation for live mode
|
||||
if (!dryRun) {
|
||||
console.log(`\n⚠️ WARNING: This will refactor ${files.length} files!`)
|
||||
console.log(' Press Ctrl+C to cancel, or wait 5 seconds to continue...')
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60))
|
||||
console.log('PHASE 1: REFACTORING')
|
||||
console.log('='.repeat(60) + '\n')
|
||||
|
||||
// Refactor files
|
||||
const refactor = new ASTLambdaRefactor({ dryRun, verbose: true })
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
console.log(`\n[${i + 1}/${files.length}] Processing: ${file.path}`)
|
||||
|
||||
try {
|
||||
await refactor.refactorFile(file.path)
|
||||
file.status = 'completed'
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
if (errorMsg.includes('skipping') || errorMsg.includes('No functions')) {
|
||||
file.status = 'skipped'
|
||||
file.error = errorMsg
|
||||
} else {
|
||||
file.status = 'failed'
|
||||
file.error = errorMsg
|
||||
console.error(` ❌ Error: ${errorMsg}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay to avoid overwhelming system
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
// Summary
|
||||
const summary = {
|
||||
total: files.length,
|
||||
completed: files.filter(f => f.status === 'completed').length,
|
||||
skipped: files.filter(f => f.status === 'skipped').length,
|
||||
failed: files.filter(f => f.status === 'failed').length,
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60))
|
||||
console.log('REFACTORING SUMMARY')
|
||||
console.log('='.repeat(60))
|
||||
console.log(` ✅ Completed: ${summary.completed}`)
|
||||
console.log(` ⏭️ Skipped: ${summary.skipped}`)
|
||||
console.log(` ❌ Failed: ${summary.failed}`)
|
||||
console.log(` 📊 Total: ${summary.total}`)
|
||||
|
||||
if (!dryRun && summary.completed > 0) {
|
||||
// Phase 2: Linting
|
||||
if (!skipLint) {
|
||||
console.log('\n' + '='.repeat(60))
|
||||
console.log('PHASE 2: LINTING & IMPORT FIXING')
|
||||
console.log('='.repeat(60) + '\n')
|
||||
|
||||
console.log('🔧 Running ESLint with --fix...')
|
||||
const lintResult = await runCommand('npm run lint:fix')
|
||||
console.log(lintResult.stdout)
|
||||
if (lintResult.stderr && !lintResult.stderr.includes('warning')) {
|
||||
console.log('⚠️ Lint stderr:', lintResult.stderr)
|
||||
}
|
||||
console.log(' ✅ Linting complete')
|
||||
}
|
||||
|
||||
// Phase 3: Type checking
|
||||
console.log('\n' + '='.repeat(60))
|
||||
console.log('PHASE 3: TYPE CHECKING')
|
||||
console.log('='.repeat(60) + '\n')
|
||||
|
||||
console.log('🔍 Running TypeScript compiler check...')
|
||||
const typecheckResult = await runCommand('npm run typecheck')
|
||||
|
||||
if (typecheckResult.stderr.includes('error TS')) {
|
||||
console.log('❌ Type errors detected:')
|
||||
console.log(typecheckResult.stderr.split('\n').slice(0, 20).join('\n'))
|
||||
console.log('\n⚠️ Please fix type errors before committing')
|
||||
} else {
|
||||
console.log(' ✅ No type errors')
|
||||
}
|
||||
|
||||
// Phase 4: Testing
|
||||
if (!skipTest) {
|
||||
console.log('\n' + '='.repeat(60))
|
||||
console.log('PHASE 4: TESTING')
|
||||
console.log('='.repeat(60) + '\n')
|
||||
|
||||
console.log('🧪 Running unit tests...')
|
||||
const testResult = await runCommand('npm run test:unit -- --run')
|
||||
|
||||
if (testResult.stderr.includes('FAIL') || testResult.stdout.includes('FAIL')) {
|
||||
console.log('❌ Some tests failed')
|
||||
console.log(testResult.stdout.split('\n').slice(-30).join('\n'))
|
||||
} else {
|
||||
console.log(' ✅ All tests passed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save detailed results
|
||||
const resultsPath = path.join(process.cwd(), 'docs/todo/REFACTOR_RESULTS.json')
|
||||
await fs.writeFile(resultsPath, JSON.stringify(files, null, 2), 'utf-8')
|
||||
console.log(`\n💾 Detailed results saved: ${resultsPath}`)
|
||||
|
||||
// Final instructions
|
||||
console.log('\n' + '='.repeat(60))
|
||||
console.log('✨ REFACTORING COMPLETE!')
|
||||
console.log('='.repeat(60))
|
||||
|
||||
if (dryRun) {
|
||||
console.log('\n📌 This was a DRY RUN. No files were modified.')
|
||||
console.log(' Run without --dry-run to apply changes.')
|
||||
} else {
|
||||
console.log('\n📌 Next Steps:')
|
||||
console.log(' 1. Review the changes: git diff')
|
||||
console.log(' 2. Fix any type errors if needed')
|
||||
console.log(' 3. Run tests: npm run test:unit')
|
||||
console.log(' 4. Commit: git add . && git commit -m "Refactor to lambda-per-file structure"')
|
||||
}
|
||||
|
||||
console.log(`\n📊 Final Stats:`)
|
||||
console.log(` Files refactored: ${summary.completed}`)
|
||||
console.log(` Files skipped: ${summary.skipped}`)
|
||||
console.log(` Files failed: ${summary.failed}`)
|
||||
|
||||
if (summary.failed > 0) {
|
||||
console.log(`\n❌ Failed files:`)
|
||||
files.filter(f => f.status === 'failed').forEach(f => {
|
||||
console.log(` - ${f.path}: ${f.error}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(console.error)
|
||||
}
|
||||
243
tools/refactoring/cli/refactor-to-lambda.ts
Normal file
243
tools/refactoring/cli/refactor-to-lambda.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env ts-node
|
||||
/**
|
||||
* Refactor large TypeScript files into lambda-per-file structure
|
||||
*
|
||||
* This tool helps identify files exceeding 150 lines and tracks refactoring progress.
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
interface FileInfo {
|
||||
path: string
|
||||
lines: number
|
||||
category: 'component' | 'library' | 'test' | 'tool' | 'dbal' | 'type' | 'other'
|
||||
priority: number
|
||||
status: 'pending' | 'in-progress' | 'completed' | 'skipped'
|
||||
reason?: string
|
||||
}
|
||||
|
||||
async function countLines(filePath: string): Promise<number> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
return content.split('\n').length
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function categorizeFile(filePath: string): FileInfo['category'] {
|
||||
if (filePath.includes('.test.')) return 'test'
|
||||
if (filePath.endsWith('.tsx')) return 'component'
|
||||
if (filePath.includes('/tools/')) return 'tool'
|
||||
if (filePath.includes('/dbal/')) return 'dbal'
|
||||
if (filePath.includes('/types/') || filePath.endsWith('.d.ts')) return 'type'
|
||||
if (filePath.includes('/lib/') && filePath.endsWith('.ts')) return 'library'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function calculatePriority(file: FileInfo): number {
|
||||
// Higher priority for library files (easiest to refactor)
|
||||
// Lower priority for components (need more complex refactoring)
|
||||
// Skip tests and types
|
||||
const categoryPriority = {
|
||||
library: 10,
|
||||
tool: 8,
|
||||
dbal: 6,
|
||||
component: 4,
|
||||
test: 0, // Skip
|
||||
type: 0, // Skip
|
||||
other: 2,
|
||||
}
|
||||
|
||||
const base = categoryPriority[file.category]
|
||||
|
||||
// Prioritize moderately large files over extremely large ones
|
||||
// (easier to refactor step-by-step)
|
||||
if (file.lines > 1000) return base - 3
|
||||
if (file.lines > 500) return base - 1
|
||||
if (file.lines > 300) return base
|
||||
return base + 1
|
||||
}
|
||||
|
||||
async function findLargeFiles(rootDir: string, minLines: number = 150): Promise<FileInfo[]> {
|
||||
const { stdout } = await execAsync(
|
||||
`find ${rootDir} \\( -name "*.ts" -o -name "*.tsx" \\) ` +
|
||||
`-not -path "*/node_modules/*" ` +
|
||||
`-not -path "*/.next/*" ` +
|
||||
`-not -path "*/dist/*" ` +
|
||||
`-not -path "*/build/*" ` +
|
||||
`-exec sh -c 'lines=$(wc -l < "$1"); if [ "$lines" -gt ${minLines} ]; then echo "$lines $1"; fi' _ {} \\;`
|
||||
)
|
||||
|
||||
const files: FileInfo[] = []
|
||||
for (const line of stdout.trim().split('\n').filter(Boolean)) {
|
||||
const [linesStr, filePath] = line.trim().split(' ', 2)
|
||||
const lines = parseInt(linesStr, 10)
|
||||
const category = categorizeFile(filePath)
|
||||
const fileInfo: FileInfo = {
|
||||
path: filePath.replace(rootDir + '/', ''),
|
||||
lines,
|
||||
category,
|
||||
priority: 0,
|
||||
status: category === 'test' || category === 'type' ? 'skipped' : 'pending',
|
||||
reason: category === 'test' ? 'Test files can remain large for comprehensive coverage' :
|
||||
category === 'type' ? 'Type definition files are typically large' : undefined
|
||||
}
|
||||
fileInfo.priority = calculatePriority(fileInfo)
|
||||
files.push(fileInfo)
|
||||
}
|
||||
|
||||
return files.sort((a, b) => b.priority - a.priority || b.lines - a.lines)
|
||||
}
|
||||
|
||||
async function generateReport(files: FileInfo[]): Promise<string> {
|
||||
const total = files.length
|
||||
const byCategory = files.reduce((acc, f) => {
|
||||
acc[f.category] = (acc[f.category] || 0) + 1
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
|
||||
const byStatus = files.reduce((acc, f) => {
|
||||
acc[f.status] = (acc[f.status] || 0) + 1
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
|
||||
let report = '# Lambda-per-File Refactoring Progress\n\n'
|
||||
report += `**Generated:** ${new Date().toISOString()}\n\n`
|
||||
report += `## Summary\n\n`
|
||||
report += `- **Total files > 150 lines:** ${total}\n`
|
||||
report += `- **Pending:** ${byStatus.pending || 0}\n`
|
||||
report += `- **In Progress:** ${byStatus['in-progress'] || 0}\n`
|
||||
report += `- **Completed:** ${byStatus.completed || 0}\n`
|
||||
report += `- **Skipped:** ${byStatus.skipped || 0}\n\n`
|
||||
|
||||
report += `## By Category\n\n`
|
||||
for (const [category, count] of Object.entries(byCategory).sort((a, b) => b[1] - a[1])) {
|
||||
report += `- **${category}:** ${count}\n`
|
||||
}
|
||||
|
||||
report += `\n## Refactoring Queue\n\n`
|
||||
report += `Files are prioritized by ease of refactoring and impact.\n\n`
|
||||
|
||||
// Group by priority
|
||||
const highPriority = files.filter(f => f.priority >= 8 && f.status === 'pending')
|
||||
const medPriority = files.filter(f => f.priority >= 4 && f.priority < 8 && f.status === 'pending')
|
||||
const lowPriority = files.filter(f => f.priority < 4 && f.status === 'pending')
|
||||
|
||||
if (highPriority.length > 0) {
|
||||
report += `### High Priority (${highPriority.length} files)\n\n`
|
||||
report += `Library and tool files - easiest to refactor\n\n`
|
||||
for (const file of highPriority.slice(0, 20)) {
|
||||
report += `- [ ] \`${file.path}\` (${file.lines} lines)\n`
|
||||
}
|
||||
if (highPriority.length > 20) {
|
||||
report += `- ... and ${highPriority.length - 20} more\n`
|
||||
}
|
||||
report += `\n`
|
||||
}
|
||||
|
||||
if (medPriority.length > 0) {
|
||||
report += `### Medium Priority (${medPriority.length} files)\n\n`
|
||||
report += `DBAL and component files - moderate complexity\n\n`
|
||||
for (const file of medPriority.slice(0, 20)) {
|
||||
report += `- [ ] \`${file.path}\` (${file.lines} lines)\n`
|
||||
}
|
||||
if (medPriority.length > 20) {
|
||||
report += `- ... and ${medPriority.length - 20} more\n`
|
||||
}
|
||||
report += `\n`
|
||||
}
|
||||
|
||||
if (lowPriority.length > 0) {
|
||||
report += `### Low Priority (${lowPriority.length} files)\n\n`
|
||||
for (const file of lowPriority.slice(0, 20)) {
|
||||
report += `- [ ] \`${file.path}\` (${file.lines} lines)\n`
|
||||
}
|
||||
if (lowPriority.length > 20) {
|
||||
report += `- ... and ${lowPriority.length - 20} more\n`
|
||||
}
|
||||
report += `\n`
|
||||
}
|
||||
|
||||
// Skipped files
|
||||
const skipped = files.filter(f => f.status === 'skipped')
|
||||
if (skipped.length > 0) {
|
||||
report += `### Skipped Files (${skipped.length})\n\n`
|
||||
report += `These files do not need refactoring:\n\n`
|
||||
for (const file of skipped.slice(0, 10)) {
|
||||
report += `- \`${file.path}\` (${file.lines} lines) - ${file.reason}\n`
|
||||
}
|
||||
if (skipped.length > 10) {
|
||||
report += `- ... and ${skipped.length - 10} more\n`
|
||||
}
|
||||
report += `\n`
|
||||
}
|
||||
|
||||
report += `## Refactoring Patterns\n\n`
|
||||
report += `### For Library Files\n`
|
||||
report += `1. Create a \`functions/\` subdirectory\n`
|
||||
report += `2. Extract each function to its own file\n`
|
||||
report += `3. Create a class wrapper (like SchemaUtils)\n`
|
||||
report += `4. Update main file to re-export\n`
|
||||
report += `5. Verify tests still pass\n\n`
|
||||
|
||||
report += `### For Components\n`
|
||||
report += `1. Extract hooks into separate files\n`
|
||||
report += `2. Extract sub-components\n`
|
||||
report += `3. Extract utility functions\n`
|
||||
report += `4. Keep main component < 150 lines\n\n`
|
||||
|
||||
report += `### For DBAL Files\n`
|
||||
report += `1. Split adapters by operation type\n`
|
||||
report += `2. Extract provider implementations\n`
|
||||
report += `3. Keep interfaces separate from implementations\n\n`
|
||||
|
||||
report += `## Example: SchemaUtils Pattern\n\n`
|
||||
report += `The \`frontends/nextjs/src/lib/schema/\` directory demonstrates the lambda-per-file pattern:\n\n`
|
||||
report += `\`\`\`\n`
|
||||
report += `schema/\n`
|
||||
report += `├── functions/\n`
|
||||
report += `│ ├── field/\n`
|
||||
report += `│ │ ├── get-field-label.ts\n`
|
||||
report += `│ │ ├── validate-field.ts\n`
|
||||
report += `│ │ └── ...\n`
|
||||
report += `│ ├── model/\n`
|
||||
report += `│ │ ├── find-model.ts\n`
|
||||
report += `│ │ └── ...\n`
|
||||
report += `│ └── index.ts (re-exports all)\n`
|
||||
report += `├── SchemaUtils.ts (class wrapper)\n`
|
||||
report += `└── schema-utils.ts (backward compat re-exports)\n`
|
||||
report += `\`\`\`\n\n`
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const rootDir = process.cwd()
|
||||
console.log('Scanning for TypeScript files exceeding 150 lines...')
|
||||
|
||||
const files = await findLargeFiles(rootDir, 150)
|
||||
console.log(`Found ${files.length} files`)
|
||||
|
||||
const report = await generateReport(files)
|
||||
|
||||
const outputPath = path.join(rootDir, 'docs', 'todo', 'LAMBDA_REFACTOR_PROGRESS.md')
|
||||
await fs.writeFile(outputPath, report, 'utf-8')
|
||||
|
||||
console.log(`Report generated: ${outputPath}`)
|
||||
console.log(`\nSummary:`)
|
||||
console.log(`- Total files: ${files.length}`)
|
||||
console.log(`- Pending refactor: ${files.filter(f => f.status === 'pending').length}`)
|
||||
console.log(`- Skipped: ${files.filter(f => f.status === 'skipped').length}`)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(console.error)
|
||||
}
|
||||
|
||||
export { findLargeFiles, generateReport }
|
||||
@@ -79,7 +79,7 @@ class ErrorAsTodoRefactor {
|
||||
category: 'parse_error',
|
||||
severity: 'high',
|
||||
message: 'Could not load progress report - run refactor-to-lambda.ts first',
|
||||
suggestion: 'npx tsx tools/refactoring/refactor-to-lambda.ts'
|
||||
suggestion: 'npx tsx tools/refactoring/cli/refactor-to-lambda.ts'
|
||||
})
|
||||
return []
|
||||
}
|
||||
|
||||
209
tools/refactoring/languages/cpp-refactor.ts
Normal file
209
tools/refactoring/languages/cpp-refactor.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { DependencyInfo, FunctionInfo, RefactorResult } from './types'
|
||||
|
||||
type ModuleContext = {
|
||||
dir: string
|
||||
basename: string
|
||||
functions: FunctionInfo[]
|
||||
functionsDir: string
|
||||
dependencies: DependencyInfo
|
||||
result: RefactorResult
|
||||
}
|
||||
|
||||
export class CppLambdaRefactor {
|
||||
constructor(private readonly options: { dryRun: boolean; log: (message: string) => void }) {}
|
||||
|
||||
getFunctionExtension() {
|
||||
return '.cpp'
|
||||
}
|
||||
|
||||
async extractFunctions(filePath: string): Promise<FunctionInfo[]> {
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const lines = content.split('\n')
|
||||
const functions: FunctionInfo[] = []
|
||||
|
||||
const functionRegex = /^([a-zA-Z_][a-zA-Z0-9_:<>*&\s]*?)\s+([a-zA-Z_][a-zA-Z0-9_:]*)\s*(\([^)]*\))\s*(const)?\s*(noexcept)?\s*\{/
|
||||
|
||||
let i = 0
|
||||
let currentNamespace = ''
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
const namespaceMatch = line.match(/^namespace\s+([a-zA-Z0-9_]+)/)
|
||||
if (namespaceMatch) {
|
||||
currentNamespace = namespaceMatch[1]
|
||||
}
|
||||
|
||||
const funcMatch = line.match(functionRegex)
|
||||
|
||||
if (funcMatch) {
|
||||
const returnType = funcMatch[1].trim()
|
||||
const fullName = funcMatch[2]
|
||||
const params = funcMatch[3]
|
||||
const isConst = !!funcMatch[4]
|
||||
|
||||
const nameParts = fullName.split('::')
|
||||
const name = nameParts[nameParts.length - 1]
|
||||
const className = nameParts.length > 1 ? nameParts[0] : undefined
|
||||
const isMethod = !!className
|
||||
|
||||
const comments: string[] = []
|
||||
let commentLine = i - 1
|
||||
while (commentLine >= 0 && (lines[commentLine].trim().startsWith('//') ||
|
||||
lines[commentLine].trim().startsWith('/*') ||
|
||||
lines[commentLine].trim().startsWith('*'))) {
|
||||
comments.unshift(lines[commentLine])
|
||||
commentLine--
|
||||
}
|
||||
|
||||
let braceCount = 1
|
||||
let bodyLines: string[] = [line]
|
||||
let j = i + 1
|
||||
|
||||
while (j < lines.length && braceCount > 0) {
|
||||
bodyLines.push(lines[j])
|
||||
for (const char of lines[j]) {
|
||||
if (char === '{') braceCount++
|
||||
if (char === '}') braceCount--
|
||||
if (braceCount === 0) break
|
||||
}
|
||||
j++
|
||||
}
|
||||
|
||||
functions.push({
|
||||
name,
|
||||
isAsync: false,
|
||||
isExported: true,
|
||||
params,
|
||||
returnType,
|
||||
body: bodyLines.join('\n'),
|
||||
startLine: i,
|
||||
endLine: j - 1,
|
||||
comments,
|
||||
isMethod,
|
||||
isStatic: false,
|
||||
isConst,
|
||||
namespace: currentNamespace || undefined,
|
||||
className,
|
||||
})
|
||||
|
||||
i = j
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return functions
|
||||
}
|
||||
|
||||
async extractDependencies(filePath: string): Promise<DependencyInfo> {
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const lines = content.split('\n')
|
||||
|
||||
const imports: string[] = []
|
||||
const types: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (trimmed.startsWith('#include')) {
|
||||
imports.push(line)
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('struct ') || trimmed.startsWith('class ') ||
|
||||
trimmed.startsWith('using ') || trimmed.startsWith('typedef ')) {
|
||||
types.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
return { imports, types }
|
||||
}
|
||||
|
||||
generateFunctionFile(func: FunctionInfo, includes: string[]): string {
|
||||
let content = ''
|
||||
|
||||
if (includes.length > 0) {
|
||||
content += includes.join('\n') + '\n\n'
|
||||
}
|
||||
|
||||
if (func.namespace) {
|
||||
content += `namespace ${func.namespace} {\n\n`
|
||||
}
|
||||
|
||||
if (func.comments.length > 0) {
|
||||
content += func.comments.join('\n') + '\n'
|
||||
}
|
||||
|
||||
const constKeyword = func.isConst ? ' const' : ''
|
||||
content += `${func.returnType} ${func.name}${func.params}${constKeyword} {\n`
|
||||
|
||||
const bodyLines = func.body.split('\n')
|
||||
const actualBody = bodyLines.slice(1, -1).join('\n')
|
||||
|
||||
content += actualBody + '\n'
|
||||
content += '}\n'
|
||||
|
||||
if (func.namespace) {
|
||||
content += `\n} // namespace ${func.namespace}\n`
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
async generateModule(context: ModuleContext) {
|
||||
const { dir, basename, functions, dependencies, result } = context
|
||||
const headerFilePath = path.join(dir, basename, `${basename}.hpp`)
|
||||
const headerContent = this.generateHeaderFile(functions, dependencies.imports, basename)
|
||||
|
||||
if (!this.options.dryRun) {
|
||||
await fs.writeFile(headerFilePath, headerContent, 'utf-8')
|
||||
}
|
||||
|
||||
result.newFiles.push(headerFilePath)
|
||||
this.options.log(` ✓ ${basename}.hpp (header)`)
|
||||
|
||||
const includeContent = `// This file has been refactored into modular functions\n` +
|
||||
`#include "${basename}/${basename}.hpp"\n`
|
||||
|
||||
if (!this.options.dryRun) {
|
||||
await fs.writeFile(path.join(dir, `${basename}.cpp`), includeContent, 'utf-8')
|
||||
}
|
||||
|
||||
this.options.log(` ✓ Updated ${basename}.cpp to include header`)
|
||||
}
|
||||
|
||||
private generateHeaderFile(functions: FunctionInfo[], includes: string[], basename: string): string {
|
||||
const guard = `${basename.toUpperCase()}_HPP_INCLUDED`
|
||||
let content = ''
|
||||
|
||||
content += `#ifndef ${guard}\n`
|
||||
content += `#define ${guard}\n\n`
|
||||
|
||||
if (includes.length > 0) {
|
||||
content += includes.join('\n') + '\n\n'
|
||||
}
|
||||
|
||||
const namespace = functions[0]?.namespace
|
||||
if (namespace) {
|
||||
content += `namespace ${namespace} {\n\n`
|
||||
}
|
||||
|
||||
for (const func of functions) {
|
||||
if (func.comments.length > 0) {
|
||||
content += func.comments.join('\n') + '\n'
|
||||
}
|
||||
const constKeyword = func.isConst ? ' const' : ''
|
||||
content += `${func.returnType} ${func.name}${func.params}${constKeyword};\n\n`
|
||||
}
|
||||
|
||||
if (namespace) {
|
||||
content += `} // namespace ${namespace}\n\n`
|
||||
}
|
||||
|
||||
content += `#endif // ${guard}\n`
|
||||
|
||||
return content
|
||||
}
|
||||
}
|
||||
30
tools/refactoring/languages/types.ts
Normal file
30
tools/refactoring/languages/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type Language = 'typescript' | 'cpp'
|
||||
|
||||
export interface FunctionInfo {
|
||||
name: string
|
||||
isAsync: boolean
|
||||
isExported: boolean
|
||||
params: string
|
||||
returnType: string
|
||||
body: string
|
||||
startLine: number
|
||||
endLine: number
|
||||
comments: string[]
|
||||
isMethod: boolean
|
||||
isStatic: boolean
|
||||
isConst: boolean
|
||||
namespace?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface DependencyInfo {
|
||||
imports: string[]
|
||||
types: string[]
|
||||
}
|
||||
|
||||
export interface RefactorResult {
|
||||
success: boolean
|
||||
originalFile: string
|
||||
newFiles: string[]
|
||||
errors: string[]
|
||||
}
|
||||
219
tools/refactoring/languages/typescript-refactor.ts
Normal file
219
tools/refactoring/languages/typescript-refactor.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { DependencyInfo, FunctionInfo, RefactorResult } from './types'
|
||||
|
||||
type ModuleContext = {
|
||||
dir: string
|
||||
basename: string
|
||||
functions: FunctionInfo[]
|
||||
functionsDir: string
|
||||
dependencies: DependencyInfo
|
||||
result: RefactorResult
|
||||
}
|
||||
|
||||
export class TypeScriptLambdaRefactor {
|
||||
constructor(private readonly options: { dryRun: boolean; log: (message: string) => void }) {}
|
||||
|
||||
getFunctionExtension() {
|
||||
return '.ts'
|
||||
}
|
||||
|
||||
async extractFunctions(filePath: string): Promise<FunctionInfo[]> {
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const lines = content.split('\n')
|
||||
const functions: FunctionInfo[] = []
|
||||
|
||||
const functionRegex = /^(export\s+)?(async\s+)?function\s+([a-zA-Z0-9_]+)\s*(\([^)]*\))(\s*:\s*[^{]+)?\s*\{/
|
||||
const methodRegex = /^\s*(public|private|protected)?\s*(static\s+)?(async\s+)?([a-zA-Z0-9_]+)\s*(\([^)]*\))(\s*:\s*[^{]+)?\s*\{/
|
||||
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
const funcMatch = line.match(functionRegex)
|
||||
const methodMatch = line.match(methodRegex)
|
||||
|
||||
if (funcMatch || methodMatch) {
|
||||
const isMethod = !!methodMatch
|
||||
const match = funcMatch || methodMatch!
|
||||
|
||||
const isExported = funcMatch ? !!match[1] : true
|
||||
const isStatic = methodMatch ? !!match[2] : false
|
||||
const isAsync = funcMatch ? !!match[2] : !!match[3]
|
||||
const name = funcMatch ? match[3] : match[4]
|
||||
const params = funcMatch ? match[4] : match[5]
|
||||
const returnType = (funcMatch ? match[5] : match[6]) || ''
|
||||
|
||||
const comments: string[] = []
|
||||
let commentLine = i - 1
|
||||
while (commentLine >= 0 && (lines[commentLine].trim().startsWith('//') ||
|
||||
lines[commentLine].trim().startsWith('*') ||
|
||||
lines[commentLine].trim().startsWith('/*'))) {
|
||||
comments.unshift(lines[commentLine])
|
||||
commentLine--
|
||||
}
|
||||
|
||||
let braceCount = 1
|
||||
let bodyLines: string[] = [line]
|
||||
let j = i + 1
|
||||
|
||||
while (j < lines.length && braceCount > 0) {
|
||||
bodyLines.push(lines[j])
|
||||
for (const char of lines[j]) {
|
||||
if (char === '{') braceCount++
|
||||
if (char === '}') braceCount--
|
||||
if (braceCount === 0) break
|
||||
}
|
||||
j++
|
||||
}
|
||||
|
||||
functions.push({
|
||||
name,
|
||||
isAsync,
|
||||
isExported,
|
||||
params,
|
||||
returnType,
|
||||
body: bodyLines.join('\n'),
|
||||
startLine: i,
|
||||
endLine: j - 1,
|
||||
comments,
|
||||
isMethod,
|
||||
isStatic,
|
||||
isConst: false,
|
||||
})
|
||||
|
||||
i = j
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return functions
|
||||
}
|
||||
|
||||
async extractDependencies(filePath: string): Promise<DependencyInfo> {
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const lines = content.split('\n')
|
||||
|
||||
const imports: string[] = []
|
||||
const types: string[] = []
|
||||
|
||||
let inImport = false
|
||||
let currentImport = ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (trimmed.startsWith('import ') || inImport) {
|
||||
currentImport += line + '\n'
|
||||
if (trimmed.includes('}') || (!trimmed.includes('{') && trimmed.endsWith("'"))) {
|
||||
imports.push(currentImport.trim())
|
||||
currentImport = ''
|
||||
inImport = false
|
||||
} else {
|
||||
inImport = true
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('export type ') || trimmed.startsWith('export interface ') ||
|
||||
trimmed.startsWith('type ') || trimmed.startsWith('interface ')) {
|
||||
types.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
return { imports, types }
|
||||
}
|
||||
|
||||
generateFunctionFile(func: FunctionInfo, imports: string[]): string {
|
||||
let content = ''
|
||||
|
||||
if (imports.length > 0) {
|
||||
content += imports.join('\n') + '\n\n'
|
||||
}
|
||||
|
||||
if (func.comments.length > 0) {
|
||||
content += func.comments.join('\n') + '\n'
|
||||
}
|
||||
|
||||
const asyncKeyword = func.isAsync ? 'async ' : ''
|
||||
const exportKeyword = 'export '
|
||||
|
||||
content += `${exportKeyword}${asyncKeyword}function ${func.name}${func.params}${func.returnType} {\n`
|
||||
|
||||
const bodyLines = func.body.split('\n')
|
||||
const actualBody = bodyLines.slice(1, -1).join('\n')
|
||||
|
||||
content += actualBody + '\n'
|
||||
content += '}\n'
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
async generateModule(context: ModuleContext) {
|
||||
const { dir, basename, functions, result } = context
|
||||
const className = basename.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('') + 'Utils'
|
||||
const classFilePath = path.join(dir, basename, `${className}.ts`)
|
||||
const classContent = this.generateClassWrapper(className, functions)
|
||||
|
||||
if (!this.options.dryRun) {
|
||||
await fs.writeFile(classFilePath, classContent, 'utf-8')
|
||||
}
|
||||
|
||||
result.newFiles.push(classFilePath)
|
||||
this.options.log(` ✓ ${className}.ts (class wrapper)`)
|
||||
|
||||
const indexFilePath = path.join(dir, basename, 'index.ts')
|
||||
const indexContent = this.generateIndexFile(functions, className)
|
||||
|
||||
if (!this.options.dryRun) {
|
||||
await fs.writeFile(indexFilePath, indexContent, 'utf-8')
|
||||
}
|
||||
|
||||
result.newFiles.push(indexFilePath)
|
||||
this.options.log(` ✓ index.ts (re-exports)`)
|
||||
|
||||
const reexportContent = `// This file has been refactored into modular functions\n` +
|
||||
`export * from './${basename}'\n`
|
||||
|
||||
if (!this.options.dryRun) {
|
||||
await fs.writeFile(path.join(dir, `${basename}.ts`), reexportContent, 'utf-8')
|
||||
}
|
||||
|
||||
this.options.log(` ✓ Updated ${basename}.ts to re-export`)
|
||||
}
|
||||
|
||||
private generateClassWrapper(className: string, functions: FunctionInfo[]): string {
|
||||
let content = '// Auto-generated class wrapper\n\n'
|
||||
|
||||
for (const func of functions) {
|
||||
const kebabName = func.name.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')
|
||||
content += `import { ${func.name} } from './functions/${kebabName}'\n`
|
||||
}
|
||||
|
||||
content += `\nexport class ${className} {\n`
|
||||
|
||||
for (const func of functions) {
|
||||
const asyncKeyword = func.isAsync ? 'async ' : ''
|
||||
content += ` static ${asyncKeyword}${func.name}(...args: any[]) {\n`
|
||||
content += ` return ${func.isAsync ? 'await ' : ''}${func.name}(...args)\n`
|
||||
content += ` }\n\n`
|
||||
}
|
||||
|
||||
content += '}\n'
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
private generateIndexFile(functions: FunctionInfo[], className: string): string {
|
||||
let content = '// Auto-generated re-exports\n\n'
|
||||
|
||||
for (const func of functions) {
|
||||
const kebabName = func.name.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')
|
||||
content += `export { ${func.name} } from './functions/${kebabName}'\n`
|
||||
}
|
||||
|
||||
content += `\nexport { ${className} } from './${className}'\n`
|
||||
|
||||
return content
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,28 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Multi-Language Lambda Refactoring Tool
|
||||
*
|
||||
*
|
||||
* Supports both TypeScript and C++ refactoring into lambda-per-file structure
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
interface FunctionInfo {
|
||||
name: string
|
||||
isAsync: boolean
|
||||
isExported: boolean
|
||||
params: string
|
||||
returnType: string
|
||||
body: string
|
||||
startLine: number
|
||||
endLine: number
|
||||
comments: string[]
|
||||
isMethod: boolean
|
||||
isStatic: boolean
|
||||
isConst: boolean
|
||||
namespace?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface RefactorResult {
|
||||
success: boolean
|
||||
originalFile: string
|
||||
newFiles: string[]
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
type Language = 'typescript' | 'cpp'
|
||||
import { CppLambdaRefactor } from './languages/cpp-refactor'
|
||||
import { TypeScriptLambdaRefactor } from './languages/typescript-refactor'
|
||||
import { DependencyInfo, FunctionInfo, Language, RefactorResult } from './languages/types'
|
||||
|
||||
class MultiLanguageLambdaRefactor {
|
||||
private dryRun: boolean = false
|
||||
private verbose: boolean = false
|
||||
private readonly services: Record<Language, { getFunctionExtension: () => string; extractFunctions(filePath: string): Promise<FunctionInfo[]>; extractDependencies(filePath: string): Promise<DependencyInfo>; generateFunctionFile(func: FunctionInfo, imports: string[]): string; generateModule(context: { dir: string; basename: string; functions: FunctionInfo[]; functionsDir: string; dependencies: DependencyInfo; result: RefactorResult }): Promise<void> }>
|
||||
|
||||
constructor(options: { dryRun?: boolean; verbose?: boolean } = {}) {
|
||||
this.dryRun = options.dryRun || false
|
||||
this.verbose = options.verbose || false
|
||||
this.services = {
|
||||
typescript: new TypeScriptLambdaRefactor({ dryRun: this.dryRun, log: this.log.bind(this) }),
|
||||
cpp: new CppLambdaRefactor({ dryRun: this.dryRun, log: this.log.bind(this) }),
|
||||
}
|
||||
}
|
||||
|
||||
private log(message: string) {
|
||||
@@ -53,9 +31,6 @@ class MultiLanguageLambdaRefactor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect language from file extension
|
||||
*/
|
||||
detectLanguage(filePath: string): Language {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
if (ext === '.cpp' || ext === '.cc' || ext === '.cxx' || ext === '.hpp' || ext === '.h') {
|
||||
@@ -64,336 +39,6 @@ class MultiLanguageLambdaRefactor {
|
||||
return 'typescript'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract functions from TypeScript file
|
||||
*/
|
||||
async extractTypeScriptFunctions(filePath: string): Promise<FunctionInfo[]> {
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const lines = content.split('\n')
|
||||
const functions: FunctionInfo[] = []
|
||||
|
||||
const functionRegex = /^(export\s+)?(async\s+)?function\s+([a-zA-Z0-9_]+)\s*(\([^)]*\))(\s*:\s*[^{]+)?\s*\{/
|
||||
const methodRegex = /^\s*(public|private|protected)?\s*(static\s+)?(async\s+)?([a-zA-Z0-9_]+)\s*(\([^)]*\))(\s*:\s*[^{]+)?\s*\{/
|
||||
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
const funcMatch = line.match(functionRegex)
|
||||
const methodMatch = line.match(methodRegex)
|
||||
|
||||
if (funcMatch || methodMatch) {
|
||||
const isMethod = !!methodMatch
|
||||
const match = funcMatch || methodMatch!
|
||||
|
||||
const isExported = funcMatch ? !!match[1] : true
|
||||
const isStatic = methodMatch ? !!match[2] : false
|
||||
const isAsync = funcMatch ? !!match[2] : !!match[3]
|
||||
const name = funcMatch ? match[3] : match[4]
|
||||
const params = funcMatch ? match[4] : match[5]
|
||||
const returnType = (funcMatch ? match[5] : match[6]) || ''
|
||||
|
||||
const comments: string[] = []
|
||||
let commentLine = i - 1
|
||||
while (commentLine >= 0 && (lines[commentLine].trim().startsWith('//') ||
|
||||
lines[commentLine].trim().startsWith('*') ||
|
||||
lines[commentLine].trim().startsWith('/*'))) {
|
||||
comments.unshift(lines[commentLine])
|
||||
commentLine--
|
||||
}
|
||||
|
||||
let braceCount = 1
|
||||
let bodyLines: string[] = [line]
|
||||
let j = i + 1
|
||||
|
||||
while (j < lines.length && braceCount > 0) {
|
||||
bodyLines.push(lines[j])
|
||||
for (const char of lines[j]) {
|
||||
if (char === '{') braceCount++
|
||||
if (char === '}') braceCount--
|
||||
if (braceCount === 0) break
|
||||
}
|
||||
j++
|
||||
}
|
||||
|
||||
functions.push({
|
||||
name,
|
||||
isAsync,
|
||||
isExported,
|
||||
params,
|
||||
returnType: returnType.trim(),
|
||||
body: bodyLines.join('\n'),
|
||||
startLine: i,
|
||||
endLine: j - 1,
|
||||
comments,
|
||||
isMethod,
|
||||
isStatic,
|
||||
isConst: false,
|
||||
})
|
||||
|
||||
i = j
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return functions
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract functions from C++ file
|
||||
*/
|
||||
async extractCppFunctions(filePath: string): Promise<FunctionInfo[]> {
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const lines = content.split('\n')
|
||||
const functions: FunctionInfo[] = []
|
||||
|
||||
// Match C++ function definitions
|
||||
// ReturnType functionName(params) { or ReturnType ClassName::functionName(params) {
|
||||
const functionRegex = /^([a-zA-Z_][a-zA-Z0-9_:<>*&\s]*?)\s+([a-zA-Z_][a-zA-Z0-9_:]*)\s*(\([^)]*\))\s*(const)?\s*(noexcept)?\s*\{/
|
||||
|
||||
let i = 0
|
||||
let currentNamespace = ''
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
// Track namespace
|
||||
const namespaceMatch = line.match(/^namespace\s+([a-zA-Z0-9_]+)/)
|
||||
if (namespaceMatch) {
|
||||
currentNamespace = namespaceMatch[1]
|
||||
}
|
||||
|
||||
const funcMatch = line.match(functionRegex)
|
||||
|
||||
if (funcMatch) {
|
||||
const returnType = funcMatch[1].trim()
|
||||
const fullName = funcMatch[2]
|
||||
const params = funcMatch[3]
|
||||
const isConst = !!funcMatch[4]
|
||||
|
||||
// Parse class name if present (ClassName::methodName)
|
||||
const nameParts = fullName.split('::')
|
||||
const name = nameParts[nameParts.length - 1]
|
||||
const className = nameParts.length > 1 ? nameParts[0] : undefined
|
||||
const isMethod = !!className
|
||||
|
||||
// Collect comments
|
||||
const comments: string[] = []
|
||||
let commentLine = i - 1
|
||||
while (commentLine >= 0 && (lines[commentLine].trim().startsWith('//') ||
|
||||
lines[commentLine].trim().startsWith('/*') ||
|
||||
lines[commentLine].trim().startsWith('*'))) {
|
||||
comments.unshift(lines[commentLine])
|
||||
commentLine--
|
||||
}
|
||||
|
||||
// Find function body
|
||||
let braceCount = 1
|
||||
let bodyLines: string[] = [line]
|
||||
let j = i + 1
|
||||
|
||||
while (j < lines.length && braceCount > 0) {
|
||||
bodyLines.push(lines[j])
|
||||
for (const char of lines[j]) {
|
||||
if (char === '{') braceCount++
|
||||
if (char === '}') braceCount--
|
||||
if (braceCount === 0) break
|
||||
}
|
||||
j++
|
||||
}
|
||||
|
||||
functions.push({
|
||||
name,
|
||||
isAsync: false, // C++ doesn't have async keyword like TS
|
||||
isExported: true, // In C++, visibility is different
|
||||
params,
|
||||
returnType,
|
||||
body: bodyLines.join('\n'),
|
||||
startLine: i,
|
||||
endLine: j - 1,
|
||||
comments,
|
||||
isMethod,
|
||||
isStatic: false,
|
||||
isConst,
|
||||
namespace: currentNamespace || undefined,
|
||||
className,
|
||||
})
|
||||
|
||||
i = j
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return functions
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract imports/includes and types
|
||||
*/
|
||||
async extractDependencies(filePath: string, language: Language): Promise<{
|
||||
imports: string[]
|
||||
types: string[]
|
||||
}> {
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const lines = content.split('\n')
|
||||
|
||||
const imports: string[] = []
|
||||
const types: string[] = []
|
||||
|
||||
if (language === 'typescript') {
|
||||
let inImport = false
|
||||
let currentImport = ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (trimmed.startsWith('import ') || inImport) {
|
||||
currentImport += line + '\n'
|
||||
if (trimmed.includes('}') || (!trimmed.includes('{') && trimmed.endsWith("'"))) {
|
||||
imports.push(currentImport.trim())
|
||||
currentImport = ''
|
||||
inImport = false
|
||||
} else {
|
||||
inImport = true
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('export type ') || trimmed.startsWith('export interface ') ||
|
||||
trimmed.startsWith('type ') || trimmed.startsWith('interface ')) {
|
||||
types.push(line)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// C++
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
// Collect #include statements
|
||||
if (trimmed.startsWith('#include')) {
|
||||
imports.push(line)
|
||||
}
|
||||
|
||||
// Collect type definitions (struct, class, using, typedef)
|
||||
if (trimmed.startsWith('struct ') || trimmed.startsWith('class ') ||
|
||||
trimmed.startsWith('using ') || trimmed.startsWith('typedef ')) {
|
||||
types.push(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { imports, types }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate TypeScript function file
|
||||
*/
|
||||
generateTypeScriptFunctionFile(func: FunctionInfo, imports: string[]): string {
|
||||
let content = ''
|
||||
|
||||
if (imports.length > 0) {
|
||||
content += imports.join('\n') + '\n\n'
|
||||
}
|
||||
|
||||
if (func.comments.length > 0) {
|
||||
content += func.comments.join('\n') + '\n'
|
||||
}
|
||||
|
||||
const asyncKeyword = func.isAsync ? 'async ' : ''
|
||||
const exportKeyword = 'export '
|
||||
|
||||
content += `${exportKeyword}${asyncKeyword}function ${func.name}${func.params}${func.returnType} {\n`
|
||||
|
||||
const bodyLines = func.body.split('\n')
|
||||
const actualBody = bodyLines.slice(1, -1).join('\n')
|
||||
|
||||
content += actualBody + '\n'
|
||||
content += '}\n'
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate C++ function file (.cpp)
|
||||
*/
|
||||
generateCppFunctionFile(func: FunctionInfo, includes: string[]): string {
|
||||
let content = ''
|
||||
|
||||
// Add includes
|
||||
if (includes.length > 0) {
|
||||
content += includes.join('\n') + '\n\n'
|
||||
}
|
||||
|
||||
// Add namespace if present
|
||||
if (func.namespace) {
|
||||
content += `namespace ${func.namespace} {\n\n`
|
||||
}
|
||||
|
||||
// Add comments
|
||||
if (func.comments.length > 0) {
|
||||
content += func.comments.join('\n') + '\n'
|
||||
}
|
||||
|
||||
// Add function
|
||||
const constKeyword = func.isConst ? ' const' : ''
|
||||
content += `${func.returnType} ${func.name}${func.params}${constKeyword} {\n`
|
||||
|
||||
const bodyLines = func.body.split('\n')
|
||||
const actualBody = bodyLines.slice(1, -1).join('\n')
|
||||
|
||||
content += actualBody + '\n'
|
||||
content += '}\n'
|
||||
|
||||
if (func.namespace) {
|
||||
content += `\n} // namespace ${func.namespace}\n`
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate C++ header file (.hpp)
|
||||
*/
|
||||
generateCppHeaderFile(functions: FunctionInfo[], includes: string[], basename: string): string {
|
||||
const guard = `${basename.toUpperCase()}_HPP_INCLUDED`
|
||||
let content = ''
|
||||
|
||||
content += `#ifndef ${guard}\n`
|
||||
content += `#define ${guard}\n\n`
|
||||
|
||||
// Add includes
|
||||
if (includes.length > 0) {
|
||||
content += includes.join('\n') + '\n\n'
|
||||
}
|
||||
|
||||
// Determine namespace
|
||||
const namespace = functions[0]?.namespace
|
||||
if (namespace) {
|
||||
content += `namespace ${namespace} {\n\n`
|
||||
}
|
||||
|
||||
// Add function declarations
|
||||
for (const func of functions) {
|
||||
if (func.comments.length > 0) {
|
||||
content += func.comments.join('\n') + '\n'
|
||||
}
|
||||
const constKeyword = func.isConst ? ' const' : ''
|
||||
content += `${func.returnType} ${func.name}${func.params}${constKeyword};\n\n`
|
||||
}
|
||||
|
||||
if (namespace) {
|
||||
content += `} // namespace ${namespace}\n\n`
|
||||
}
|
||||
|
||||
content += `#endif // ${guard}\n`
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Refactor a file (auto-detects language)
|
||||
*/
|
||||
async refactorFile(filePath: string): Promise<RefactorResult> {
|
||||
const result: RefactorResult = {
|
||||
success: false,
|
||||
@@ -404,13 +49,11 @@ class MultiLanguageLambdaRefactor {
|
||||
|
||||
try {
|
||||
const language = this.detectLanguage(filePath)
|
||||
const service = this.services[language]
|
||||
this.log(`\n🔍 Analyzing ${filePath} (${language})...`)
|
||||
|
||||
// Extract functions based on language
|
||||
const functions = language === 'typescript'
|
||||
? await this.extractTypeScriptFunctions(filePath)
|
||||
: await this.extractCppFunctions(filePath)
|
||||
|
||||
|
||||
const functions = await service.extractFunctions(filePath)
|
||||
|
||||
if (functions.length === 0) {
|
||||
result.errors.push('No functions found to extract')
|
||||
return result
|
||||
@@ -422,51 +65,39 @@ class MultiLanguageLambdaRefactor {
|
||||
}
|
||||
|
||||
this.log(` Found ${functions.length} functions: ${functions.map(f => f.name).join(', ')}`)
|
||||
|
||||
// Extract dependencies
|
||||
const { imports, types } = await this.extractDependencies(filePath, language)
|
||||
|
||||
// Create directories
|
||||
|
||||
const dependencies = await service.extractDependencies(filePath)
|
||||
|
||||
const dir = path.dirname(filePath)
|
||||
const ext = path.extname(filePath)
|
||||
const basename = path.basename(filePath, ext)
|
||||
const functionsDir = path.join(dir, basename, 'functions')
|
||||
|
||||
|
||||
if (!this.dryRun) {
|
||||
await fs.mkdir(functionsDir, { recursive: true })
|
||||
}
|
||||
|
||||
|
||||
this.log(` Creating functions directory: ${functionsDir}`)
|
||||
|
||||
// Generate function files
|
||||
|
||||
for (const func of functions) {
|
||||
const kebabName = func.name.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')
|
||||
const funcExt = language === 'typescript' ? '.ts' : '.cpp'
|
||||
const funcExt = service.getFunctionExtension()
|
||||
const funcFilePath = path.join(functionsDir, `${kebabName}${funcExt}`)
|
||||
|
||||
const content = language === 'typescript'
|
||||
? this.generateTypeScriptFunctionFile(func, imports)
|
||||
: this.generateCppFunctionFile(func, imports)
|
||||
|
||||
|
||||
const content = service.generateFunctionFile(func, dependencies.imports)
|
||||
|
||||
if (!this.dryRun) {
|
||||
await fs.writeFile(funcFilePath, content, 'utf-8')
|
||||
}
|
||||
|
||||
|
||||
result.newFiles.push(funcFilePath)
|
||||
this.log(` ✓ ${kebabName}${funcExt}`)
|
||||
}
|
||||
|
||||
if (language === 'typescript') {
|
||||
// Generate TypeScript index and class wrapper
|
||||
await this.generateTypeScriptModule(dir, basename, functions, functionsDir, result)
|
||||
} else {
|
||||
// Generate C++ header and module files
|
||||
await this.generateCppModule(dir, basename, functions, imports, functionsDir, result)
|
||||
}
|
||||
|
||||
|
||||
await service.generateModule({ dir, basename, functions, functionsDir, dependencies, result })
|
||||
|
||||
result.success = true
|
||||
this.log(` ✅ Successfully refactored into ${result.newFiles.length} files`)
|
||||
|
||||
} catch (error) {
|
||||
result.errors.push(`Error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
this.log(` ❌ Failed: ${result.errors[0]}`)
|
||||
@@ -475,112 +106,6 @@ class MultiLanguageLambdaRefactor {
|
||||
return result
|
||||
}
|
||||
|
||||
private async generateTypeScriptModule(
|
||||
dir: string,
|
||||
basename: string,
|
||||
functions: FunctionInfo[],
|
||||
functionsDir: string,
|
||||
result: RefactorResult
|
||||
) {
|
||||
// Generate class wrapper
|
||||
const className = basename.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('') + 'Utils'
|
||||
const classFilePath = path.join(dir, basename, `${className}.ts`)
|
||||
const classContent = this.generateTypeScriptClassWrapper(className, functions)
|
||||
|
||||
if (!this.dryRun) {
|
||||
await fs.writeFile(classFilePath, classContent, 'utf-8')
|
||||
}
|
||||
|
||||
result.newFiles.push(classFilePath)
|
||||
this.log(` ✓ ${className}.ts (class wrapper)`)
|
||||
|
||||
// Generate index file
|
||||
const indexFilePath = path.join(dir, basename, 'index.ts')
|
||||
const indexContent = this.generateTypeScriptIndexFile(functions, className)
|
||||
|
||||
if (!this.dryRun) {
|
||||
await fs.writeFile(indexFilePath, indexContent, 'utf-8')
|
||||
}
|
||||
|
||||
result.newFiles.push(indexFilePath)
|
||||
this.log(` ✓ index.ts (re-exports)`)
|
||||
|
||||
// Update original file
|
||||
const reexportContent = `// This file has been refactored into modular functions\n` +
|
||||
`export * from './${basename}'\n`
|
||||
|
||||
if (!this.dryRun) {
|
||||
await fs.writeFile(path.join(dir, `${basename}.ts`), reexportContent, 'utf-8')
|
||||
}
|
||||
|
||||
this.log(` ✓ Updated ${basename}.ts to re-export`)
|
||||
}
|
||||
|
||||
private async generateCppModule(
|
||||
dir: string,
|
||||
basename: string,
|
||||
functions: FunctionInfo[],
|
||||
includes: string[],
|
||||
functionsDir: string,
|
||||
result: RefactorResult
|
||||
) {
|
||||
// Generate header file
|
||||
const headerFilePath = path.join(dir, basename, `${basename}.hpp`)
|
||||
const headerContent = this.generateCppHeaderFile(functions, includes, basename)
|
||||
|
||||
if (!this.dryRun) {
|
||||
await fs.writeFile(headerFilePath, headerContent, 'utf-8')
|
||||
}
|
||||
|
||||
result.newFiles.push(headerFilePath)
|
||||
this.log(` ✓ ${basename}.hpp (header)`)
|
||||
|
||||
// Update original file to include the new header
|
||||
const includeContent = `// This file has been refactored into modular functions\n` +
|
||||
`#include "${basename}/${basename}.hpp"\n`
|
||||
|
||||
if (!this.dryRun) {
|
||||
await fs.writeFile(path.join(dir, `${basename}.cpp`), includeContent, 'utf-8')
|
||||
}
|
||||
|
||||
this.log(` ✓ Updated ${basename}.cpp to include header`)
|
||||
}
|
||||
|
||||
private generateTypeScriptClassWrapper(className: string, functions: FunctionInfo[]): string {
|
||||
let content = '// Auto-generated class wrapper\n\n'
|
||||
|
||||
for (const func of functions) {
|
||||
const kebabName = func.name.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')
|
||||
content += `import { ${func.name} } from './functions/${kebabName}'\n`
|
||||
}
|
||||
|
||||
content += `\nexport class ${className} {\n`
|
||||
|
||||
for (const func of functions) {
|
||||
const asyncKeyword = func.isAsync ? 'async ' : ''
|
||||
content += ` static ${asyncKeyword}${func.name}(...args: any[]) {\n`
|
||||
content += ` return ${func.isAsync ? 'await ' : ''}${func.name}(...args)\n`
|
||||
content += ` }\n\n`
|
||||
}
|
||||
|
||||
content += '}\n'
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
private generateTypeScriptIndexFile(functions: FunctionInfo[], className: string): string {
|
||||
let content = '// Auto-generated re-exports\n\n'
|
||||
|
||||
for (const func of functions) {
|
||||
const kebabName = func.name.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')
|
||||
content += `export { ${func.name} } from './functions/${kebabName}'\n`
|
||||
}
|
||||
|
||||
content += `\nexport { ${className} } from './${className}'\n`
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
async bulkRefactor(files: string[]): Promise<RefactorResult[]> {
|
||||
console.log(`\n📦 Multi-Language Lambda Refactoring`)
|
||||
console.log(` Mode: ${this.dryRun ? 'DRY RUN' : 'LIVE'}`)
|
||||
@@ -590,14 +115,14 @@ class MultiLanguageLambdaRefactor {
|
||||
let successCount = 0
|
||||
let skipCount = 0
|
||||
let errorCount = 0
|
||||
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
console.log(`[${i + 1}/${files.length}] Processing: ${file}`)
|
||||
|
||||
|
||||
const result = await this.refactorFile(file)
|
||||
results.push(result)
|
||||
|
||||
|
||||
if (result.success) {
|
||||
successCount++
|
||||
} else if (result.errors.some(e => e.includes('skipping'))) {
|
||||
@@ -605,54 +130,17 @@ class MultiLanguageLambdaRefactor {
|
||||
} else {
|
||||
errorCount++
|
||||
}
|
||||
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
|
||||
console.log(`\n📊 Summary:`)
|
||||
console.log(` ✅ Success: ${successCount}`)
|
||||
console.log(` ⏭️ Skipped: ${skipCount}`)
|
||||
console.log(` ❌ Errors: ${errorCount}`)
|
||||
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
// CLI
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
|
||||
console.log('Multi-Language Lambda Refactoring Tool\n')
|
||||
console.log('Supports: TypeScript (.ts, .tsx) and C++ (.cpp, .hpp, .cc, .h)\n')
|
||||
console.log('Usage: tsx multi-lang-refactor.ts [options] <file>')
|
||||
console.log('\nOptions:')
|
||||
console.log(' -d, --dry-run Preview without writing')
|
||||
console.log(' -v, --verbose Verbose output')
|
||||
console.log(' -h, --help Show help')
|
||||
console.log('\nExamples:')
|
||||
console.log(' tsx multi-lang-refactor.ts --dry-run src/utils.ts')
|
||||
console.log(' tsx multi-lang-refactor.ts --verbose dbal/src/adapter.cpp')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const dryRun = args.includes('--dry-run') || args.includes('-d')
|
||||
const verbose = args.includes('--verbose') || args.includes('-v')
|
||||
const files = args.filter(a => !a.startsWith('-'))
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error('Error: Please provide file(s) to refactor')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const refactor = new MultiLanguageLambdaRefactor({ dryRun, verbose })
|
||||
await refactor.bulkRefactor(files)
|
||||
|
||||
console.log('\n✨ Done!')
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(console.error)
|
||||
}
|
||||
|
||||
export { MultiLanguageLambdaRefactor }
|
||||
|
||||
Reference in New Issue
Block a user