diff --git a/scripts/forge/cli/setup.ts b/scripts/forge/cli/setup.ts index 2bdb905aa..3127d53e4 100644 --- a/scripts/forge/cli/setup.ts +++ b/scripts/forge/cli/setup.ts @@ -2,6 +2,7 @@ import { program } from 'commander' import fs from 'fs' import { devHandler, getAvailableServices } from '../commands/dev-commands' +import * as moduleHandlers from '../commands/module-commands' import { createCommandHandler, getAvailableCommands @@ -23,6 +24,7 @@ export function setupCLI(): void { setupProjectCommands() setupDevCommand() + setupModulesCommand() } /** @@ -44,7 +46,7 @@ function setupProjectCommands(): void { } /** - * Sets up the dev command + * Sets up the dev command for starting services in development mode */ function setupDevCommand(): void { const availableServices = getAvailableServices() @@ -59,6 +61,32 @@ function setupDevCommand(): void { .action(devHandler) } +/** + * Sets up commands for managing modules + */ +function setupModulesCommand(): void { + const command = program + .command('modules') + .description('Manage Lifeforge modules') + .argument( + '[action]', + 'Action to perform on modules. Available: list, add, remove' + ) + + command.command('list').action(moduleHandlers.listModulesHandler) + command + .command('add') + .argument('', 'Module to add, e.g., lifeforge-app/wallet') + .action(moduleHandlers.addModuleHandler) + command + .command('remove') + .argument( + '[module]', + 'Module to remove, e.g., wallet (optional, will show list if not provided)' + ) + .action(moduleHandlers.removeModuleHandler) +} + /** * Parses command line arguments and runs the CLI */ diff --git a/scripts/forge/commands/dev-commands.ts b/scripts/forge/commands/dev-commands.ts index 057a2f7a6..683683987 100644 --- a/scripts/forge/commands/dev-commands.ts +++ b/scripts/forge/commands/dev-commands.ts @@ -11,6 +11,7 @@ import { killExistingProcess, validateEnvironment } from '../utils/helpers' +import { CLILoggingService } from '../utils/logging' /** * Service command configurations @@ -102,7 +103,7 @@ function startSingleService(service: string): void { */ function startAllServices(): void { validateEnvironment(['PB_DIR']) - console.log('🚀 Starting all services: db, server, client...') + CLILoggingService.info('Starting all services: db, server, client...') try { const services = createConcurrentServices() @@ -120,8 +121,8 @@ function startAllServices(): void { prefixColors: ['cyan', 'green', 'magenta'] }) } catch (error) { - console.error('❌ Failed to start all services.') - console.error(error) + CLILoggingService.error('Failed to start all services.') + CLILoggingService.error(`${error}`) process.exit(1) } } @@ -131,8 +132,8 @@ function startAllServices(): void { */ function validateService(service: string): void { if (!VALID_SERVICES.includes(service as any)) { - console.error(`❌ Invalid service: ${service}`) - console.error(`Available services: ${VALID_SERVICES.join(', ')}`) + CLILoggingService.error(`Invalid service: ${service}`) + CLILoggingService.error(`Available services: ${VALID_SERVICES.join(', ')}`) process.exit(1) } } @@ -148,13 +149,13 @@ export function devHandler(service: string): void { return } - console.log(`🚀 Starting service: ${service}...`) + CLILoggingService.info(`Starting service: ${service}...`) try { startSingleService(service) } catch (error) { - console.error(`❌ Failed to start service: ${service}`) - console.error(error) + CLILoggingService.error(`Failed to start service: ${service}`) + CLILoggingService.error(`${error}`) process.exit(1) } } diff --git a/scripts/forge/commands/module-commands/commands/add-module.ts b/scripts/forge/commands/module-commands/commands/add-module.ts new file mode 100644 index 000000000..3f96b1a30 --- /dev/null +++ b/scripts/forge/commands/module-commands/commands/add-module.ts @@ -0,0 +1,193 @@ +import fs from 'fs' + +import { executeCommand, validateFilePaths } from '../../../utils/helpers' +import { CLILoggingService } from '../../../utils/logging' +import { + MODULE_STRUCTURE_REQUIREMENTS, + type ModuleInstallConfig +} from '../utils/constants' +import { cleanup } from '../utils/file-system' +import { hasServerComponents, moduleExists } from '../utils/file-system' +import { + injectModuleRoute, + injectModuleSchema +} from '../utils/server-injection' +import { createModuleConfig, validateRepositoryPath } from '../utils/validation' + +/** + * Clones module repository from GitHub + */ +function cloneModuleRepository(config: ModuleInstallConfig): void { + try { + executeCommand( + `git clone ${config.repoUrl} ${config.tempDir}/${config.moduleName}`, + { + exitOnError: false, + stdio: ['ignore', 'ignore', 'ignore'] + } + ) + } catch (error) { + CLILoggingService.error( + `Failed to clone module repository. Please check if the repository exists and is public.` + ) + throw error + } +} + +/** + * Validates the module structure + */ +function validateModuleStructure(config: ModuleInstallConfig): void { + const isValid = validateFilePaths( + MODULE_STRUCTURE_REQUIREMENTS, + `${config.tempDir}/${config.moduleName}` + ) + + if (!isValid) { + CLILoggingService.error( + 'Invalid module structure. The module must contain "client" directory and package.json file.' + ) + throw new Error('Invalid module structure') + } + + CLILoggingService.info(`Module structure validated.`) +} + +/** + * Moves module from temp directory to apps directory + */ +function moveModuleToApps(config: ModuleInstallConfig): void { + executeCommand( + `mv ${config.tempDir}/${config.moduleName} ${config.moduleDir}` + ) + CLILoggingService.info( + `Module ${config.author}/${config.moduleName} added successfully.` + ) +} + +/** + * Installs dependencies for the module + */ +function installDependencies(): void { + CLILoggingService.info(`Installing dependencies...`) + + try { + executeCommand('bun install', { + stdio: ['ignore', 'ignore', 'ignore'], + exitOnError: false + }) + CLILoggingService.info(`Dependencies installed successfully.`) + } catch (error) { + CLILoggingService.error(`Failed to install dependencies`) + throw error + } +} + +/** + * Generates database schema migrations + */ +function generateSchemaMigrations(): void { + CLILoggingService.info(`Generating schema migrations...`) + + try { + executeCommand('bun run db:generate-migrations', { + stdio: ['ignore', 'ignore', 'ignore'], + exitOnError: false + }) + CLILoggingService.info(`Schema migrations generated successfully.`) + } catch (error) { + CLILoggingService.warn( + `Failed to generate schema migrations. This is normal if the module doesn't have database schemas.` + ) + } +} + +/** + * Processes server component injection for a module + */ +function processServerInjection(moduleName: string): void { + CLILoggingService.info(`Checking for server components...`) + + const { hasServerDir, hasServerIndex } = hasServerComponents(moduleName) + + if (!hasServerDir) { + CLILoggingService.info( + `No server directory found for module "${moduleName}", skipping server injection` + ) + return + } + + if (!hasServerIndex) { + CLILoggingService.info( + `No server index.ts found for module "${moduleName}", skipping server injection` + ) + return + } + + CLILoggingService.info(`Injecting server imports...`) + + try { + injectModuleRoute(moduleName) + } catch (error) { + CLILoggingService.warn(`Failed to inject route for ${moduleName}: ${error}`) + } + + try { + injectModuleSchema(moduleName) + } catch (error) { + CLILoggingService.warn( + `Failed to inject schema for ${moduleName}: ${error}` + ) + } +} + +/** + * Handles adding a new module to the Lifeforge system + */ +export function addModuleHandler(repoPath: string): void { + // Validate repository path format + if (!validateRepositoryPath(repoPath)) { + CLILoggingService.error( + 'Invalid module name. Use the format /, e.g., lifeforge-app/wallet' + ) + process.exit(1) + } + + const config = createModuleConfig(repoPath) + CLILoggingService.info(`Adding module ${repoPath} from ${config.author}`) + + // Setup temporary directory + cleanup(config.tempDir) + fs.mkdirSync(config.tempDir) + + try { + // Check if module already exists + if (moduleExists(config.moduleName)) { + CLILoggingService.error( + `A module with the name "${config.moduleName}" already exists in apps/. Please remove it first if you want to re-add.` + ) + throw new Error('Module already exists') + } + + // Clone and validate module + cloneModuleRepository(config) + validateModuleStructure(config) + + // Install module + moveModuleToApps(config) + processServerInjection(config.moduleName) + + // Install dependencies and generate migrations + installDependencies() + generateSchemaMigrations() + + CLILoggingService.info( + `Module ${repoPath} setup completed. You may now start the system by using "bun forge dev all"` + ) + } catch (error) { + CLILoggingService.error(`Module installation failed: ${error}`) + process.exit(1) + } finally { + cleanup(config.tempDir) + } +} diff --git a/scripts/forge/commands/module-commands/commands/list-modules.ts b/scripts/forge/commands/module-commands/commands/list-modules.ts new file mode 100644 index 000000000..5506d3aaf --- /dev/null +++ b/scripts/forge/commands/module-commands/commands/list-modules.ts @@ -0,0 +1,30 @@ +import chalk from 'chalk' + +import { CLILoggingService } from '../../../utils/logging' +import { getInstalledModules } from '../utils/file-system' + +/** + * Handles the list modules command + */ +export async function listModulesHandler(): Promise { + const modules = getInstalledModules() + + if (modules.length === 0) { + CLILoggingService.info('No modules installed yet.') + return + } + + console.log() + CLILoggingService.info( + `Found ${modules.length} installed module${modules.length > 1 ? 's' : ''}:` + ) + console.log() + + modules.forEach((module, index) => { + console.log( + ` ${chalk.cyan((index + 1).toString().padStart(2))}. ${chalk.white(module)}` + ) + }) + + console.log() +} diff --git a/scripts/forge/commands/module-commands/commands/remove-module.ts b/scripts/forge/commands/module-commands/commands/remove-module.ts new file mode 100644 index 000000000..1e37fd46a --- /dev/null +++ b/scripts/forge/commands/module-commands/commands/remove-module.ts @@ -0,0 +1,185 @@ +import chalk from 'chalk' +import fs from 'fs' +import path from 'path' +import prompts from 'prompts' + +import { executeCommand } from '../../../utils/helpers' +import { CLILoggingService } from '../../../utils/logging' +import { + getInstalledModules, + hasServerComponents, + moduleExists +} from '../utils/file-system' +import { + removeModuleRoute, + removeModuleSchema +} from '../utils/server-injection' + +/** + * Removes server component references for a module + */ +function removeServerReferences(moduleName: string): void { + CLILoggingService.info(`Removing server references...`) + + try { + removeModuleRoute(moduleName) + } catch (error) { + CLILoggingService.warn(`Failed to remove route for ${moduleName}: ${error}`) + } + + try { + removeModuleSchema(moduleName) + } catch (error) { + CLILoggingService.warn( + `Failed to remove schema for ${moduleName}: ${error}` + ) + } +} + +/** + * Removes the module directory + */ +function removeModuleDirectory(moduleName: string): void { + const moduleDir = `apps/${moduleName}` + + if (!fs.existsSync(moduleDir)) { + CLILoggingService.warn(`Module directory ${moduleDir} does not exist`) + return + } + + try { + executeCommand(`rm -rf ${moduleDir}`) + CLILoggingService.info(`Removed module directory: ${moduleDir}`) + } catch (error) { + CLILoggingService.error(`Failed to remove module directory: ${error}`) + throw error + } +} + +/** + * Prompts user to select a module to remove + */ +async function selectModuleToRemove(): Promise { + const installedModules = getInstalledModules() + + if (installedModules.length === 0) { + CLILoggingService.info('No modules found to remove.') + process.exit(0) + } + + // Create choices with detailed information + const choices = installedModules.map(module => { + const modulePath = `apps/${module}` + const packageJsonPath = path.join(modulePath, 'package.json') + let description = 'No description' + + // Try to get additional info from package.json + if (fs.existsSync(packageJsonPath)) { + try { + const packageData = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) + description = packageData.description || 'No description' + } catch (error) { + // If we can't read package.json, use defaults + } + } + + // Check if module has server components + const { hasServerDir, hasServerIndex } = hasServerComponents(module) + const serverStatus = + hasServerDir && hasServerIndex + ? chalk.green('[Server]') + : chalk.blue('[Client only]') + + const moduleName = chalk.cyan.bold(module) + const moduleDescription = chalk.gray(description) + + return { + title: `${moduleName} - ${moduleDescription} ${serverStatus}`, + value: module + } + }) + + // Add cancel option + choices.push({ + title: chalk.red('Cancel (do not remove any module)'), + value: '__cancel__' + }) + + const response = await prompts({ + type: 'autocomplete', + name: 'selectedModule', + message: 'Which module would you like to remove?', + choices, + initial: 0, + suggest: (input: string, choices: any[]) => { + return Promise.resolve( + choices.filter( + choice => + choice.value.toLowerCase().includes(input.toLowerCase()) || + choice.title.toLowerCase().includes(input.toLowerCase()) + ) + ) + } + }) + + if (!response.selectedModule || response.selectedModule === '__cancel__') { + CLILoggingService.info('Module removal cancelled.') + process.exit(0) + } + + // Confirm the deletion + const confirmResponse = await prompts({ + type: 'confirm', + name: 'confirmRemoval', + message: `Are you sure you want to PERMANENTLY REMOVE the "${response.selectedModule}" module?\n This action cannot be undone and will delete all module files.`, + initial: false + }) + + if (!confirmResponse.confirmRemoval) { + CLILoggingService.info('Module removal cancelled.') + process.exit(0) + } + + return response.selectedModule +} + +/** + * Handles removing a module from the Lifeforge system + */ +export async function removeModuleHandler(moduleName?: string): Promise { + CLILoggingService.info('Starting module removal process...') + + // If no module name provided, show interactive selection + if (!moduleName) { + moduleName = await selectModuleToRemove() + } + + // Validate module exists + if (!moduleExists(moduleName)) { + CLILoggingService.error( + `Module "${moduleName}" does not exist in apps/ directory` + ) + process.exit(1) + } + + CLILoggingService.info(`Removing module: ${moduleName}`) + + try { + // Remove server references first + removeServerReferences(moduleName) + + // Remove the module directory + removeModuleDirectory(moduleName) + + CLILoggingService.info(`Module "${moduleName}" removed successfully.`) + CLILoggingService.info( + 'You may need to restart the system to see the changes.' + ) + CLILoggingService.warn( + 'Note: Database migrations are not rolled back to prevent any data loss. If you want to remove any database schemas associated with this module, please do so manually.' + ) + } catch (error) { + CLILoggingService.error(`Module removal failed: ${error}`) + process.exit(1) + } +} diff --git a/scripts/forge/commands/module-commands/index.ts b/scripts/forge/commands/module-commands/index.ts new file mode 100644 index 000000000..7aa5cddfb --- /dev/null +++ b/scripts/forge/commands/module-commands/index.ts @@ -0,0 +1,31 @@ +export { addModuleHandler } from './commands/add-module' +export { removeModuleHandler } from './commands/remove-module' +export { listModulesHandler } from './commands/list-modules' + +// Export utilities for use by other CLI components +export { + getInstalledModules, + moduleExists, + hasServerComponents, + cleanup +} from './utils/file-system' + +export { validateRepositoryPath, createModuleConfig } from './utils/validation' + +export { createDynamicImport } from './utils/ast-utils' + +// Export constants for use by other CLI components +export { + SERVER_CONFIG, + MODULE_STRUCTURE_REQUIREMENTS, + AST_GENERATION_OPTIONS, + type ModuleInstallConfig +} from './utils/constants' + +// Export server injection utilities +export { + injectModuleRoute, + removeModuleRoute, + injectModuleSchema, + removeModuleSchema +} from './utils/server-injection' diff --git a/scripts/forge/commands/module-commands/utils/ast-utils.ts b/scripts/forge/commands/module-commands/utils/ast-utils.ts new file mode 100644 index 000000000..2cde1117d --- /dev/null +++ b/scripts/forge/commands/module-commands/utils/ast-utils.ts @@ -0,0 +1,16 @@ +import * as t from '@babel/types' + +/** + * AST manipulation utilities for Babel transformations + */ + +/** + * Creates a dynamic import expression for module loading + */ +export function createDynamicImport(modulePath: string): t.MemberExpression { + const awaitImport = t.awaitExpression( + t.callExpression(t.import(), [t.stringLiteral(modulePath)]) + ) + + return t.memberExpression(awaitImport, t.identifier('default')) +} diff --git a/scripts/forge/commands/module-commands/utils/constants.ts b/scripts/forge/commands/module-commands/utils/constants.ts new file mode 100644 index 000000000..9e3c46f8b --- /dev/null +++ b/scripts/forge/commands/module-commands/utils/constants.ts @@ -0,0 +1,43 @@ +/** + * Module installation configuration + */ +export interface ModuleInstallConfig { + tempDir: string + moduleDir: string + author: string + moduleName: string + repoUrl: string +} + +/** + * Configuration paths for server files + */ +export const SERVER_CONFIG = { + ROUTES_FILE: 'server/src/core/routes/app.routes.ts', + SCHEMA_FILE: 'server/src/core/schema.ts' +} as const + +/** + * Module structure validation requirements + */ +export const MODULE_STRUCTURE_REQUIREMENTS = [ + { + path: 'client', + type: 'directory' as const + }, + { + path: 'package.json', + type: 'file' as const + } +] + +/** + * Babel AST generation options + */ +export const AST_GENERATION_OPTIONS = { + retainLines: false, + compact: false, + jsescOption: { + quotes: 'single' as const + } +} as const diff --git a/scripts/forge/commands/module-commands/utils/file-system.ts b/scripts/forge/commands/module-commands/utils/file-system.ts new file mode 100644 index 000000000..839a9e62a --- /dev/null +++ b/scripts/forge/commands/module-commands/utils/file-system.ts @@ -0,0 +1,55 @@ +import fs from 'fs' +import path from 'path' + +/** + * File system utilities for module operations + */ + +/** + * Checks if module already exists in the apps directory + */ +export function moduleExists(moduleName: string): boolean { + return fs.existsSync(`apps/${moduleName}`) +} + +/** + * Checks if module has server components + */ +export function hasServerComponents(moduleName: string): { + hasServerDir: boolean + hasServerIndex: boolean +} { + const serverPath = path.resolve(`apps/${moduleName}/server`) + const serverIndexPath = path.resolve(`apps/${moduleName}/server/index.ts`) + + return { + hasServerDir: fs.existsSync(serverPath), + hasServerIndex: fs.existsSync(serverIndexPath) + } +} + +/** + * Gets list of installed modules from apps directory + */ +export function getInstalledModules(): string[] { + const appsDir = 'apps' + + if (!fs.existsSync(appsDir)) { + return [] + } + + return fs + .readdirSync(appsDir, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name) + .filter(name => !name.startsWith('.')) // Exclude hidden directories +} + +/** + * Cleans up temporary directory + */ +export function cleanup(tempDir: string): void { + if (fs.existsSync(tempDir)) { + fs.rmdirSync(tempDir, { recursive: true }) + } +} diff --git a/scripts/forge/commands/module-commands/utils/route-injection.ts b/scripts/forge/commands/module-commands/utils/route-injection.ts new file mode 100644 index 000000000..9b5c1c551 --- /dev/null +++ b/scripts/forge/commands/module-commands/utils/route-injection.ts @@ -0,0 +1,155 @@ +import generate from '@babel/generator' +import { parse } from '@babel/parser' +import traverse from '@babel/traverse' +import * as t from '@babel/types' +import fs from 'fs' +import path from 'path' + +import { CLILoggingService } from '../../../utils/logging' +import { createDynamicImport } from './ast-utils' +import { AST_GENERATION_OPTIONS, SERVER_CONFIG } from './constants' + +/** + * Route injection utilities for server configuration + */ + +/** + * Injects a module's server route import into the app.routes.ts file + */ +export function injectModuleRoute(moduleName: string): void { + const routesConfigPath = path.resolve(SERVER_CONFIG.ROUTES_FILE) + + if (!fs.existsSync(routesConfigPath)) { + CLILoggingService.warn( + `Routes config file not found at ${routesConfigPath}` + ) + return + } + + const routesContent = fs.readFileSync(routesConfigPath, 'utf8') + + try { + const ast = parse(routesContent, { + sourceType: 'module', + plugins: ['typescript'] + }) + + let routerObjectPath: any = null + + // Find the forgeRouter call expression + traverse(ast, { + CallExpression(path) { + if ( + t.isIdentifier(path.node.callee, { name: 'forgeRouter' }) && + path.node.arguments.length > 0 && + t.isObjectExpression(path.node.arguments[0]) + ) { + routerObjectPath = path.get('arguments')[0] + } + } + }) + + // Check if module already exists and add if not + if (routerObjectPath && t.isObjectExpression(routerObjectPath.node)) { + const hasExistingProperty = routerObjectPath.node.properties.some( + (prop: any) => + t.isObjectProperty(prop) && + t.isIdentifier(prop.key) && + prop.key.name === moduleName + ) + + if (!hasExistingProperty) { + const moduleImport = createDynamicImport(`@lib/${moduleName}/server`) + const newProperty = t.objectProperty( + t.identifier(moduleName), + moduleImport + ) + routerObjectPath.node.properties.push(newProperty) + } + } + + const { code } = generate(ast, AST_GENERATION_OPTIONS) + fs.writeFileSync(routesConfigPath, code) + + CLILoggingService.info( + `Injected route for module "${moduleName}" into ${SERVER_CONFIG.ROUTES_FILE}` + ) + } catch (error) { + CLILoggingService.error( + `Failed to inject route for module "${moduleName}": ${error}` + ) + } +} + +/** + * Removes a module's server route from the app.routes.ts file + */ +export function removeModuleRoute(moduleName: string): void { + const routesConfigPath = path.resolve(SERVER_CONFIG.ROUTES_FILE) + + if (!fs.existsSync(routesConfigPath)) { + CLILoggingService.warn( + `Routes config file not found at ${routesConfigPath}` + ) + return + } + + const routesContent = fs.readFileSync(routesConfigPath, 'utf8') + + try { + const ast = parse(routesContent, { + sourceType: 'module', + plugins: ['typescript'] + }) + + let modified = false + + // Find and remove the module property from forgeRouter object + traverse(ast, { + CallExpression(path) { + if ( + t.isIdentifier(path.node.callee, { name: 'forgeRouter' }) && + path.node.arguments.length > 0 && + t.isObjectExpression(path.node.arguments[0]) + ) { + const routerObject = path.node.arguments[0] + const originalLength = routerObject.properties.length + + routerObject.properties = routerObject.properties.filter( + (prop: any) => { + if ( + t.isObjectProperty(prop) && + t.isIdentifier(prop.key) && + prop.key.name === moduleName + ) { + return false // Remove this property + } + return true // Keep other properties + } + ) + + if (routerObject.properties.length < originalLength) { + modified = true + } + } + } + }) + + if (modified) { + const { code } = generate(ast, AST_GENERATION_OPTIONS) + fs.writeFileSync(routesConfigPath, code) + + CLILoggingService.info( + `Removed route for module "${moduleName}" from ${SERVER_CONFIG.ROUTES_FILE}` + ) + } else { + CLILoggingService.info( + `No route found for module "${moduleName}" in ${SERVER_CONFIG.ROUTES_FILE}` + ) + } + } catch (error) { + CLILoggingService.error( + `Failed to remove route for module "${moduleName}": ${error}` + ) + } +} diff --git a/scripts/forge/commands/module-commands/utils/schema-injection.ts b/scripts/forge/commands/module-commands/utils/schema-injection.ts new file mode 100644 index 000000000..467e977d0 --- /dev/null +++ b/scripts/forge/commands/module-commands/utils/schema-injection.ts @@ -0,0 +1,161 @@ +import generate from '@babel/generator' +import { parse } from '@babel/parser' +import traverse from '@babel/traverse' +import * as t from '@babel/types' +import fs from 'fs' +import path from 'path' + +import { CLILoggingService } from '../../../utils/logging' +import { AST_GENERATION_OPTIONS, SERVER_CONFIG } from './constants' + +/** + * Schema injection utilities for server configuration + */ + +/** + * Injects a module's schema import into the schema.ts file + */ +export function injectModuleSchema(moduleName: string): void { + const schemaConfigPath = path.resolve(SERVER_CONFIG.SCHEMA_FILE) + + if (!fs.existsSync(schemaConfigPath)) { + CLILoggingService.warn( + `Schema config file not found at ${schemaConfigPath}` + ) + return + } + + // Check if module has a schema file first + const moduleSchemaPath = path.resolve(`apps/${moduleName}/server/schema.ts`) + if (!fs.existsSync(moduleSchemaPath)) { + CLILoggingService.info( + `No schema file found for module "${moduleName}", skipping schema injection` + ) + return + } + + const schemaContent = fs.readFileSync(schemaConfigPath, 'utf8') + + try { + const ast = parse(schemaContent, { + sourceType: 'module', + plugins: ['typescript'] + }) + + let arrayExpressionPath: any = null + + // Find the array expression in the exported default + traverse(ast, { + ExportDefaultDeclaration(path) { + if (t.isArrayExpression(path.node.declaration)) { + arrayExpressionPath = path.get('declaration') + } + } + }) + + if (arrayExpressionPath && t.isArrayExpression(arrayExpressionPath.node)) { + // Check if module is already imported + const hasExistingImport = arrayExpressionPath.node.elements.some( + (element: any) => + t.isAwaitExpression(element) && + t.isCallExpression(element.argument) && + t.isImport(element.argument.callee) && + element.argument.arguments.length > 0 && + t.isStringLiteral(element.argument.arguments[0]) && + element.argument.arguments[0].value.includes(moduleName) + ) + + if (!hasExistingImport) { + const moduleImport = t.awaitExpression( + t.callExpression(t.import(), [ + t.stringLiteral(`@lib/${moduleName}/server/schema`) + ]) + ) + arrayExpressionPath.node.elements.push(moduleImport) + } + } + + const { code } = generate(ast, AST_GENERATION_OPTIONS) + fs.writeFileSync(schemaConfigPath, code) + + CLILoggingService.info( + `Injected schema for module "${moduleName}" into ${SERVER_CONFIG.SCHEMA_FILE}` + ) + } catch (error) { + CLILoggingService.error( + `Failed to inject schema for module "${moduleName}": ${error}` + ) + } +} + +/** + * Removes a module's schema from the schema.ts file + */ +export function removeModuleSchema(moduleName: string): void { + const schemaConfigPath = path.resolve(SERVER_CONFIG.SCHEMA_FILE) + + if (!fs.existsSync(schemaConfigPath)) { + CLILoggingService.warn( + `Schema config file not found at ${schemaConfigPath}` + ) + return + } + + const schemaContent = fs.readFileSync(schemaConfigPath, 'utf8') + + try { + const ast = parse(schemaContent, { + sourceType: 'module', + plugins: ['typescript'] + }) + + let modified = false + + // Find and remove the module import from the array + traverse(ast, { + ExportDefaultDeclaration(path) { + if (t.isArrayExpression(path.node.declaration)) { + const arrayExpression = path.node.declaration + const originalLength = arrayExpression.elements.length + + arrayExpression.elements = arrayExpression.elements.filter( + (element: any) => { + if ( + t.isAwaitExpression(element) && + t.isCallExpression(element.argument) && + t.isImport(element.argument.callee) && + element.argument.arguments.length > 0 && + t.isStringLiteral(element.argument.arguments[0]) && + element.argument.arguments[0].value.includes(moduleName) + ) { + return false // Remove this import + } + return true // Keep other imports + } + ) + + if (arrayExpression.elements.length < originalLength) { + modified = true + } + } + } + }) + + if (modified) { + const { code } = generate(ast, AST_GENERATION_OPTIONS) + fs.writeFileSync(schemaConfigPath, code) + + CLILoggingService.info( + `Removed schema for module "${moduleName}" from ${SERVER_CONFIG.SCHEMA_FILE}` + ) + } else { + CLILoggingService.info( + `No schema found for module "${moduleName}" in ${SERVER_CONFIG.SCHEMA_FILE}` + ) + } + } catch (error) { + CLILoggingService.error( + `Failed to remove schema for module "${moduleName}": ${error}` + ) + } +} diff --git a/scripts/forge/commands/module-commands/utils/server-injection.ts b/scripts/forge/commands/module-commands/utils/server-injection.ts new file mode 100644 index 000000000..5faded903 --- /dev/null +++ b/scripts/forge/commands/module-commands/utils/server-injection.ts @@ -0,0 +1,3 @@ +// Re-export server injection utilities +export { injectModuleRoute, removeModuleRoute } from './route-injection' +export { injectModuleSchema, removeModuleSchema } from './schema-injection' diff --git a/scripts/forge/commands/module-commands/utils/utils.ts b/scripts/forge/commands/module-commands/utils/utils.ts new file mode 100644 index 000000000..6c9951203 --- /dev/null +++ b/scripts/forge/commands/module-commands/utils/utils.ts @@ -0,0 +1,11 @@ +// Re-export utilities from specialized modules for backward compatibility +export { createDynamicImport } from './ast-utils' + +export { + cleanup, + getInstalledModules, + hasServerComponents, + moduleExists +} from './file-system' + +export { createModuleConfig, validateRepositoryPath } from './validation' diff --git a/scripts/forge/commands/module-commands/utils/validation.ts b/scripts/forge/commands/module-commands/utils/validation.ts new file mode 100644 index 000000000..ba0d7df38 --- /dev/null +++ b/scripts/forge/commands/module-commands/utils/validation.ts @@ -0,0 +1,27 @@ +import type { ModuleInstallConfig } from './constants' + +/** + * Validation utilities for module operations + */ + +/** + * Validates module repository path format + */ +export function validateRepositoryPath(repoPath: string): boolean { + return /^[\w-]+\/[\w-]+$/.test(repoPath) +} + +/** + * Creates module installation configuration + */ +export function createModuleConfig(repoPath: string): ModuleInstallConfig { + const [author, moduleName] = repoPath.split('/') + + return { + tempDir: '.temp', + moduleDir: `apps/${moduleName}`, + author, + moduleName, + repoUrl: `https://github.com/${author}/lifeforge-module-${moduleName}.git` + } +} diff --git a/scripts/forge/commands/project-commands.ts b/scripts/forge/commands/project-commands.ts index 235fe95c9..dc23bbfe8 100644 --- a/scripts/forge/commands/project-commands.ts +++ b/scripts/forge/commands/project-commands.ts @@ -7,6 +7,7 @@ import { resolveProjects, validateProjects } from '../utils/helpers' +import { CLILoggingService } from '../utils/logging' /** * Executes a command for multiple projects @@ -38,10 +39,10 @@ export function validateProjectArguments(projects: string[]): void { const validation = validateProjects(projects, validProjects) if (!validation.isValid) { - console.error( - `❌ Invalid project(s): ${validation.invalidProjects.join(', ')}` + CLILoggingService.error( + `Invalid project(s): ${validation.invalidProjects.join(', ')}` ) - console.error( + CLILoggingService.error( `Available projects: all, ${Object.keys(PROJECTS_ALLOWED).join(', ')}` ) process.exit(1) diff --git a/scripts/forge/index.ts b/scripts/forge/index.ts index 2b3dd4e4f..05a006e3c 100644 --- a/scripts/forge/index.ts +++ b/scripts/forge/index.ts @@ -3,6 +3,7 @@ import dotenv from 'dotenv' import path from 'path' import { runCLI, setupCLI } from './cli/setup' +import { CLILoggingService } from './utils/logging' /** * Lifeforge Forge - Build and development tool for Lifeforge projects @@ -23,6 +24,6 @@ try { setupCLI() runCLI() } catch (error) { - console.error('❌ Fatal error:', error) + CLILoggingService.error(`Fatal error: ${error}`) process.exit(1) } diff --git a/scripts/forge/types/index.ts b/scripts/forge/types/index.ts index 6a5e878c2..99880cf5f 100644 --- a/scripts/forge/types/index.ts +++ b/scripts/forge/types/index.ts @@ -1,3 +1,5 @@ +import type { IOType } from 'child_process' + export interface ProjectConfig { path: string displayName?: string @@ -9,7 +11,7 @@ export interface ServiceConfig extends ProjectConfig { } export interface CommandExecutionOptions { - stdio?: 'inherit' | 'pipe' + stdio?: IOType | [IOType, IOType, IOType] cwd?: string env?: Record exitOnError?: boolean diff --git a/scripts/forge/utils/helpers.ts b/scripts/forge/utils/helpers.ts index 5d4dbe5c7..7601c19ce 100644 --- a/scripts/forge/utils/helpers.ts +++ b/scripts/forge/utils/helpers.ts @@ -1,6 +1,9 @@ import { execSync } from 'child_process' +import fs from 'fs' +import path from 'path' import type { CommandExecutionOptions } from '../types' +import { CLILoggingService } from './logging' /** * Validates if the provided projects are valid @@ -40,13 +43,13 @@ export function executeCommand( const cmd = typeof command === 'function' ? command() : command try { - console.log(`📋 Executing: ${cmd}`) + CLILoggingService.info(`Executing: ${cmd}`) const result = execSync(cmd, { stdio: 'inherit', ...options }) - console.log(`✅ Completed: ${cmd}`) + CLILoggingService.info(`Completed: ${cmd}`) return result?.toString().trim() } catch (error) { @@ -54,8 +57,8 @@ export function executeCommand( throw error } - console.error(`❌ Failed: ${cmd}`) - console.error(error) + CLILoggingService.error(`Failed: ${cmd}`) + CLILoggingService.error(`${error}`) process.exit(1) } } @@ -67,10 +70,10 @@ export function validateEnvironment(requiredVars: string[]): void { const missingVars = requiredVars.filter(varName => !process.env[varName]) if (missingVars.length > 0) { - console.error( - `❌ Error: Missing required environment variables: ${missingVars.join(', ')}` + CLILoggingService.error( + `Missing required environment variables: ${missingVars.join(', ')}` ) - console.error('Please set them in your .env file.') + CLILoggingService.error('Please set them in your .env file.') process.exit(1) } } @@ -86,8 +89,8 @@ export function formatProjectList(projects: string[]): string { * Logs a process start message */ export function logProcessStart(processType: string, projects: string[]): void { - console.log( - `🚀 Running ${processType} for ${projects.length} project(s): ${formatProjectList(projects)}` + CLILoggingService.info( + `Running ${processType} for ${projects.length} project(s): ${formatProjectList(projects)}` ) } @@ -95,7 +98,7 @@ export function logProcessStart(processType: string, projects: string[]): void { * Logs a process completion message */ export function logProcessComplete(processType: string): void { - console.log(`🎉 All projects ${processType} completed successfully.`) + CLILoggingService.info(`All projects ${processType} completed successfully.`) } /** @@ -115,3 +118,33 @@ export function killExistingProcess(processKeyword: string): void { // No existing server instance found } } + +type PathConfig = { + path: string + type: 'file' | 'directory' +} + +export function validateFilePaths( + paths: PathConfig[], + basedir: string +): boolean { + for (const p of paths) { + const { path: pth, type } = p + + const fullPath = path.resolve(basedir, pth) + if (!fs.existsSync(fullPath)) { + return false + } + + const stats = fs.lstatSync(fullPath) + + if (type === 'file' && !stats.isFile()) { + return false + } + + if (type === 'directory' && !stats.isDirectory()) { + return false + } + } + return true +} diff --git a/scripts/forge/utils/logging.ts b/scripts/forge/utils/logging.ts new file mode 100644 index 000000000..5aa9e31ac --- /dev/null +++ b/scripts/forge/utils/logging.ts @@ -0,0 +1,37 @@ +import { LoggingService } from '@server/core/functions/logging/loggingService' + +/** + * CLI Logging service that wraps the server's LoggingService + * Provides consistent logging across the entire CLI with file persistence + */ +export class CLILoggingService { + private static readonly SERVICE_NAME = 'CLI' + + /** + * Log an informational message + */ + static info(message: string): void { + LoggingService.info(message, this.SERVICE_NAME) + } + + /** + * Log an error message + */ + static error(message: string): void { + LoggingService.error(message, this.SERVICE_NAME) + } + + /** + * Log a warning message + */ + static warn(message: string): void { + LoggingService.warn(message, this.SERVICE_NAME) + } + + /** + * Log a debug message + */ + static debug(message: string): void { + LoggingService.debug(message, this.SERVICE_NAME) + } +}