feat: very crude implementation

This commit is contained in:
Melvin Chia
2026-01-04 00:40:46 +08:00
parent 11f18d8248
commit df5449b1e1
109 changed files with 2657 additions and 2012 deletions

9
.gitignore vendored
View File

@@ -19,7 +19,9 @@ pb_*/
medium
.temp
server/src/core/routes/app.routes.ts
server/src/core/routes/generated-routes.ts
server/src/core/schema.ts
client/src/module-registry.ts
# system files
Thumbs.db
@@ -52,3 +54,10 @@ env/*
!env/.env.example
!env/.env.docker.example
keys
# module and locale packages (installed from registry)
# Note: Tailwind still scans these via @source directives in index.css
apps/*
!apps/.gitkeep
locales/*
!locales/.gitkeep

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
registry=https://registry.npmjs.org/
@lifeforge:registry=https://registry.lifeforge.dev/

3
bunfig.toml Normal file
View File

@@ -0,0 +1,3 @@
[install]
[install.scopes]
"@lifeforge" = "https://registry.lifeforge.dev/"

View File

@@ -1,41 +1,24 @@
import type { ModuleCategory, ModuleConfig } from 'shared'
import { modules } from '../module-registry'
let ROUTES: ModuleCategory[] = []
const categoryFile = import.meta.glob('../../../apps/cat.config.json', {
eager: true
})
let categoriesSeq: string[] = []
if (categoryFile['../../../apps/cat.config.json']) {
categoriesSeq = (
categoryFile['../../../apps/cat.config.json'] as { default: string[] }
).default
}
await Promise.all(
Object.entries(
import.meta.glob(['../apps/**/manifest.ts', '../../../apps/**/manifest.ts'])
).map(async ([_, resolver]) => {
const mod = (await resolver()) as {
default: ModuleConfig & { category?: string }
}
const category = mod.default.category || 'Miscellaneous'
// Process modules from generated registry
for (const mod of modules as (ModuleConfig & { category?: string })[]) {
const category = mod.category || 'Miscellaneous'
const categoryIndex = ROUTES.findIndex(cat => cat.title === category)
if (categoryIndex > -1) {
ROUTES[categoryIndex].items.push(mod.default)
ROUTES[categoryIndex].items.push(mod)
} else {
ROUTES.push({
title: category,
items: [mod.default]
items: [mod]
})
}
})
)
}
ROUTES = ROUTES.sort((a, b) => {
const order = ['<START>', 'Miscellaneous', 'Settings', 'SSO', '<END>']
@@ -61,27 +44,6 @@ ROUTES = ROUTES.sort((a, b) => {
if (bIndex >= 1) return -1 // Settings, SSO, <END> go last
}
if (categoriesSeq.length > 0) {
const aCatIndex = categoriesSeq.indexOf(a.title)
const bCatIndex = categoriesSeq.indexOf(b.title)
// Both found in sequence
if (aCatIndex !== -1 && bCatIndex !== -1) {
return aCatIndex - bCatIndex
}
// Only a found in sequence
if (aCatIndex !== -1) {
return -1
}
// Only b found in sequence
if (bCatIndex !== -1) {
return 1
}
}
// Default to alphabetical
return a.title.localeCompare(b.title)
}).map(cat => ({
@@ -89,8 +51,4 @@ ROUTES = ROUTES.sort((a, b) => {
items: cat.items.sort((a, b) => a.name.localeCompare(b.name))
}))
import.meta.glob('../../../apps/**/client/index.css', {
eager: true
})
export default ROUTES

View File

@@ -52,3 +52,4 @@ services:
- "80:80"
depends_on:
- server

View File

@@ -87,20 +87,5 @@
</div>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<!-- Google tag for analytics -->
<!-- Don't worry, I'm just curious how much traffic the docs get :) -->
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-40WQCL0RYK"
></script>
<script>
window.dataLayer = window.dataLayer || []
function gtag() {
dataLayer.push(arguments)
}
gtag('js', new Date())
gtag('config', 'G-40WQCL0RYK')
</script>
</body>
</html>

View File

@@ -17,6 +17,7 @@
"./shared",
"./packages/*",
"./apps/*",
"./locales/*",
"./tools/*"
],
"scripts": {
@@ -31,19 +32,13 @@
"lifeforge"
],
"devDependencies": {
"@babel/generator": "^7.28.0",
"@babel/parser": "^7.28.0",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.1",
"@eslint/js": "^9.26.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/lodash": "^4.17.20",
"@types/prettier": "^3.0.0",
"@types/prompts": "^2.4.9",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"bun-types": "latest",
"commander": "^14.0.1",
"concurrently": "^9.1.2",
"eslint": "^9.26.0",
"eslint-config-standard-with-typescript": "^40.0.0",
@@ -55,14 +50,17 @@
"eslint-plugin-sonarjs": "^3.0.2",
"eslint-plugin-unused-imports": "^4.2.0",
"globals": "^16.5.0",
"inquirer-autocomplete-prompt": "^3.0.1",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
"prompts": "^2.4.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.31.1"
},
"dependencies": {
"dotenv": "^17.2.3"
"@lifeforge/lang-en": "workspace:*",
"@lifeforge/lang-ms": "workspace:*",
"@lifeforge/lang-tr": "workspace:*",
"@lifeforge/lang-zh-CN": "workspace:*",
"@lifeforge/lang-zh-TW": "workspace:*",
"@lifeforge/lifeforge--achievements": "workspace:*"
}
}

View File

@@ -5,8 +5,8 @@ import { forgeController, forgeRouter } from '@functions/routes'
import { registerRoutes } from '@functions/routes/functions/forgeRouter'
import { clientError } from '@functions/routes/utils/response'
import appRoutes from './app.routes'
import coreRoutes from './core.routes'
import appRoutes from './generated-routes'
const router = express.Router()

View File

@@ -7,12 +7,6 @@
"types": "tsc --noEmit"
},
"dependencies": {
"@babel/generator": "^7.28.5",
"@babel/parser": "^7.28.5",
"@babel/traverse": "^7.28.5",
"@babel/types": "^7.28.5",
"@types/babel__generator": "^7.27.0",
"@types/babel__traverse": "^7.28.0",
"axios": "^1.12.2",
"chalk": "^5.6.2",
"commander": "^14.0.2",

View File

@@ -141,13 +141,13 @@ export async function createStructureMigration(
* Runs migrate up to apply pending migrations
*/
export function runMigrateUp(): void {
CLILoggingService.info('Applying pending migrations...')
CLILoggingService.debug('Applying pending migrations...')
execSync(`${PB_BINARY_PATH} migrate up ${PB_KWARGS.join(' ')}`, {
stdio: ['pipe', 'pipe', 'pipe']
})
CLILoggingService.success('Migrations applied successfully')
CLILoggingService.debug('Migrations applied successfully')
}
/**

View File

@@ -60,7 +60,7 @@ export async function generateMigrationsHandler(
const schemaFiles = getSchemaFiles(targetModule)
CLILoggingService.info(
CLILoggingService.debug(
targetModule
? `Processing module: ${chalk.bold.blue(targetModule)}`
: `Found ${chalk.bold.blue(schemaFiles.length)} schema files.`
@@ -76,7 +76,7 @@ export async function generateMigrationsHandler(
)
// Phase 1: Generate all skeleton migrations
CLILoggingService.step('Phase 1: Creating skeleton migrations...')
CLILoggingService.debug('Phase 1: Creating skeleton migrations...')
for (const { moduleName, schema } of importedSchemas) {
const result = await createSkeletonMigration(moduleName, schema)
@@ -90,16 +90,16 @@ export async function generateMigrationsHandler(
}
}
CLILoggingService.success(
CLILoggingService.debug(
`Created ${importedSchemas.length} skeleton migrations`
)
// Phase 2: Run migrate up to apply skeleton migrations
CLILoggingService.step('Phase 2: Applying skeleton migrations...')
CLILoggingService.debug('Phase 2: Applying skeleton migrations...')
runMigrateUp()
// Phase 3: Generate all structure migrations
CLILoggingService.step('Phase 3: Creating structure migrations...')
CLILoggingService.debug('Phase 3: Creating structure migrations...')
for (const { moduleName, schema } of importedSchemas) {
const result = await createStructureMigration(
@@ -117,16 +117,16 @@ export async function generateMigrationsHandler(
}
}
CLILoggingService.success(
CLILoggingService.debug(
`Created ${importedSchemas.length} structure migrations`
)
// Phase 4: Run migrate up to apply structure migrations
CLILoggingService.step('Phase 4: Applying structure migrations...')
CLILoggingService.debug('Phase 4: Applying structure migrations...')
runMigrateUp()
// Phase 5: Generate view query migrations (for modules with view collections)
CLILoggingService.step('Phase 5: Creating view query migrations...')
CLILoggingService.debug('Phase 5: Creating view query migrations...')
let viewMigrationCount = 0
@@ -147,23 +147,23 @@ export async function generateMigrationsHandler(
}
if (viewMigrationCount > 0) {
CLILoggingService.success(
CLILoggingService.debug(
`Created ${viewMigrationCount} view query migrations`
)
// Phase 6: Apply view query migrations
CLILoggingService.step('Phase 6: Applying view query migrations...')
CLILoggingService.debug('Phase 6: Applying view query migrations...')
runMigrateUp()
} else {
CLILoggingService.info(
CLILoggingService.debug(
'No view collections found, skipping view migrations'
)
}
// Summary
const message = targetModule
? `Migration script completed for module ${chalk.bold.blue(targetModule)}`
: 'Migration script completed for all modules'
? `Database migrations applied for ${chalk.bold.blue(targetModule)}`
: 'Database migrations applied for all modules'
CLILoggingService.success(message)
} catch (error) {

View File

@@ -6,8 +6,6 @@ import path from 'path'
import { PB_BINARY_PATH, PB_KWARGS, PB_MIGRATIONS_DIR } from '@/constants/db'
import CLILoggingService from '@/utils/logging'
/**
* Cleans up old migrations
*/
@@ -15,7 +13,7 @@ export async function cleanupOldMigrations(
targetModule?: string
): Promise<void> {
try {
CLILoggingService.warn('Cleaning up old migrations directory...')
CLILoggingService.debug('Cleaning up old migrations directory...')
if (!targetModule) {
fs.rmSync(PB_MIGRATIONS_DIR, { recursive: true, force: true })
@@ -41,7 +39,7 @@ export async function cleanupOldMigrations(
}
)
CLILoggingService.info(
CLILoggingService.debug(
`Removed ${chalk.bold.blue(
migrationFiles.filter(file => file.endsWith(`_${targetModule}.js`))
.length

View File

@@ -0,0 +1,8 @@
import fs from 'fs'
import path from 'path'
export const LOCALES_DIR = path.join(process.cwd(), 'locales')
if (!fs.existsSync(LOCALES_DIR)) {
fs.mkdirSync(LOCALES_DIR)
}

View File

@@ -0,0 +1,27 @@
import CLILoggingService from '@/utils/logging'
import getPBInstance from '@/utils/pocketbase'
async function ensureLocaleNotInUse(shortName: string) {
CLILoggingService.debug('Checking if locale is in use...')
const { pb, killPB } = await getPBInstance()
try {
const user = await pb.collection('users').getFirstListItem("id != ''")
if (user.language === shortName) {
CLILoggingService.actionableError(
`Cannot uninstall locale "${shortName}"`,
'This language is currently selected. Change your language first.'
)
killPB?.()
process.exit(1)
}
} finally {
killPB?.()
}
}
export default ensureLocaleNotInUse

View File

@@ -0,0 +1,44 @@
import fs from 'fs'
import path from 'path'
import { LOCALES_DIR } from '../constants'
export function getInstalledLocales(): string[] {
return fs.readdirSync(LOCALES_DIR).filter(dir => {
if (dir.startsWith('.')) return false
const fullPath = path.join(LOCALES_DIR, dir)
const packageJsonPath = path.join(fullPath, 'package.json')
return fs.statSync(fullPath).isDirectory() && fs.existsSync(packageJsonPath)
})
}
export function getInstalledLocalesWithMeta(): {
name: string
displayName: string
version: string
}[] {
const locales = getInstalledLocales()
const installedLocales: {
name: string
displayName: string
version: string
}[] = []
locales.forEach(locale => {
const packageJsonPath = path.join(LOCALES_DIR, locale, 'package.json')
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'))
installedLocales.push({
name: locale,
displayName: packageJson.lifeforge?.displayName || locale,
version: packageJson.version
})
})
return installedLocales
}

View File

@@ -0,0 +1,35 @@
import path from 'path'
import { LOCALES_DIR } from '../constants'
function extractLocaleName(packageName: string): string {
return packageName.replace('@lifeforge/lang-', '')
}
export function normalizeLocalePackageName(langCode: string): string {
return langCode.startsWith('@lifeforge/lang-')
? langCode
: `@lifeforge/lang-${langCode}`
}
function getLocalePath(langCode: string): string {
const shortName = extractLocaleName(langCode)
return path.join(LOCALES_DIR, shortName)
}
function getLocalesMeta(langCode: string) {
const fullPackageName = normalizeLocalePackageName(langCode)
const shortName = extractLocaleName(fullPackageName)
const targetDir = getLocalePath(shortName)
return {
fullPackageName,
shortName,
targetDir
}
}
export default getLocalesMeta

View File

@@ -0,0 +1,33 @@
import CLILoggingService from '@/utils/logging'
import { getInstalledLocalesWithMeta } from './getInstalledLocales'
import { normalizeLocalePackageName } from './getLocalesMeta'
function getPackagesToCheck(langCode?: string) {
const localePackages = getInstalledLocalesWithMeta()
if (!localePackages.length) {
CLILoggingService.info('No locales installed')
process.exit(0)
}
const packagesToCheck = langCode
? localePackages.filter(
p => p.name === normalizeLocalePackageName(langCode)
)
: localePackages
if (!packagesToCheck?.length) {
CLILoggingService.actionableError(
`Locale "${langCode}" is not installed`,
'Run "bun forge locales list" to see installed locales'
)
process.exit(0)
}
return packagesToCheck
}
export default getPackagesToCheck

View File

@@ -0,0 +1,63 @@
import CLILoggingService from '@/utils/logging'
import { getRegistryUrl } from '@/utils/registry'
interface LocaleUpgrade {
name: string
current: string
latest: string
}
async function getLatestLocaleVersion(
packageName: string
): Promise<string | null> {
try {
const registryUrl = getRegistryUrl()
const response = await fetch(`${registryUrl}/${packageName}`)
if (!response.ok) {
return null
}
const data = (await response.json()) as {
'dist-tags'?: { latest?: string }
}
return data['dist-tags']?.latest || null
} catch {
return null
}
}
async function getUpgrades(
packagesToCheck: { name: string; version: string }[]
) {
const upgrades: LocaleUpgrade[] = []
for (const pkg of packagesToCheck) {
const latestVersion = await getLatestLocaleVersion(pkg.name)
if (latestVersion && latestVersion !== pkg.version) {
upgrades.push({
name: pkg.name,
current: pkg.version,
latest: latestVersion
})
}
}
if (!upgrades.length) {
CLILoggingService.success('All locales are up to date!')
process.exit(0)
}
CLILoggingService.info('Available upgrades:')
upgrades.forEach(u =>
CLILoggingService.info(` ${u.name}: ${u.current}${u.latest}`)
)
return upgrades
}
export default getUpgrades

View File

@@ -0,0 +1,30 @@
import fs from 'fs'
import { executeCommand } from '@/utils/helpers'
import { addWorkspaceDependency } from '@/utils/package'
function installAndMoveLocales(fullPackageName: string, targetDir: string) {
if (fs.existsSync(targetDir)) {
fs.rmSync(targetDir, { recursive: true, force: true })
}
executeCommand(`bun add ${fullPackageName}@latest`, {
cwd: process.cwd(),
stdio: 'inherit'
})
const installedPath = `${process.cwd()}/node_modules/${fullPackageName}`
if (!fs.existsSync(installedPath)) {
throw new Error(`Failed to install ${fullPackageName}`)
}
fs.cpSync(installedPath, targetDir, { recursive: true })
addWorkspaceDependency(fullPackageName)
fs.rmSync(installedPath, { recursive: true, force: true })
executeCommand('bun install', { cwd: process.cwd(), stdio: 'inherit' })
}
export default installAndMoveLocales

View File

@@ -0,0 +1,23 @@
import CLILoggingService from '@/utils/logging'
import getPBInstance from '@/utils/pocketbase'
import { getInstalledLocales } from './getInstalledLocales'
async function setFirstLangInDB(shortName: string) {
const installedLocales = getInstalledLocales()
if (installedLocales.length === 1) {
CLILoggingService.step('First language pack - setting as default for user')
const { pb, killPB } = await getPBInstance()
const user = await pb.collection('users').getFirstListItem("id != ''")
await pb.collection('users').update(user.id, { language: shortName })
CLILoggingService.info(`Set ${shortName} as default language`)
killPB?.()
}
}
export default setFirstLangInDB

View File

@@ -0,0 +1,65 @@
import fs from 'fs'
import path from 'path'
import CLILoggingService from '@/utils/logging'
interface LocalePackageJson {
name?: string
version?: string
lifeforge?: {
displayName?: string
icon?: string
}
[key: string]: unknown
}
export function validateLocaleStructure(localePath: string) {
const errors: string[] = []
const warnings: string[] = []
const packageJsonPath = path.join(localePath, 'package.json')
if (!fs.existsSync(packageJsonPath)) {
errors.push('Missing package.json')
return { valid: false, errors, warnings }
}
const packageJson: LocalePackageJson = JSON.parse(
fs.readFileSync(packageJsonPath, 'utf-8')
)
if (!packageJson.name) {
errors.push('package.json is missing "name" field')
} else if (!packageJson.name.startsWith('@lifeforge/lang-')) {
errors.push('Package name must start with "@lifeforge/lang-"')
}
if (!packageJson.version) {
errors.push('package.json is missing "version" field')
} else if (!packageJson.version.match(/^\d+\.\d+\.\d+/)) {
errors.push('Version must be valid semver (e.g., 0.1.0)')
}
if (!packageJson.lifeforge) {
errors.push('package.json is missing "lifeforge" field')
} else {
if (!packageJson.lifeforge.displayName) {
errors.push('lifeforge.displayName is required')
}
if (!packageJson.lifeforge.icon) {
warnings.push('lifeforge.icon is missing (optional)')
}
}
if (errors.length > 0) {
CLILoggingService.error('Locale validation failed:')
errors.forEach(err => CLILoggingService.error(` - ${err}`))
process.exit(1)
}
warnings.forEach(warn => CLILoggingService.warn(` - ${warn}`))
}

View File

@@ -1,191 +0,0 @@
import chalk from 'chalk'
import fs from 'fs'
import { updateGitSubmodules } from '@/commands/modules/functions'
import { executeCommand } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
import getPBInstance from '@/utils/pocketbase'
import {
type LocaleInstallConfig,
createLocaleConfig,
getInstalledLocales,
localeExists,
validateLocaleName
} from '../utils'
const LOCALE_STRUCTURE_REQUIREMENTS = ['manifest.json']
/**
* Removes path from git index if it exists but is not a submodule
* This handles the case where locale directories were previously tracked directly
*/
function removeFromGitIndex(localeDir: string): void {
try {
executeCommand(`git rm -r --cached --ignore-unmatch ${localeDir}`, {
exitOnError: false,
stdio: ['ignore', 'ignore', 'ignore']
})
} catch {
// Ignore errors, path may not exist in index
}
}
/**
* Clones locale repository from GitHub as a submodule
*/
function cloneLocaleRepository(config: LocaleInstallConfig): void {
if (!fs.existsSync('.gitmodules')) {
fs.writeFileSync('.gitmodules', '')
}
CLILoggingService.progress('Cloning locale repository from GitHub')
// Remove from git index if it was previously tracked (not as submodule)
removeFromGitIndex(config.localeDir)
try {
executeCommand(
`git submodule add --force ${config.repoUrl} ${config.localeDir}`,
{
exitOnError: false,
stdio: ['ignore', 'ignore', 'ignore']
}
)
CLILoggingService.success('Repository cloned successfully')
} catch (error) {
CLILoggingService.actionableError(
'Failed to clone locale repository',
`Verify the repository lifeforge-app/lang-${config.langName} exists and is accessible`
)
throw error
}
}
/**
* Validates the locale structure
*/
function validateLocaleStructure(config: LocaleInstallConfig): void {
CLILoggingService.step('Validating locale structure')
const missingFiles = LOCALE_STRUCTURE_REQUIREMENTS.filter(
file => !fs.existsSync(`${config.localeDir}/${file}`)
)
if (missingFiles.length > 0) {
CLILoggingService.actionableError(
'Invalid locale structure detected',
`Missing required files: ${missingFiles.join(', ')}`
)
throw new Error('Invalid locale structure')
}
CLILoggingService.success('Locale structure validated')
}
/**
* Cleans up on failure
*/
function cleanup(localeDir: string): void {
if (fs.existsSync(localeDir)) {
fs.rmSync(localeDir, { recursive: true })
}
}
/**
* Removes submodule entry from .gitmodules on failure
*/
function cleanupGitmodules(localeDir: string): void {
if (!fs.existsSync('.gitmodules')) return
const content = fs.readFileSync('.gitmodules', 'utf-8')
const lines = content.split('\n')
const filteredLines: string[] = []
let skipSection = false
for (const line of lines) {
if (line.startsWith('[submodule')) {
skipSection = line.includes(`"${localeDir}"`)
}
if (!skipSection) {
filteredLines.push(line)
}
}
fs.writeFileSync('.gitmodules', filteredLines.join('\n').trim() + '\n')
}
/**
* Handles adding a new locale to the LifeForge system
*/
export async function addLocaleHandler(langName: string): Promise<void> {
if (!validateLocaleName(langName)) {
CLILoggingService.actionableError(
'Invalid language name format',
'Use formats like "en", "ms", "zh-CN", "zh-TW" (lowercase language code, optionally with uppercase region)'
)
process.exit(1)
}
if (localeExists(langName)) {
CLILoggingService.actionableError(
`Language "${langName}" is already installed`,
'Use "bun forge locales list" to see installed languages'
)
process.exit(1)
}
const installedLocales = getInstalledLocales()
const isFirstLocale = installedLocales.length === 0
const config = createLocaleConfig(langName)
CLILoggingService.step(`Adding language pack: ${langName}`)
try {
cloneLocaleRepository(config)
validateLocaleStructure(config)
updateGitSubmodules(config.localeDir)
executeCommand('git add .gitmodules')
if (isFirstLocale) {
CLILoggingService.step(
'First language pack - setting as default for all users'
)
const { pb, killPB } = await getPBInstance()
const users = await pb.collection('users').getFullList()
for (const user of users) {
await pb.collection('users').update(user.id, { language: langName })
}
CLILoggingService.info(
`Set ${chalk.bold.blue(langName)} as default language for ${chalk.bold.blue(users.length)} user(s)`
)
killPB?.()
}
CLILoggingService.success(
`Language pack "${langName}" installed successfully! Restart the server to apply changes.`
)
} catch (error) {
CLILoggingService.actionableError(
'Locale installation failed',
'Check the error details above and try again'
)
CLILoggingService.debug(`Installation error: ${error}`)
cleanup(config.localeDir)
cleanupGitmodules(config.localeDir)
process.exit(1)
}
}

View File

@@ -0,0 +1,38 @@
import fs from 'fs'
import CLILoggingService from '@/utils/logging'
import getLocalesMeta from '../functions/getLocalesMeta'
import installAndMoveLocales from '../functions/installAndMoveLocales'
import setFirstLangInDB from '../functions/setFirstLangInDB'
export async function installLocaleHandler(langCode: string): Promise<void> {
const { fullPackageName, shortName, targetDir } = getLocalesMeta(langCode)
if (fs.existsSync(targetDir)) {
CLILoggingService.actionableError(
`Locale already exists at locales/${shortName}`,
`Remove it first with: bun forge locales uninstall ${shortName}`
)
process.exit(1)
}
CLILoggingService.progress('Fetching locale from registry...')
try {
installAndMoveLocales(fullPackageName, targetDir)
await setFirstLangInDB(shortName)
CLILoggingService.success(
`Locale ${fullPackageName} installed successfully!`
)
} catch (error) {
CLILoggingService.actionableError(
`Failed to install ${fullPackageName}`,
'Make sure the locale exists in the registry'
)
throw error
}
}

View File

@@ -0,0 +1,26 @@
import chalk from 'chalk'
import CLILoggingService from '@/utils/logging'
import { getInstalledLocalesWithMeta } from '../functions/getInstalledLocales'
export function listLocalesHandler(): void {
const locales = getInstalledLocalesWithMeta()
if (locales.length === 0) {
CLILoggingService.info('No language packs installed')
CLILoggingService.info(
'Use "bun forge locales install <lang>" to install a language pack'
)
return
}
CLILoggingService.info(`Installed language packs (${locales.length}):`)
for (const locale of locales.sort((a, b) => a.name.localeCompare(b.name))) {
console.log(
` ${chalk.bold.blue(locale.name)} - ${locale.displayName} (v.${locale.version})`
)
}
}

View File

@@ -1,43 +0,0 @@
import chalk from 'chalk'
import fs from 'fs'
import path from 'path'
import CLILoggingService from '@/utils/logging'
import { getInstalledLocales } from '../utils'
/**
* Lists all installed locales
*/
export function listLocalesHandler(): void {
const locales = getInstalledLocales()
if (locales.length === 0) {
CLILoggingService.info('No language packs installed')
CLILoggingService.info(
'Use "bun forge locales add <lang>" to install a language pack'
)
return
}
CLILoggingService.info(`Installed language packs (${locales.length}):`)
for (const locale of locales) {
const manifestPath = path.join('locales', locale, 'manifest.json')
let displayName = locale
if (fs.existsSync(manifestPath)) {
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
displayName = manifest.displayName || locale
} catch {
// Use locale name as fallback
}
}
console.log(` ${chalk.bold.blue(locale)} - ${displayName}`)
}
}

View File

@@ -0,0 +1,48 @@
import fs from 'fs'
import { executeCommand } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
import { validateMaintainerAccess } from '../../../utils/github-cli'
import { checkAuth, getRegistryUrl } from '../../../utils/registry'
import getLocalesMeta from '../functions/getLocalesMeta'
import { validateLocaleStructure } from '../functions/validateLocaleStructure'
export async function publishLocaleHandler(
langCode: string,
options?: { official?: boolean }
): Promise<void> {
const { targetDir, shortName } = getLocalesMeta(langCode)
if (!fs.existsSync(targetDir)) {
CLILoggingService.actionableError(
`Locale "${langCode}" not found in locales/`,
'Run "bun forge locales list" to see available locales'
)
return
}
validateLocaleStructure(targetDir)
const auth = await checkAuth()
if (options?.official) {
validateMaintainerAccess(auth.username ?? '')
}
try {
executeCommand(`npm publish --registry ${getRegistryUrl()}`, {
cwd: targetDir,
stdio: 'inherit'
})
CLILoggingService.success(`Locale "${shortName}" published successfully!`)
} catch (error) {
CLILoggingService.actionableError(
'Failed to publish locale',
'Check if you are properly authenticated with the registry'
)
throw error
}
}

View File

@@ -1,191 +0,0 @@
import chalk from 'chalk'
import fs from 'fs'
import type PocketBase from 'pocketbase'
import { executeCommand } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
import getPBInstance from '@/utils/pocketbase'
import { getInstalledLocales, localeExists, validateLocaleName } from '../utils'
/**
* Updates users' language preferences when a locale is being removed
*/
async function updateUsersLanguage(
pb: PocketBase,
fromLang: string,
toLang: string
): Promise<number> {
const users = await pb.collection('users').getFullList({
filter: `language = "${fromLang}"`
})
if (users.length === 0) {
return 0
}
CLILoggingService.progress(
`Updating ${users.length} user(s) from "${fromLang}" to "${toLang}"`
)
for (const user of users) {
await pb.collection('users').update(user.id, { language: toLang })
}
return users.length
}
/**
* Removes the git submodule for a locale
*/
function removeGitSubmodule(localeDir: string): void {
CLILoggingService.progress('Removing git submodule')
// Step 1: Deinitialize the submodule
try {
executeCommand(`git submodule deinit -f ${localeDir}`, {
exitOnError: false,
stdio: ['ignore', 'ignore', 'ignore']
})
} catch {
// May fail if not a proper submodule
}
// Step 2: Remove from git index (git rm)
try {
executeCommand(`git rm -rf ${localeDir}`, {
exitOnError: false,
stdio: ['ignore', 'ignore', 'ignore']
})
} catch {
// May fail if not tracked
}
// Step 3: Force remove from git index (handles orphaned entries)
try {
executeCommand(`git update-index --force-remove ${localeDir}`, {
exitOnError: false,
stdio: ['ignore', 'ignore', 'ignore']
})
} catch {
// May fail if not in index
}
// Step 4: Remove from .git/modules
const gitModulesPath = `.git/modules/${localeDir}`
if (fs.existsSync(gitModulesPath)) {
fs.rmSync(gitModulesPath, { recursive: true })
}
// Step 5: Remove entry from .gitmodules file
if (fs.existsSync('.gitmodules')) {
const content = fs.readFileSync('.gitmodules', 'utf-8')
const lines = content.split('\n')
const filteredLines: string[] = []
let skipSection = false
for (const line of lines) {
if (line.startsWith('[submodule')) {
skipSection = line.includes(`"${localeDir}"`)
}
if (!skipSection) {
filteredLines.push(line)
}
}
fs.writeFileSync('.gitmodules', filteredLines.join('\n').trim() + '\n')
try {
executeCommand('git add .gitmodules', {
exitOnError: false,
stdio: ['ignore', 'ignore', 'ignore']
})
} catch {
// Ignore
}
}
CLILoggingService.success('Git submodule removed successfully')
}
/**
* Cleans up locale directory if it still exists
*/
function cleanupLocaleDir(localeDir: string): void {
if (fs.existsSync(localeDir)) {
fs.rmSync(localeDir, { recursive: true })
}
}
/**
* Handles removing a locale from the LifeForge system
*/
export async function removeLocaleHandler(langName: string): Promise<void> {
if (!validateLocaleName(langName)) {
CLILoggingService.actionableError(
'Invalid language name format',
'Use formats like "en", "ms", "zh-CN", "zh-TW"'
)
process.exit(1)
}
if (!localeExists(langName)) {
CLILoggingService.actionableError(
`Language "${langName}" is not installed`,
'Use "bun forge locales list" to see installed languages'
)
process.exit(1)
}
const installedLocales = getInstalledLocales()
if (installedLocales.length <= 1) {
CLILoggingService.actionableError(
'Cannot remove the last installed language',
'At least one language must remain installed'
)
process.exit(1)
}
CLILoggingService.step(`Removing language pack: ${langName}`)
const { pb, killPB } = await getPBInstance()
try {
const remainingLocales = installedLocales.filter(l => l !== langName)
const fallbackLang = remainingLocales[0]
const affectedUsers = await updateUsersLanguage(pb, langName, fallbackLang)
if (affectedUsers > 0) {
CLILoggingService.info(
`Updated ${chalk.bold.blue(affectedUsers)} user(s) to "${fallbackLang}"`
)
}
const localeDir = `locales/${langName}`
removeGitSubmodule(localeDir)
cleanupLocaleDir(localeDir)
CLILoggingService.success(
`Language pack "${langName}" removed successfully! Restart the server to apply changes.`
)
killPB?.()
} catch (error) {
CLILoggingService.actionableError(
'Locale removal failed',
'Check the error details above and try again'
)
CLILoggingService.debug(`Removal error: ${error}`)
killPB?.()
process.exit(1)
}
}

View File

@@ -0,0 +1,35 @@
import fs from 'fs'
import { executeCommand } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
import { findPackageName, removeWorkspaceDependency } from '@/utils/package'
import ensureLocaleNotInUse from '../functions/ensureLocaleNotInUse'
import getLocalesMeta from '../functions/getLocalesMeta'
export async function uninstallLocaleHandler(langCode: string): Promise<void> {
const { fullPackageName, shortName, targetDir } = getLocalesMeta(langCode)
const found = findPackageName(fullPackageName)
if (!found) {
CLILoggingService.actionableError(
`Locale "${shortName}" is not installed`,
'Run "bun forge locales list" to see installed locales'
)
return
}
await ensureLocaleNotInUse(shortName)
CLILoggingService.info(`Uninstalling locale ${fullPackageName}...`)
fs.rmSync(targetDir, { recursive: true, force: true })
removeWorkspaceDependency(fullPackageName)
executeCommand('bun install', { cwd: process.cwd(), stdio: 'inherit' })
CLILoggingService.info(`Uninstalled locale ${fullPackageName}`)
}

View File

@@ -0,0 +1,39 @@
import { confirmAction, executeCommand } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
import { checkAuth } from '../../../utils/registry'
import getLocalesMeta from '../functions/getLocalesMeta'
import getPackagesToCheck from '../functions/getPackagesToCheck'
import getUpgrades from '../functions/getUpgrades'
import installAndMoveLocales from '../functions/installAndMoveLocales'
export async function upgradeLocaleHandler(langCode?: string): Promise<void> {
const packagesToCheck = getPackagesToCheck(langCode)
const upgrades = await getUpgrades(packagesToCheck)
if (!(await confirmAction('Proceed with upgrades?'))) return
await checkAuth()
let upgradedCount = 0
for (const upgrade of upgrades) {
try {
installAndMoveLocales(
upgrade.name,
getLocalesMeta(upgrade.name).targetDir
)
CLILoggingService.success(`Upgraded ${upgrade.name} to ${upgrade.latest}`)
upgradedCount++
} catch (error) {
CLILoggingService.error(`Failed to upgrade ${upgrade.name}: ${error}`)
}
}
if (upgradedCount > 0) {
executeCommand('bun install', { cwd: process.cwd(), stdio: 'inherit' })
CLILoggingService.success(`Upgraded ${upgradedCount} locale(s)`)
}
}

View File

@@ -1,28 +1,58 @@
import { program } from 'commander'
import type { Command } from 'commander'
import { addLocaleHandler } from './handlers/addLocaleHandler'
import { listLocalesHandler } from './handlers/listLocalesHandler'
import { removeLocaleHandler } from './handlers/removeLocaleHandler'
import { loginModuleHandler } from '../modules/handlers/login-module'
import { installLocaleHandler } from './handlers/install-locale'
import { listLocalesHandler } from './handlers/list-locales'
import { publishLocaleHandler } from './handlers/publish-locale'
import { uninstallLocaleHandler } from './handlers/uninstall-locale'
import { upgradeLocaleHandler } from './handlers/upgrade-locale'
export default function setup(): void {
export default function setup(program: Command): void {
const command = program
.command('locales')
.description('Manage LifeForge language packs')
command
.command('login')
.description('Login to the locale registry')
.action(loginModuleHandler)
command
.command('list')
.description('List all installed language packs')
.action(listLocalesHandler)
command
.command('add')
.description('Download and install a language pack')
.command('install')
.alias('i')
.description('Install a language pack from the registry')
.argument('<lang>', 'Language code, e.g., en, ms, zh-CN, zh-TW')
.action(addLocaleHandler)
.action(installLocaleHandler)
command
.command('remove')
.description('Remove an installed language pack')
.command('uninstall')
.alias('un')
.description('Uninstall a language pack')
.argument('<lang>', 'Language code to remove')
.action(removeLocaleHandler)
.action(uninstallLocaleHandler)
command
.command('upgrade')
.alias('up')
.description('Upgrade language packs to latest version')
.argument(
'[lang]',
'Language code to upgrade (optional, checks all if omitted)'
)
.action(upgradeLocaleHandler)
command
.command('publish')
.description('Publish a language pack to the registry')
.argument('<lang>', 'Language code to publish from locales/')
.option(
'--official',
'Publish as official locale (requires maintainer access)'
)
.action(publishLocaleHandler)
}

View File

@@ -1,52 +0,0 @@
import fs from 'fs'
import path from 'path'
const LOCALES_DIR = 'locales'
/**
* Checks if a locale already exists in the locales directory
*/
export function localeExists(langName: string): boolean {
return fs.existsSync(path.join(LOCALES_DIR, langName))
}
/**
* Gets list of installed locales from locales directory
*/
export function getInstalledLocales(): string[] {
if (!fs.existsSync(LOCALES_DIR)) {
return []
}
return fs
.readdirSync(LOCALES_DIR, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
.filter(name => !name.startsWith('.'))
}
/**
* Validates language name format (lowercase, alphanumeric with hyphens)
*/
export function validateLocaleName(langName: string): boolean {
return /^[a-z]{2}(-[A-Z]{2})?$/.test(langName)
}
export interface LocaleInstallConfig {
langName: string
localeDir: string
repoUrl: string
tempDir: string
}
/**
* Creates locale installation configuration
*/
export function createLocaleConfig(langName: string): LocaleInstallConfig {
return {
langName,
localeDir: path.join(LOCALES_DIR, langName),
repoUrl: `https://github.com/lifeforge-app/lang-${langName}.git`,
tempDir: '.temp'
}
}

View File

@@ -1,32 +0,0 @@
import fs from 'fs'
import { executeCommand } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
import type { ModuleInstallConfig } from '../../utils/constants'
export function cloneModuleRepository(config: ModuleInstallConfig): void {
if (!fs.existsSync('.gitmodules')) {
fs.writeFileSync('.gitmodules', '')
}
CLILoggingService.progress('Cloning module repository from GitHub')
try {
executeCommand(
`git submodule add --force ${config.repoUrl} ${config.tempDir}/${config.moduleName}`,
{
exitOnError: false,
stdio: ['ignore', 'ignore', 'ignore']
}
)
CLILoggingService.success('Repository cloned successfully')
} catch (error) {
CLILoggingService.actionableError(
'Failed to clone module repository',
'Verify the repository URL is correct and accessible, or check your internet connection'
)
throw error
}
}

View File

@@ -1,108 +0,0 @@
import { execSync } from 'child_process'
import fs from 'fs'
import path from 'path'
import { executeCommand } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
export function updateGitSubmodules(modulePath?: string): void {
if (modulePath) {
CLILoggingService.progress(`Updating git submodule: ${modulePath}`)
} else {
CLILoggingService.progress('Updating all git submodules')
}
try {
const command = modulePath
? `git submodule update --init --recursive --remote ${modulePath}`
: 'git submodule update --init --recursive --remote'
executeCommand(command, {
stdio: ['ignore', 'ignore', 'ignore'],
exitOnError: false
})
CLILoggingService.success(
modulePath
? `Git submodule updated: ${modulePath}`
: 'Git submodules updated successfully'
)
} catch (error) {
CLILoggingService.actionableError(
'Failed to update git submodules',
'Check your git configuration and try again'
)
throw error
}
}
export function removeGitSubmodule(modulePath: string): void {
CLILoggingService.progress(`Removing git submodule: ${modulePath}`)
try {
execSync(`git submodule deinit -f ${modulePath}`, {
cwd: process.cwd(),
stdio: ['pipe', 'pipe', 'pipe']
})
CLILoggingService.debug('Submodule deinitialized')
execSync(`git rm -f ${modulePath}`, {
cwd: process.cwd(),
stdio: ['pipe', 'pipe', 'pipe']
})
CLILoggingService.debug('Submodule removed from git')
const gitModulesDir = path.join(
process.cwd(),
'.git',
'modules',
modulePath
)
if (fs.existsSync(gitModulesDir)) {
fs.rmSync(gitModulesDir, { recursive: true, force: true })
CLILoggingService.debug('Submodule git directory removed')
}
CLILoggingService.success(`Git submodule removed: ${modulePath}`)
} catch (error) {
CLILoggingService.warn(
`Git submodule removal failed, falling back to manual removal: ${error}`
)
throw error
}
}
export function removeGitModulesEntry(modulePath: string): void {
const gitModulesPath = path.join(process.cwd(), '.gitmodules')
if (!fs.existsSync(gitModulesPath)) {
return
}
CLILoggingService.progress('Updating .gitmodules file')
try {
let gitModulesContent = fs.readFileSync(gitModulesPath, 'utf8')
const moduleEntryRegex = new RegExp(
`\\[submodule "${modulePath.replace(
/[-/\\^$*+?.()|[\]{}]/g,
'\\$&'
)}"\\][^\\[]*`,
'g'
)
gitModulesContent = gitModulesContent.replace(moduleEntryRegex, '')
gitModulesContent = gitModulesContent.replace(/\n{3,}/g, '\n\n').trim()
if (gitModulesContent) {
fs.writeFileSync(gitModulesPath, gitModulesContent + '\n', 'utf8')
} else {
fs.unlinkSync(gitModulesPath)
}
CLILoggingService.success('.gitmodules file updated')
} catch (error) {
CLILoggingService.warn(`Failed to update .gitmodules file: ${error}`)
}
}

View File

@@ -1,88 +0,0 @@
import fs from 'fs'
import { executeCommand } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
export function checkGithubCLI(): void {
try {
executeCommand('gh --version', { stdio: 'pipe' })
} catch {
CLILoggingService.actionableError(
'GitHub CLI is not installed or not found in PATH.',
'Please install GitHub CLI from https://cli.github.com/ and ensure it is accessible from your command line.'
)
process.exit(1)
}
CLILoggingService.info('GitHub CLI is installed and ready to use.')
const authCheck = executeCommand('gh auth status', { stdio: 'pipe' })
if (!authCheck.includes('Logged in to github.com')) {
CLILoggingService.actionableError(
'GitHub CLI is not authenticated.',
'Please authenticate by running "gh auth login" and follow the prompts.'
)
process.exit(1)
}
CLILoggingService.info('GitHub CLI is authenticated.')
}
export function createGithubRepo(moduleName: string): string {
try {
executeCommand(
`gh repo create lifeforge-module-${moduleName} --public --source=./apps/${moduleName} --remote=origin --push`,
{ stdio: 'pipe' }
)
const repoLinkResult = executeCommand(`git remote get-url origin`, {
stdio: 'pipe',
cwd: `apps/${moduleName}`
})
const repoLinkMatch = repoLinkResult.match(
/https:\/\/github\.com\/(.*?\/lifeforge-module-.*?)\.git/
)
if (!repoLinkMatch) {
CLILoggingService.actionableError(
`Failed to parse GitHub repository link for module ${moduleName}.`,
'Please check the output above for any errors.'
)
process.exit(1)
}
const repoLink = repoLinkMatch[1]
CLILoggingService.success(
`GitHub repository for module ${moduleName} created and code pushed successfully.`
)
return repoLink
} catch {
CLILoggingService.actionableError(
`Failed to create GitHub repository for module ${moduleName}.`,
'Refer to the error message above for more details.'
)
process.exit(1)
}
}
export function replaceRepoWithSubmodule(
moduleName: string,
repoLink: string
): void {
const modulePath = `apps/${moduleName}`
try {
fs.rmSync(modulePath, { recursive: true, force: true })
executeCommand(`bun forge modules add ${repoLink}`)
} catch (error) {
CLILoggingService.actionableError(
`Failed to replace local module ${moduleName} with Git submodule.`,
`Error: ${error instanceof Error ? error.message : String(error)}`
)
process.exit(1)
}
}

View File

@@ -1,17 +0,0 @@
export { cloneModuleRepository } from './clone-repository'
export {
removeGitModulesEntry,
removeGitSubmodule,
updateGitSubmodules
} from './git-submodule'
export { checkForUpdates, checkGitCleanliness } from './git-status'
export type { CommitInfo } from './git-status'
export {
checkGithubCLI,
createGithubRepo,
replaceRepoWithSubmodule
} from './github-cli'

View File

@@ -1,14 +1,16 @@
// Git operations
export * from './git'
export * from './git-status'
export * from '../../../utils/github-cli'
// Module lifecycle operations
export * from './module-lifecycle'
// Migration operations
export * from './migrations'
// Module operations
export * from './install-dependencies'
export * from './module-migrations'
// Interactive prompts
export * from './prompts'
// Template operations
export * from './templates'
// Registry generation
export * from './registry'

View File

@@ -1,5 +0,0 @@
export {
generateDatabaseSchemas,
generateSchemaMigrations,
removeModuleMigrations
} from './module-migrations'

View File

@@ -1,13 +0,0 @@
export { installDependencies } from './install-dependencies'
export { processServerInjection } from './process-server-injection'
export { removeServerReferences } from './remove-server-references'
export { validateModuleStructure } from './validate-module-structure'
export {
moveModuleToApps,
removeModuleDirectory,
removeRegularDirectory
} from './move-module'

View File

@@ -1,81 +0,0 @@
import fs from 'fs'
import path from 'path'
import { executeCommand } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
import type { ModuleInstallConfig } from '../../utils/constants'
import { removeGitModulesEntry, removeGitSubmodule } from '../git/git-submodule'
export function moveModuleToApps(config: ModuleInstallConfig): void {
CLILoggingService.step('Installing module to workspace')
executeCommand(
`git mv ${config.tempDir}/${config.moduleName} ${config.moduleDir}`
)
CLILoggingService.success(
`Module ${config.author}/${config.moduleName} installed successfully`
)
let gitmodulesContent = fs.readFileSync('.gitmodules', 'utf-8')
const modulePath = `${config.tempDir}/${config.moduleName}`
gitmodulesContent = gitmodulesContent.replace(
`[submodule "${modulePath}"]`,
`[submodule "apps/${config.moduleName}"]`
)
fs.writeFileSync('.gitmodules', gitmodulesContent.trim() + '\n')
executeCommand('git add .gitmodules')
}
export function removeModuleDirectory(moduleName: string): void {
const modulePath = `apps/${moduleName}`
const moduleDir = path.join(process.cwd(), modulePath)
if (!fs.existsSync(moduleDir)) {
CLILoggingService.warn(`Module directory ${modulePath} does not exist`)
return
}
CLILoggingService.progress(`Removing module directory: ${modulePath}`)
const gitModulesPath = path.join(process.cwd(), '.gitmodules')
const isSubmodule =
fs.existsSync(gitModulesPath) &&
fs
.readFileSync(gitModulesPath, 'utf8')
.includes(`[submodule "${modulePath}"]`)
if (isSubmodule) {
try {
removeGitSubmodule(modulePath)
} catch {
removeRegularDirectory(moduleDir, modulePath)
removeGitModulesEntry(modulePath)
}
} else {
removeRegularDirectory(moduleDir, modulePath)
}
}
export function removeRegularDirectory(
moduleDir: string,
modulePath: string
): void {
try {
fs.rmSync(moduleDir, { recursive: true, force: true })
CLILoggingService.success(`Module directory removed: ${modulePath}`)
} catch (error) {
CLILoggingService.actionableError(
`Failed to remove module directory: ${modulePath}`,
'Check file permissions and ensure no processes are using the module files'
)
throw error
}
}

View File

@@ -1,43 +0,0 @@
import CLILoggingService from '@/utils/logging'
import { hasServerComponents } from '../../utils/file-system'
import { injectModuleRoute } from '../../utils/route-injection'
import { injectModuleSchema } from '../../utils/schema-injection'
export function processServerInjection(moduleName: string): void {
CLILoggingService.step('Checking for server components')
const { hasServerDir, hasServerIndex } = hasServerComponents(moduleName)
if (!hasServerDir) {
CLILoggingService.info(
`No server directory found - skipping server setup (UI-only module)`
)
return
}
if (!hasServerIndex) {
CLILoggingService.info(`No server index.ts found - skipping server setup`)
return
}
CLILoggingService.progress('Setting up server components')
try {
injectModuleRoute(moduleName)
CLILoggingService.success('Server routes configured')
} catch (error) {
CLILoggingService.warn(`Failed to inject route for ${moduleName}: ${error}`)
}
try {
injectModuleSchema(moduleName)
CLILoggingService.success('Server schema configured')
} catch (error) {
CLILoggingService.warn(
`Failed to inject schema for ${moduleName}: ${error}`
)
}
}

View File

@@ -1,24 +0,0 @@
import CLILoggingService from '@/utils/logging'
import { removeModuleRoute } from '../../utils/route-injection'
import { removeModuleSchema } from '../../utils/schema-injection'
export function removeServerReferences(moduleName: string): void {
CLILoggingService.progress('Removing server references')
try {
removeModuleRoute(moduleName)
CLILoggingService.success('Server routes removed')
} catch (error) {
CLILoggingService.warn(`Failed to remove route for ${moduleName}: ${error}`)
}
try {
removeModuleSchema(moduleName)
CLILoggingService.success('Server schema references removed')
} catch (error) {
CLILoggingService.warn(
`Failed to remove schema for ${moduleName}: ${error}`
)
}
}

View File

@@ -1,38 +0,0 @@
import { type PathConfig, validateFilePaths } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
import { type ModuleInstallConfig } from '../../utils/constants'
const MODULE_STRUCTURE_REQUIREMENTS: PathConfig[] = [
{
path: 'client',
type: 'directory'
},
{
path: 'package.json',
type: 'file'
},
{
path: 'manifest.ts',
type: 'file'
},
{
path: 'locales',
type: 'directory'
},
{
path: 'tsconfig.json',
type: 'file'
}
]
export function validateModuleStructure(config: ModuleInstallConfig): void {
CLILoggingService.step('Validating module structure')
validateFilePaths(
MODULE_STRUCTURE_REQUIREMENTS,
`${config.tempDir}/${config.moduleName}`
)
CLILoggingService.success('Module structure validated')
}

View File

@@ -0,0 +1,41 @@
import { extractModuleName } from './module-utils'
export function generateClientRegistry(modules: string[]): string {
if (modules.length === 0) {
return `// AUTO-GENERATED - DO NOT EDIT
import type { ModuleConfig } from 'shared'
export const modules: ModuleConfig[] = []
`
}
const imports = modules
.map(mod => {
const name = extractModuleName(mod)
const varName = name.replace(/-/g, '_')
return `import ${varName}Manifest from '${mod}/manifest'`
})
.join('\n')
const exports = modules
.map(mod => {
const name = extractModuleName(mod)
const varName = name.replace(/-/g, '_')
return ` ${varName}Manifest,`
})
.join('\n')
return `// AUTO-GENERATED - DO NOT EDIT
import type { ModuleConfig } from 'shared'
${imports}
export const modules: ModuleConfig[] = [
${exports}
]
`
}

View File

@@ -0,0 +1,150 @@
import fs from 'fs'
import path from 'path'
import CLILoggingService from '@/utils/logging'
import { generateClientRegistry } from './client-registry'
import {
getLifeforgeModules,
getModulePath,
moduleHasSchema
} from './module-utils'
import { generateSchemaRegistry } from './schema-registry'
import { generateServerRegistry } from './server-registry'
interface ModulePackageJson {
exports?: Record<string, string | { types?: string; default?: string }>
[key: string]: unknown
}
function generateManifestDeclaration(): string {
return `// AUTO-GENERATED - DO NOT EDIT
// This declaration file allows TypeScript to type-check module imports
// without resolving internal module aliases like @
import type { ModuleConfig } from 'shared'
declare const manifest: ModuleConfig
export default manifest
`
}
function updateModulePackageJson(modulePath: string): boolean {
const packageJsonPath = path.join(modulePath, 'package.json')
if (!fs.existsSync(packageJsonPath)) {
return false
}
const packageJson: ModulePackageJson = JSON.parse(
fs.readFileSync(packageJsonPath, 'utf-8')
)
if (!packageJson.exports) {
return false
}
let updated = false
if (packageJson.exports['./manifest']) {
const currentExport = packageJson.exports['./manifest']
if (typeof currentExport === 'string') {
packageJson.exports['./manifest'] = {
types: './manifest.d.ts',
default: currentExport
}
updated = true
} else if (typeof currentExport === 'object' && !currentExport.types) {
currentExport.types = './manifest.d.ts'
updated = true
}
}
const schemaPath = path.join(modulePath, 'server', 'schema.ts')
if (fs.existsSync(schemaPath) && !packageJson.exports['./server/schema']) {
packageJson.exports['./server/schema'] = './server/schema.ts'
updated = true
}
if (updated) {
fs.writeFileSync(
packageJsonPath,
JSON.stringify(packageJson, null, 2) + '\n'
)
}
return updated
}
export function generateModuleRegistries(): void {
CLILoggingService.progress('Generating module registries...')
const modules = getLifeforgeModules()
if (modules.length === 0) {
CLILoggingService.info('No @lifeforge/* modules found')
} else {
CLILoggingService.debug(`Found ${modules.length} module(s):`)
modules.forEach(mod => CLILoggingService.debug(` - ${mod}`))
}
const serverOutputPath = path.join(
process.cwd(),
'server/src/core/routes/generated-routes.ts'
)
const clientOutputPath = path.join(
process.cwd(),
'client/src/module-registry.ts'
)
const schemaOutputPath = path.join(process.cwd(), 'server/src/core/schema.ts')
// Generate server registry
const serverContent = generateServerRegistry(modules)
fs.mkdirSync(path.dirname(serverOutputPath), { recursive: true })
fs.writeFileSync(serverOutputPath, serverContent)
CLILoggingService.debug(`Generated: ${serverOutputPath}`)
// Generate client registry
const clientContent = generateClientRegistry(modules)
fs.mkdirSync(path.dirname(clientOutputPath), { recursive: true })
fs.writeFileSync(clientOutputPath, clientContent)
CLILoggingService.debug(`Generated: ${clientOutputPath}`)
// Generate schema registry (only for modules with schema.ts)
const modulesWithSchema = modules.filter(mod => moduleHasSchema(mod))
const schemaContent = generateSchemaRegistry(modulesWithSchema)
fs.mkdirSync(path.dirname(schemaOutputPath), { recursive: true })
fs.writeFileSync(schemaOutputPath, schemaContent)
CLILoggingService.debug(`Generated: ${schemaOutputPath}`)
// Generate manifest.d.ts for each module and update package.json
for (const mod of modules) {
const modulePath = getModulePath(mod)
if (modulePath) {
const declarationPath = path.join(modulePath, 'manifest.d.ts')
const declarationContent = generateManifestDeclaration()
fs.writeFileSync(declarationPath, declarationContent)
CLILoggingService.debug(`Generated: ${declarationPath}`)
const updated = updateModulePackageJson(modulePath)
if (updated) {
CLILoggingService.debug(
`Updated: ${path.join(modulePath, 'package.json')}`
)
}
}
}
CLILoggingService.success('Module registries generated')
}

View File

@@ -0,0 +1,11 @@
export { generateClientRegistry } from './client-registry'
export {
getLifeforgeModules,
getModulePath,
moduleHasSchema
} from './module-utils'
export { generateSchemaRegistry } from './schema-registry'
export { generateServerRegistry } from './server-registry'

View File

@@ -0,0 +1,62 @@
import fs from 'fs'
import path from 'path'
const LIFEFORGE_SCOPE = '@lifeforge/'
interface PackageJson {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}
export function getLifeforgeModules(): string[] {
const packageJsonPath = path.join(process.cwd(), 'package.json')
const packageJson: PackageJson = JSON.parse(
fs.readFileSync(packageJsonPath, 'utf-8')
)
const allDeps = {
...packageJson.dependencies,
...packageJson.devDependencies
}
return Object.keys(allDeps).filter(dep => dep.startsWith(LIFEFORGE_SCOPE))
}
export function extractModuleName(packageName: string): string {
const withoutScope = packageName.replace(LIFEFORGE_SCOPE, '')
if (withoutScope.startsWith('lifeforge--')) {
return withoutScope.replace('lifeforge--', '')
}
return withoutScope
}
export function getModulePath(packageName: string): string | null {
const nodeModulesPath = path.join(process.cwd(), 'node_modules', packageName)
try {
const realPath = fs.realpathSync(nodeModulesPath)
if (realPath.includes('/apps/')) {
return realPath
}
return nodeModulesPath
} catch {
return null
}
}
export function moduleHasSchema(packageName: string): boolean {
const modulePath = getModulePath(packageName)
if (!modulePath) {
return false
}
const schemaPath = path.join(modulePath, 'server', 'schema.ts')
return fs.existsSync(schemaPath)
}

View File

@@ -0,0 +1,25 @@
import { extractModuleName } from './module-utils'
export function generateSchemaRegistry(modulesWithSchema: string[]): string {
const moduleSchemas = modulesWithSchema
.map(mod => {
const name = extractModuleName(mod)
return ` ${name}: (await import('${mod}/server/schema')).default,`
})
.join('\n')
return `// AUTO-GENERATED - DO NOT EDIT
import flattenSchemas from '@functions/utils/flattenSchema'
export const SCHEMAS = {
user: (await import('@lib/user/schema')).default,
api_keys: (await import('@lib/apiKeys/schema')).default,
${moduleSchemas}
}
const COLLECTION_SCHEMAS = flattenSchemas(SCHEMAS)
export default COLLECTION_SCHEMAS
`
}

View File

@@ -0,0 +1,31 @@
import { extractModuleName } from './module-utils'
export function generateServerRegistry(modules: string[]): string {
if (modules.length === 0) {
return `// AUTO-GENERATED - DO NOT EDIT
import { forgeRouter } from '@functions/routes'
const appRoutes = forgeRouter({})
export default appRoutes
`
}
const imports = modules
.map(mod => {
const name = extractModuleName(mod)
return ` ${name}: (await import('${mod}/server')).default,`
})
.join('\n')
return `// AUTO-GENERATED - DO NOT EDIT
import { forgeRouter } from '@functions/routes'
const appRoutes = forgeRouter({
${imports}
})
export default appRoutes
`
}

View File

@@ -1,68 +0,0 @@
import fs from 'fs'
import CLILoggingService from '@/utils/logging'
import { checkRunningPBInstances } from '@/utils/pocketbase'
import { cloneModuleRepository, updateGitSubmodules } from '../functions/git'
import { generateSchemaMigrations } from '../functions/migrations'
import {
installDependencies,
moveModuleToApps,
processServerInjection,
validateModuleStructure
} from '../functions/module-lifecycle'
import { cleanup, moduleExists } from '../utils/file-system'
import { createModuleConfig, validateRepositoryPath } from '../utils/validation'
export async function addModuleHandler(repoPath: string): Promise<void> {
checkRunningPBInstances()
if (!validateRepositoryPath(repoPath)) {
CLILoggingService.actionableError(
'Invalid module repository path format',
'Use the format <author>/<module-name>, e.g., "lifeforge-app/wallet"'
)
process.exit(1)
}
const config = createModuleConfig(repoPath)
CLILoggingService.step(`Adding module ${repoPath} from ${config.author}`)
cleanup(config.tempDir)
fs.mkdirSync(config.tempDir)
try {
if (moduleExists(config.moduleName)) {
CLILoggingService.actionableError(
`Module "${config.moduleName}" already exists in workspace`,
`Remove it first with "bun forge module remove ${config.moduleName}" if you want to re-add it`
)
throw new Error('Module already exists')
}
cloneModuleRepository(config)
validateModuleStructure(config)
moveModuleToApps(config)
updateGitSubmodules(`apps/${config.moduleName}`)
processServerInjection(config.moduleName)
installDependencies()
if (fs.existsSync(`${config.moduleDir}/server/schema.ts`)) {
generateSchemaMigrations(config.moduleName)
}
CLILoggingService.success(
`Module ${repoPath} setup completed successfully! Start the system with "bun forge dev"`
)
cleanup(config.tempDir)
} catch (error) {
CLILoggingService.actionableError(
'Module installation failed',
'Check the error details above and try again'
)
CLILoggingService.debug(`Installation error: ${error}`)
cleanup(config.tempDir)
process.exit(1)
}
}

View File

@@ -5,8 +5,8 @@ import { runDatabaseMigrations } from '@/commands/db/functions/database-initiali
import CLILoggingService from '@/utils/logging'
import { checkRunningPBInstances } from '@/utils/pocketbase'
import { generateDatabaseSchemas } from '../functions/migrations'
import { installDependencies } from '../functions/module-lifecycle'
import { installDependencies } from '../functions/install-dependencies'
import { generateDatabaseSchemas } from '../functions/module-migrations'
import {
checkModuleTypeAvailability,
promptForModuleName,
@@ -15,14 +15,13 @@ import {
promptModuleType,
selectIcon
} from '../functions/prompts'
import { generateModuleRegistries } from '../functions/registry/generator'
import {
type ModuleMetadata,
copyTemplateFiles,
initializeGitRepository,
registerHandlebarsHelpers
} from '../functions/templates'
import { injectModuleRoute } from '../utils/route-injection'
import { injectModuleSchema } from '../utils/schema-injection'
registerHandlebarsHelpers()
@@ -57,8 +56,8 @@ export async function createModuleHandler(moduleName?: string): Promise<void> {
installDependencies(`${process.cwd()}/apps`)
injectModuleRoute(camelizedModuleName)
injectModuleSchema(camelizedModuleName)
// Regenerate registries to include the new module
generateModuleRegistries()
if (
fs.existsSync(

View File

@@ -0,0 +1,155 @@
import fs from 'fs'
import path from 'path'
import { executeCommand } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
import { generateModuleRegistries } from '../functions/registry/generator'
interface PackageJson {
name?: string
version?: string
dependencies?: Record<string, string>
[key: string]: unknown
}
function extractModuleName(packageName: string): string {
// @lifeforge/lifeforge--calendar -> lifeforge--calendar
// @lifeforge/melvin--myapp -> melvin--myapp
return packageName.replace('@lifeforge/', '')
}
export async function installModuleHandler(moduleName: string): Promise<void> {
// Normalize module name
const fullPackageName = moduleName.startsWith('@lifeforge/')
? moduleName
: `@lifeforge/${moduleName}`
const shortName = extractModuleName(fullPackageName)
const appsDir = path.join(process.cwd(), 'apps')
const targetDir = path.join(appsDir, shortName)
CLILoggingService.info(`Installing module ${fullPackageName}...`)
// Check if module already exists in apps/
if (fs.existsSync(targetDir)) {
CLILoggingService.actionableError(
`Module already exists at apps/${shortName}`,
`Remove it first with: bun forge modules remove ${shortName}`
)
return
}
// Create apps directory if it doesn't exist
if (!fs.existsSync(appsDir)) {
fs.mkdirSync(appsDir, { recursive: true })
}
CLILoggingService.progress('Fetching module from registry...')
try {
// Use bun to install the package to node_modules
executeCommand(`bun add ${fullPackageName}@latest`, {
cwd: process.cwd(),
stdio: 'inherit'
})
// Find the installed package in node_modules
const installedPath = path.join(
process.cwd(),
'node_modules',
fullPackageName
)
if (!fs.existsSync(installedPath)) {
throw new Error(`Failed to install ${fullPackageName}`)
}
CLILoggingService.progress('Moving module to apps/...')
// Copy from node_modules to apps/
fs.cpSync(installedPath, targetDir, { recursive: true })
CLILoggingService.success(`Module copied to apps/${shortName}`)
// Update root package.json to use workspace:*
CLILoggingService.progress('Updating package.json...')
const rootPackageJsonPath = path.join(process.cwd(), 'package.json')
const rootPackageJson: PackageJson = JSON.parse(
fs.readFileSync(rootPackageJsonPath, 'utf-8')
)
if (!rootPackageJson.dependencies) {
rootPackageJson.dependencies = {}
}
// Change to workspace reference
rootPackageJson.dependencies[fullPackageName] = 'workspace:*'
fs.writeFileSync(
rootPackageJsonPath,
JSON.stringify(rootPackageJson, null, 2) + '\n'
)
CLILoggingService.success('Updated root package.json')
// Run bun install to create symlinks
CLILoggingService.progress('Linking workspace...')
// Remove the node_modules copy so bun creates a proper symlink
const nodeModulesPath = path.join(
process.cwd(),
'node_modules',
fullPackageName
)
if (fs.existsSync(nodeModulesPath)) {
fs.rmSync(nodeModulesPath, { recursive: true, force: true })
}
executeCommand('bun install', {
cwd: process.cwd(),
stdio: 'inherit'
})
// Generate module registries
CLILoggingService.progress('Generating module registries...')
generateModuleRegistries()
// Generate database migrations if the module has a schema
const schemaPath = path.join(targetDir, 'server', 'schema.ts')
if (fs.existsSync(schemaPath)) {
CLILoggingService.progress('Generating database migrations...')
try {
executeCommand(`bun forge db push ${shortName}`, {
cwd: process.cwd(),
stdio: 'inherit'
})
CLILoggingService.success('Database migrations generated')
} catch {
CLILoggingService.warn(
'Failed to generate database migrations. You may need to run "bun forge db migrations generate" manually.'
)
}
}
CLILoggingService.success(
`Module ${fullPackageName} installed successfully!`
)
CLILoggingService.info(`Location: apps/${shortName}`)
} catch (error) {
CLILoggingService.actionableError(
`Failed to install ${fullPackageName}`,
'Make sure the module exists in the registry'
)
throw error
}
}

View File

@@ -0,0 +1,38 @@
import { confirmAction } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
import {
checkAuth,
getRegistryUrl,
openRegistryLogin
} from '../../../utils/registry'
export async function loginModuleHandler(): Promise<void> {
CLILoggingService.progress('Checking registry authentication...')
const auth = await checkAuth()
if (auth.authenticated && auth.username) {
CLILoggingService.success(`Already authenticated as ${auth.username}`)
const reLogin = await confirmAction('Would you like to login again?')
if (!reLogin) {
return
}
}
CLILoggingService.info('Opening registry login page...')
openRegistryLogin()
const registry = getRegistryUrl()
CLILoggingService.info('Please follow these steps to complete login:')
CLILoggingService.info('1. Log in with GitHub on the registry page')
CLILoggingService.info('2. Copy your token from the registry UI')
CLILoggingService.info('3. Run the following command:')
CLILoggingService.info(
`npm config set //${registry.replace('http://', '').replace(/\/$/, '')}/:_authToken "YOUR_TOKEN"`
)
}

View File

@@ -0,0 +1,283 @@
import fs from 'fs'
import kebabCase from 'lodash/kebabCase'
import path from 'path'
import { executeCommand } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
import {
getGithubUser,
validateMaintainerAccess
} from '../../../utils/github-cli'
import { checkAuth } from '../../../utils/registry'
import { generateModuleRegistries } from '../functions/registry/generator'
interface PackageJson {
name?: string
version?: string
exports?: Record<string, string | { types?: string; default?: string }>
[key: string]: unknown
}
function toNewFolderName(oldName: string, username?: string): string {
// codeTime -> lifeforge--code-time (kebab-case)
// or with username: invoiceMaker -> melvinchia3636--invoice-maker
const normalized = kebabCase(oldName)
if (username) {
return `${username}--${normalized}`
}
if (normalized.startsWith('lifeforge-')) {
// Already has lifeforge prefix, just add the extra dash
return normalized.replace('lifeforge-', 'lifeforge--')
}
return `lifeforge--${normalized}`
}
function toPackageName(folderName: string): string {
// lifeforge--code-time -> @lifeforge/lifeforge--code-time
// melvinchia3636--invoice-maker -> @lifeforge/melvinchia3636--invoice-maker
return `@lifeforge/${folderName}`
}
function getUnmigratedModules(): string[] {
const appsDir = path.join(process.cwd(), 'apps')
if (!fs.existsSync(appsDir)) {
return []
}
const entries = fs.readdirSync(appsDir, { withFileTypes: true })
return entries
.filter(entry => entry.isDirectory())
.filter(entry => !entry.name.includes('--'))
.filter(entry => !entry.name.startsWith('.'))
.map(entry => entry.name)
}
async function migrateSingleModule(
moduleName: string,
username?: string,
skipGenHandler = false
): Promise<boolean> {
const appsDir = path.join(process.cwd(), 'apps')
const oldPath = path.join(appsDir, moduleName)
// Check if module exists
if (!fs.existsSync(oldPath)) {
CLILoggingService.warn(`Module "${moduleName}" not found in apps/`)
return false
}
// Check if already migrated
if (moduleName.startsWith('lifeforge--')) {
CLILoggingService.debug(`Module "${moduleName}" already migrated, skipping`)
return false
}
const newFolderName = toNewFolderName(moduleName, username)
const newPath = path.join(appsDir, newFolderName)
const packageName = toPackageName(newFolderName)
CLILoggingService.step(`Migrating "${moduleName}" → "${newFolderName}"`)
try {
// Step 1: Rename folder
if (fs.existsSync(newPath)) {
CLILoggingService.warn(
`Target folder "${newFolderName}" already exists, skipping`
)
return false
}
fs.renameSync(oldPath, newPath)
// Step 2: Remove .git submodule reference
const gitPath = path.join(newPath, '.git')
if (fs.existsSync(gitPath)) {
const gitStat = fs.statSync(gitPath)
if (gitStat.isFile()) {
fs.unlinkSync(gitPath)
} else {
fs.rmSync(gitPath, { recursive: true, force: true })
}
}
// Step 3: Update package.json
const packageJsonPath = path.join(newPath, 'package.json')
const packageJson: PackageJson = JSON.parse(
fs.readFileSync(packageJsonPath, 'utf-8')
)
packageJson.name = packageName
if (packageJson.version && !packageJson.version.match(/^\d+\.\d+\.\d+/)) {
packageJson.version = '0.1.0'
}
// Populate author if missing
if (!packageJson.author) {
CLILoggingService.progress(
'Fetching GitHub user details for author field...'
)
const user = getGithubUser()
if (user) {
packageJson.author = `${user.name} <${user.email}>`
CLILoggingService.success(`Set author to: ${packageJson.author}`)
} else {
CLILoggingService.warn(
'Could not fetch GitHub user details for author field'
)
}
}
const hasServerIndex = fs.existsSync(
path.join(newPath, 'server', 'index.ts')
)
const hasSchema = fs.existsSync(path.join(newPath, 'server', 'schema.ts'))
packageJson.exports = {
...(hasServerIndex && { './server': './server/index.ts' }),
'./manifest': './manifest.ts',
...(hasSchema && { './server/schema': './server/schema.ts' })
}
fs.writeFileSync(
packageJsonPath,
JSON.stringify(packageJson, null, 2) + '\n'
)
// Step 4: Add to root package.json
const rootPackageJsonPath = path.join(process.cwd(), 'package.json')
const rootPackageJson = JSON.parse(
fs.readFileSync(rootPackageJsonPath, 'utf-8')
)
if (!rootPackageJson.dependencies) {
rootPackageJson.dependencies = {}
}
rootPackageJson.dependencies[packageName] = 'workspace:*'
fs.writeFileSync(
rootPackageJsonPath,
JSON.stringify(rootPackageJson, null, 2) + '\n'
)
CLILoggingService.success(`Migrated "${moduleName}" → "${packageName}"`)
// Only run bun install and gen if not batching
if (!skipGenHandler) {
executeCommand('bun install', {
cwd: process.cwd(),
stdio: 'inherit'
})
generateModuleRegistries()
}
return true
} catch (error) {
CLILoggingService.error(`Failed to migrate "${moduleName}": ${error}`)
return false
}
}
export async function migrateModuleHandler(
folderName?: string,
options?: { official?: boolean }
): Promise<void> {
// Check authentication first
CLILoggingService.progress('Checking registry authentication...')
const auth = await checkAuth()
if (!auth.authenticated || !auth.username) {
CLILoggingService.actionableError(
'Authentication required to migrate modules',
'Run: bun forge modules login'
)
process.exit(1)
}
CLILoggingService.success(`Authenticated as ${auth.username}`)
let username = auth.username
if (options?.official) {
const isMaintainer = validateMaintainerAccess(auth.username)
if (!isMaintainer) {
CLILoggingService.actionableError(
'Maintainer access required',
'You must have maintainer access to lifeforge-app/lifeforge to migrate as official module'
)
process.exit(1)
}
username = 'lifeforge' // Use lifeforge as the "username" prefix for official modules
}
// If no folder specified, migrate all unmigrated modules
if (!folderName) {
const unmigrated = getUnmigratedModules()
if (unmigrated.length === 0) {
CLILoggingService.info('No unmigrated modules found in apps/')
return
}
CLILoggingService.step(`Found ${unmigrated.length} unmigrated module(s):`)
unmigrated.forEach(mod => CLILoggingService.info(` - ${mod}`))
let migratedCount = 0
for (const mod of unmigrated) {
const success = await migrateSingleModule(mod, username, true)
if (success) {
migratedCount++
}
}
// Run bun install and gen once at the end
if (migratedCount > 0) {
CLILoggingService.progress('Linking workspaces...')
executeCommand('bun install', {
cwd: process.cwd(),
stdio: 'inherit'
})
CLILoggingService.progress('Generating registries...')
generateModuleRegistries()
CLILoggingService.success(`Migrated ${migratedCount} module(s)`)
}
return
}
// Normalize folder name (remove apps/ prefix if present)
const moduleName = folderName.replace(/^apps\//, '')
await migrateSingleModule(moduleName, username)
}

View File

@@ -1,30 +1,281 @@
import fs from 'fs'
import path from 'path'
import { confirmAction, executeCommand } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
import { checkRunningPBInstances } from '@/utils/pocketbase'
import {
checkGitCleanliness,
checkGithubCLI,
createGithubRepo,
replaceRepoWithSubmodule
} from '../functions/git'
import { getInstalledModules } from '../utils/file-system'
checkAuth,
getRegistryUrl,
openRegistryLogin
} from '../../../utils/registry'
import { validateMaintainerAccess } from '../functions'
export async function publishModuleHandler(moduleName: string): Promise<void> {
const availableModules = getInstalledModules()
const LIFEFORGE_SCOPE = '@lifeforge'
if (!availableModules.includes(moduleName)) {
interface PackageJson {
name?: string
version?: string
description?: string
exports?: Record<string, unknown>
[key: string]: unknown
}
interface ModuleValidationResult {
valid: boolean
errors: string[]
warnings: string[]
}
function validateModuleStructure(modulePath: string): ModuleValidationResult {
const errors: string[] = []
const warnings: string[] = []
// Check package.json
const packageJsonPath = path.join(modulePath, 'package.json')
if (!fs.existsSync(packageJsonPath)) {
errors.push('Missing package.json')
return { valid: false, errors, warnings }
}
const packageJson: PackageJson = JSON.parse(
fs.readFileSync(packageJsonPath, 'utf-8')
)
// Check name follows @lifeforge/<username>--<module> pattern
if (!packageJson.name) {
errors.push('package.json is missing "name" field')
} else if (!packageJson.name.startsWith(`${LIFEFORGE_SCOPE}/`)) {
errors.push(`Package name must start with "${LIFEFORGE_SCOPE}/"`)
} else {
const nameWithoutScope = packageJson.name.replace(`${LIFEFORGE_SCOPE}/`, '')
if (!nameWithoutScope.includes('--')) {
errors.push(
'Package name must follow format @lifeforge/<username>--<module>'
)
}
}
// Check version is semver
if (!packageJson.version) {
errors.push('package.json is missing "version" field')
} else if (!packageJson.version.match(/^\d+\.\d+\.\d+/)) {
errors.push('Version must be valid semver (e.g., 0.1.0)')
}
// Check exports field
if (!packageJson.exports) {
errors.push('package.json is missing "exports" field')
} else {
if (!packageJson.exports['./manifest']) {
errors.push('exports must include "./manifest"')
}
}
// Check manifest.ts exists
const manifestPath = path.join(modulePath, 'manifest.ts')
if (!fs.existsSync(manifestPath)) {
errors.push('Missing manifest.ts')
}
// Check client directory
const clientPath = path.join(modulePath, 'client')
if (!fs.existsSync(clientPath)) {
warnings.push('No client/ directory found')
}
// Check locales directory
const localesPath = path.join(modulePath, 'locales')
if (!fs.existsSync(localesPath)) {
warnings.push('No locales/ directory found')
}
// Check server if exports reference it
if (packageJson.exports?.['./server']) {
const serverIndexPath = path.join(modulePath, 'server', 'index.ts')
if (!fs.existsSync(serverIndexPath)) {
errors.push('exports references "./server" but server/index.ts not found')
}
}
if (packageJson.exports?.['./server/schema']) {
const schemaPath = path.join(modulePath, 'server', 'schema.ts')
if (!fs.existsSync(schemaPath)) {
errors.push(
'exports references "./server/schema" but server/schema.ts not found'
)
}
}
return {
valid: errors.length === 0,
errors,
warnings
}
}
async function promptNpmLogin(): Promise<boolean> {
CLILoggingService.info('You need to authenticate with the registry first.')
CLILoggingService.info(
'The new authentication flow requires a browser login.'
)
const shouldLogin = await confirmAction(
'Would you like to open the login page now?'
)
if (shouldLogin) {
openRegistryLogin()
CLILoggingService.info('After logging in and copying your token, run:')
CLILoggingService.info(' bun forge modules login')
return false // Return false to stop execution and let user run login command
}
return false
}
export async function publishModuleHandler(
folderName: string,
options?: { official?: boolean }
): Promise<void> {
// Normalize folder name
const moduleName = folderName.replace(/^apps\//, '')
const modulePath = path.join(process.cwd(), 'apps', moduleName)
// Check module exists
if (!fs.existsSync(modulePath)) {
CLILoggingService.actionableError(
`Module ${moduleName} is not installed.`,
`Available modules: ${availableModules.join(', ')}`
`Module "${moduleName}" not found in apps/`,
'Make sure the module exists in the apps directory'
)
process.exit(1)
}
checkRunningPBInstances()
checkGitCleanliness(moduleName)
checkGithubCLI()
CLILoggingService.step(`Validating module "${moduleName}"...`)
const repoLink = createGithubRepo(moduleName)
// Validate structure
const validation = validateModuleStructure(modulePath)
replaceRepoWithSubmodule(moduleName, repoLink)
if (validation.warnings.length > 0) {
validation.warnings.forEach(warning => {
CLILoggingService.warn(`${warning}`)
})
}
if (!validation.valid) {
CLILoggingService.error('Module validation failed:')
validation.errors.forEach(error => {
CLILoggingService.error(`${error}`)
})
process.exit(1)
}
CLILoggingService.success('Module structure is valid')
// Check authentication
CLILoggingService.progress('Checking registry authentication...')
let auth = await checkAuth()
if (!auth.authenticated) {
const loggedIn = await promptNpmLogin()
if (!loggedIn) {
CLILoggingService.actionableError(
'Authentication required to publish',
`Run: bun forge modules login`
)
process.exit(1)
}
auth = await checkAuth()
if (!auth.authenticated) {
CLILoggingService.error('Authentication failed')
process.exit(1)
}
}
CLILoggingService.success(`Authenticated as ${auth.username}`)
// Read package.json for display
const packageJson: PackageJson = JSON.parse(
fs.readFileSync(path.join(modulePath, 'package.json'), 'utf-8')
)
// Verify authenticated user matches package name prefix
const nameWithoutScope = (packageJson.name || '').replace(
`${LIFEFORGE_SCOPE}/`,
''
)
const usernamePrefix = nameWithoutScope.split('--')[0]
if (usernamePrefix && usernamePrefix !== auth.username) {
// Check if publishing as official module and prefix is lifeforge
if (options?.official && usernamePrefix === 'lifeforge') {
const isMaintainer = validateMaintainerAccess(auth.username || '')
if (!isMaintainer) {
CLILoggingService.actionableError(
'Maintainer access required',
'You must have maintainer access to lifeforge-app/lifeforge to publish official modules'
)
process.exit(1)
}
// Pass validation if maintainer
} else {
CLILoggingService.actionableError(
`Cannot publish as "${auth.username}" - package belongs to "${usernamePrefix}"`,
`You can only publish packages starting with @lifeforge/${auth.username}--`
)
process.exit(1)
}
}
CLILoggingService.info(`Package: ${packageJson.name}@${packageJson.version}`)
CLILoggingService.info(`Description: ${packageJson.description || '(none)'}`)
// Confirm publish
const shouldPublish = await confirmAction(
`Publish ${packageJson.name}@${packageJson.version} to registry?`
)
if (!shouldPublish) {
CLILoggingService.info('Publish cancelled')
return
}
// Publish to registry
CLILoggingService.progress('Publishing to registry...')
try {
executeCommand(`npm publish --registry ${getRegistryUrl()}`, {
cwd: modulePath,
stdio: 'inherit'
})
CLILoggingService.success(
`Published ${packageJson.name}@${packageJson.version} to registry!`
)
CLILoggingService.info('')
CLILoggingService.info('Others can install with:')
CLILoggingService.info(` bun forge modules install ${packageJson.name}`)
} catch (error) {
CLILoggingService.error(`Publish failed: ${error}`)
process.exit(1)
}
}

View File

@@ -1,47 +0,0 @@
import CLILoggingService from '@/utils/logging'
import { removeModuleMigrations } from '../functions/migrations'
import {
removeModuleDirectory,
removeServerReferences
} from '../functions/module-lifecycle'
import { selectModuleToRemove } from '../functions/prompts'
import { moduleExists } from '../utils/file-system'
export async function removeModuleHandler(moduleName?: string): Promise<void> {
CLILoggingService.step('Starting module removal process')
if (!moduleName) {
moduleName = await selectModuleToRemove()
}
if (!moduleExists(moduleName)) {
CLILoggingService.actionableError(
`Module "${moduleName}" does not exist in workspace`,
'Use "bun forge module list" to see available modules'
)
process.exit(1)
}
CLILoggingService.step(`Removing module: ${moduleName}`)
try {
removeServerReferences(moduleName)
await removeModuleMigrations(moduleName)
removeModuleDirectory(moduleName)
CLILoggingService.success(`Module "${moduleName}" removed successfully`)
CLILoggingService.info(
'Restart the system with "bun forge dev" to see the changes'
)
} catch (error) {
CLILoggingService.actionableError(
'Module removal failed',
'Check the error details above and ensure you have proper file permissions'
)
CLILoggingService.debug(`Removal error: ${error}`)
process.exit(1)
}
}

View File

@@ -0,0 +1,125 @@
import fs from 'fs'
import path from 'path'
import { executeCommand } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
import { generateModuleRegistries } from '../functions/registry/generator'
interface PackageJson {
dependencies?: Record<string, string>
[key: string]: unknown
}
function extractModuleName(packageName: string): string {
// @lifeforge/lifeforge--calendar -> lifeforge--calendar
// @lifeforge/melvin--myapp -> melvin--myapp
return packageName.replace('@lifeforge/', '')
}
function findModulePackageName(
shortName: string,
dependencies: Record<string, string>
): string | null {
// Try to find the full package name from dependencies
for (const dep of Object.keys(dependencies)) {
if (dep.startsWith('@lifeforge/') && extractModuleName(dep) === shortName) {
return dep
}
}
return null
}
export async function uninstallModuleHandler(
moduleName: string
): Promise<void> {
const rootPackageJsonPath = path.join(process.cwd(), 'package.json')
const rootPackageJson: PackageJson = JSON.parse(
fs.readFileSync(rootPackageJsonPath, 'utf-8')
)
// Determine the full package name and short name
let fullPackageName: string
let shortName: string
if (moduleName.startsWith('@lifeforge/')) {
fullPackageName = moduleName
shortName = extractModuleName(moduleName)
} else {
shortName = moduleName
const found = findModulePackageName(
shortName,
rootPackageJson.dependencies || {}
)
if (!found) {
CLILoggingService.actionableError(
`Module "${shortName}" is not installed`,
'Run "bun forge modules list" to see installed modules'
)
return
}
fullPackageName = found
}
const appsDir = path.join(process.cwd(), 'apps')
const targetDir = path.join(appsDir, shortName)
CLILoggingService.info(`Uninstalling module ${fullPackageName}...`)
// Check if module exists in apps/
if (!fs.existsSync(targetDir)) {
CLILoggingService.warn(`Module not found in apps/${shortName}`)
} else {
// Remove from apps/
CLILoggingService.progress('Removing module files...')
fs.rmSync(targetDir, { recursive: true, force: true })
CLILoggingService.success(`Removed apps/${shortName}`)
}
// Remove from package.json
if (rootPackageJson.dependencies?.[fullPackageName]) {
CLILoggingService.progress('Updating package.json...')
delete rootPackageJson.dependencies[fullPackageName]
fs.writeFileSync(
rootPackageJsonPath,
JSON.stringify(rootPackageJson, null, 2) + '\n'
)
CLILoggingService.success('Updated root package.json')
}
// Remove from node_modules
const nodeModulesPath = path.join(
process.cwd(),
'node_modules',
fullPackageName
)
if (fs.existsSync(nodeModulesPath)) {
fs.rmSync(nodeModulesPath, { recursive: true, force: true })
}
// Run bun install to clean up
CLILoggingService.progress('Cleaning up...')
executeCommand('bun install', {
cwd: process.cwd(),
stdio: 'inherit'
})
// Regenerate module registries
CLILoggingService.progress('Regenerating module registries...')
generateModuleRegistries()
CLILoggingService.success(
`Module ${fullPackageName} uninstalled successfully!`
)
}

View File

@@ -1,90 +0,0 @@
import fs from 'fs'
import { confirmAction, executeCommand } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
import { type CommitInfo, checkForUpdates } from '../functions/git'
import { getInstalledModules, moduleExists } from '../utils/file-system'
async function updateSingleModule(moduleName: string): Promise<void> {
CLILoggingService.step(`Checking for updates in module: ${moduleName}`)
const availableUpdates: CommitInfo[] = await checkForUpdates(moduleName)
if (availableUpdates.length === 0) {
CLILoggingService.info(`Module "${moduleName}" is already up to date`)
return
}
CLILoggingService.info(
`Found ${availableUpdates.length} new commit(s) for "${moduleName}":`
)
CLILoggingService.newline()
availableUpdates.forEach((commit, index) => {
console.log(
` ${(index + 1).toString().padStart(2)}. ${commit.hash} - ${commit.message}`
)
})
CLILoggingService.newline()
const shouldUpdate = await confirmAction(
`Do you want to update module "${moduleName}"?`
)
if (!shouldUpdate) {
CLILoggingService.info(`Skipping update for module "${moduleName}"`)
return
}
CLILoggingService.progress(`Updating module: ${moduleName}`)
try {
executeCommand(
`cd apps/${moduleName} && git pull origin main && bun install --linker isolated`
)
if (fs.existsSync(`apps/${moduleName}/server/schema.ts`)) {
executeCommand(`bun forge db push ${moduleName}`)
}
CLILoggingService.success(`Successfully updated module: ${moduleName}`)
} catch (error) {
CLILoggingService.error(`Failed to update module "${moduleName}": ${error}`)
}
}
export async function updateModuleHandler(moduleName?: string): Promise<void> {
if (!moduleName) {
const modules = getInstalledModules()
if (modules.length === 0) {
CLILoggingService.info('No modules installed to update')
return
}
for (const mod of modules) {
await updateSingleModule(mod)
if (mod !== modules[modules.length - 1]) {
CLILoggingService.newline()
}
}
return
}
if (!moduleExists(moduleName)) {
CLILoggingService.actionableError(
`Module "${moduleName}" does not exist in workspace`,
'Use "bun forge module list" to see available modules'
)
process.exit(1)
}
await updateSingleModule(moduleName)
}

View File

@@ -0,0 +1,292 @@
import fs from 'fs'
import path from 'path'
import { confirmAction, executeCommand } from '@/utils/helpers'
import CLILoggingService from '@/utils/logging'
import {
checkAuth,
getRegistryUrl,
openRegistryLogin
} from '../../../utils/registry'
import { generateModuleRegistries } from '../functions/registry/generator'
const LIFEFORGE_SCOPE = '@lifeforge/'
interface PackageJson {
name?: string
version?: string
dependencies?: Record<string, string>
[key: string]: unknown
}
function getInstalledModules(): {
name: string
version: string
folder: string
}[] {
const rootPackageJsonPath = path.join(process.cwd(), 'package.json')
const rootPackageJson: PackageJson = JSON.parse(
fs.readFileSync(rootPackageJsonPath, 'utf-8')
)
const modules: { name: string; version: string; folder: string }[] = []
for (const [dep, version] of Object.entries(
rootPackageJson.dependencies || {}
)) {
if (dep.startsWith(LIFEFORGE_SCOPE) && version === 'workspace:*') {
// Get version from the module's package.json
const folderName = dep.replace(LIFEFORGE_SCOPE, '')
const modulePath = path.join(
process.cwd(),
'apps',
folderName,
'package.json'
)
if (fs.existsSync(modulePath)) {
const modulePackageJson = JSON.parse(
fs.readFileSync(modulePath, 'utf-8')
)
modules.push({
name: dep,
version: modulePackageJson.version || '0.0.0',
folder: folderName
})
}
}
}
return modules
}
async function getLatestVersion(packageName: string): Promise<string | null> {
const registry = getRegistryUrl()
try {
// Query local Verdaccio registry using bun
const response = await fetch(`${registry}${packageName}`)
// If unauthorized, check auth and prompt login
if (response.status === 401 || response.status === 403) {
const auth = await checkAuth()
if (!auth.authenticated) {
CLILoggingService.info(
`Authentication required to check updates for ${packageName}`
)
const shouldLogin = await confirmAction(
'Would you like to open the login page now?'
)
if (shouldLogin) {
openRegistryLogin()
CLILoggingService.info(
'After logging in and copying your token, run: bun forge modules login'
)
CLILoggingService.info('Then try upgrading again.')
return null
}
}
}
if (!response.ok) {
return null
}
const data = (await response.json()) as {
'dist-tags'?: { latest?: string }
}
return data['dist-tags']?.latest || null
} catch {
return null
}
}
function compareVersions(current: string, latest: string): number {
const currentParts = current.split('.').map(Number)
const latestParts = latest.split('.').map(Number)
for (let i = 0; i < 3; i++) {
const c = currentParts[i] || 0
const l = latestParts[i] || 0
if (l > c) {
return 1
}
if (l < c) {
return -1
}
}
return 0
}
async function upgradeModule(
packageName: string,
folder: string,
currentVersion: string
): Promise<boolean> {
const latestVersion = await getLatestVersion(packageName)
if (!latestVersion) {
CLILoggingService.warn(`Could not check registry for ${packageName}`)
return false
}
if (compareVersions(currentVersion, latestVersion) >= 0) {
CLILoggingService.info(`${packageName}@${currentVersion} is up to date`)
return false
}
CLILoggingService.info(
`Update available: ${packageName} ${currentVersion}${latestVersion}`
)
const shouldUpgrade = await confirmAction(
`Upgrade ${packageName}? This will replace your local copy.`
)
if (!shouldUpgrade) {
CLILoggingService.info(`Skipping ${packageName}`)
return false
}
const appsDir = path.join(process.cwd(), 'apps')
const modulePath = path.join(appsDir, folder)
const backupPath = path.join(appsDir, `${folder}.backup`)
try {
// Backup current module
CLILoggingService.progress(`Backing up ${folder}...`)
if (fs.existsSync(backupPath)) {
fs.rmSync(backupPath, { recursive: true, force: true })
}
fs.cpSync(modulePath, backupPath, { recursive: true })
// Remove current module
fs.rmSync(modulePath, { recursive: true, force: true })
// Fetch latest from registry
CLILoggingService.progress(`Fetching ${packageName}@${latestVersion}...`)
executeCommand(`bun add ${packageName}@latest`, {
cwd: process.cwd(),
stdio: 'inherit'
})
// Find installed path in node_modules
const installedPath = path.join(process.cwd(), 'node_modules', packageName)
if (!fs.existsSync(installedPath)) {
throw new Error(`Failed to fetch ${packageName} from registry`)
}
// Copy to apps/
fs.cpSync(installedPath, modulePath, { recursive: true })
// Remove node_modules copy so bun creates symlink
fs.rmSync(installedPath, { recursive: true, force: true })
// Run bun install
executeCommand('bun install', {
cwd: process.cwd(),
stdio: 'inherit'
})
// Remove backup on success
fs.rmSync(backupPath, { recursive: true, force: true })
CLILoggingService.success(`Upgraded ${packageName} to ${latestVersion}`)
return true
} catch (error) {
CLILoggingService.error(`Failed to upgrade ${packageName}: ${error}`)
// Restore from backup if exists
if (fs.existsSync(backupPath)) {
CLILoggingService.progress('Restoring from backup...')
if (fs.existsSync(modulePath)) {
fs.rmSync(modulePath, { recursive: true, force: true })
}
fs.renameSync(backupPath, modulePath)
CLILoggingService.info('Restored previous version')
}
return false
}
}
export async function upgradeModuleHandler(moduleName?: string): Promise<void> {
const modules = getInstalledModules()
if (modules.length === 0) {
CLILoggingService.info('No @lifeforge/* modules installed')
return
}
let upgradedCount = 0
if (moduleName) {
// Upgrade specific module
const normalizedName = moduleName.startsWith(LIFEFORGE_SCOPE)
? moduleName
: `${LIFEFORGE_SCOPE}${moduleName}`
const mod = modules.find(
m => m.name === normalizedName || m.folder === moduleName
)
if (!mod) {
CLILoggingService.actionableError(
`Module "${moduleName}" not found`,
'Run "bun forge modules list" to see installed modules'
)
process.exit(1)
}
const upgraded = await upgradeModule(mod.name, mod.folder, mod.version)
if (upgraded) {
upgradedCount++
}
} else {
// Check all modules for updates
CLILoggingService.step('Checking for updates...')
for (const mod of modules) {
const upgraded = await upgradeModule(mod.name, mod.folder, mod.version)
if (upgraded) {
upgradedCount++
}
}
}
if (upgradedCount > 0) {
CLILoggingService.progress('Regenerating registries...')
generateModuleRegistries()
CLILoggingService.success(`Upgraded ${upgradedCount} module(s)`)
}
}

View File

@@ -1,42 +1,53 @@
import type { Command } from 'commander'
import { addModuleHandler } from './handlers/add-module'
import { createModuleHandler } from './handlers/create-module'
import { installModuleHandler } from './handlers/install-module'
import { listModulesHandler } from './handlers/list-modules'
import { loginModuleHandler } from './handlers/login-module'
import { migrateModuleHandler } from './handlers/migrate-module'
import { publishModuleHandler } from './handlers/publish-module'
import { removeModuleHandler } from './handlers/remove-module'
import { updateModuleHandler } from './handlers/update-module'
import { uninstallModuleHandler } from './handlers/uninstall-module'
import { upgradeModuleHandler } from './handlers/upgrade-module'
export default function setup(program: Command): void {
const command = program
.command('modules')
.description('Manage LifeForge modules')
command
.command('login')
.description('Login to the module registry')
.action(loginModuleHandler)
command
.command('list')
.description('List all installed modules')
.action(listModulesHandler)
command
.command('add')
.description('Download and install a module')
.argument('<module>', 'Module to add, e.g., lifeforge-app/wallet')
.action(addModuleHandler)
command
.command('update')
.description('Update an installed module')
.command('install')
.alias('i')
.description('Install a module from the LifeForge registry')
.argument(
'[module]',
'Module to update, e.g., wallet (optional, will update all if not provided)'
'<module>',
'Module to install, e.g., @lifeforge/lifeforge--calendar'
)
.action(updateModuleHandler)
.action(installModuleHandler)
command
.command('remove')
.description('Remove an installed module')
.argument(
'[module]',
'Module to remove, e.g., wallet (optional, will show list if not provided)'
)
.action(removeModuleHandler)
.command('uninstall')
.alias('un')
.description('Uninstall a module')
.argument('<module>', 'Module to uninstall, e.g., achievements')
.action(uninstallModuleHandler)
command
.command('upgrade')
.alias('up')
.description('Upgrade modules to latest version from registry')
.argument('[module]', 'Module to upgrade (optional, checks all if omitted)')
.action(upgradeModuleHandler)
command
.command('create')
.description('Create a new LifeForge module scaffold')
@@ -45,9 +56,27 @@ export default function setup(program: Command): void {
'Name of the module to create. Leave empty to prompt.'
)
.action(createModuleHandler)
command
.command('publish')
.description('Publish a LifeForge module to your GitHub account')
.argument('<module>', 'Unpublished installed module to publish')
.description('Publish a LifeForge module to the registry')
.argument('<module>', 'Module to publish from apps/')
.option(
'--official',
'Publish as official module (requires maintainer access)'
)
.action(publishModuleHandler)
command
.command('migrate')
.description('Migrate legacy modules to the new package architecture')
.argument(
'[folder]',
'Module folder name (optional, migrates all if omitted)'
)
.option(
'--official',
'Migrate as official module (requires maintainer access)'
)
.action(migrateModuleHandler)
}

View File

@@ -1,16 +0,0 @@
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'))
}

View File

@@ -1,22 +0,0 @@
/**
* Module installation configuration
*/
export interface ModuleInstallConfig {
tempDir: string
moduleDir: string
author: string
moduleName: string
repoUrl: string
}
/**
* Babel AST generation options
*/
export const AST_GENERATION_OPTIONS = {
retainLines: false,
compact: false,
jsescOption: {
quotes: 'single' as const
}
} as const

View File

@@ -1,177 +0,0 @@
import generate from '@babel/generator'
import { parse } from '@babel/parser'
import traverse from '@babel/traverse'
import * as t from '@babel/types'
import type {
ObjectExpression,
ObjectMethod,
ObjectProperty,
SpreadElement
} from '@babel/types'
import fs from 'fs'
import path from 'path'
import initRouteAndSchemaFiles from '@/utils/initRouteAndSchemaFiles'
import CLILoggingService from '@/utils/logging'
import { createDynamicImport } from './ast-utils'
import { AST_GENERATION_OPTIONS } 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 { appRoutesPath } = initRouteAndSchemaFiles()
if (!fs.existsSync(appRoutesPath)) {
CLILoggingService.warn(`Routes config file not found at ${appRoutesPath}`)
return
}
const moduleServerPath = path.resolve(`apps/${moduleName}/server/index.ts`)
if (!fs.existsSync(moduleServerPath)) {
CLILoggingService.info(
`No server entry file found for module "${moduleName}", skipping route injection`
)
return
}
const routesContent = fs.readFileSync(appRoutesPath, 'utf8')
try {
const ast = parse(routesContent, {
sourceType: 'module',
plugins: ['typescript']
})
let routerObject: ObjectExpression | null = 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])
) {
routerObject = path.node.arguments[0]
}
}
})
// Check if module already exists and add if not
if (routerObject) {
const obj = routerObject as ObjectExpression
const hasExistingProperty = obj.properties.some(
(prop: ObjectMethod | ObjectProperty | SpreadElement) =>
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
)
obj.properties.push(newProperty)
}
}
const { code } = generate(ast, AST_GENERATION_OPTIONS)
fs.writeFileSync(appRoutesPath, code)
CLILoggingService.info(
`Injected route for module "${moduleName}" into ${appRoutesPath}`
)
} 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 { appRoutesPath } = initRouteAndSchemaFiles()
if (!fs.existsSync(appRoutesPath)) {
CLILoggingService.warn(`Routes config file not found at ${appRoutesPath}`)
return
}
const routesContent = fs.readFileSync(appRoutesPath, '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 => {
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(appRoutesPath, code)
CLILoggingService.info(
`Removed route for module "${moduleName}" from ${appRoutesPath}`
)
} else {
CLILoggingService.info(
`No route found for module "${moduleName}" in ${appRoutesPath}`
)
}
} catch (error) {
CLILoggingService.error(
`Failed to remove route for module "${moduleName}": ${error}`
)
}
}

View File

@@ -1,206 +0,0 @@
import generate from '@babel/generator'
import { parse } from '@babel/parser'
import traverse from '@babel/traverse'
import * as t from '@babel/types'
import type {
ObjectExpression,
ObjectMethod,
ObjectProperty,
SpreadElement
} from '@babel/types'
import fs from 'fs'
import path from 'path'
import initRouteAndSchemaFiles from '@/utils/initRouteAndSchemaFiles'
import CLILoggingService from '@/utils/logging'
import { AST_GENERATION_OPTIONS } 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 { schemaPath } = initRouteAndSchemaFiles()
if (!fs.existsSync(schemaPath)) {
CLILoggingService.warn(`Schema config file not found at ${schemaPath}`)
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(schemaPath, 'utf8')
try {
const ast = parse(schemaContent, {
sourceType: 'module',
plugins: ['typescript']
})
let schemasObject: ObjectExpression | null = null
// Find the SCHEMAS object
traverse(ast, {
VariableDeclarator(path) {
if (
t.isIdentifier(path.node.id, { name: 'SCHEMAS' }) &&
t.isObjectExpression(path.node.init)
) {
schemasObject = path.node.init
}
}
})
if (schemasObject) {
const obj = schemasObject as ObjectExpression
// Convert module name to snake_case for the key
const snakeCaseModuleName = moduleName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '')
// Check if module is already imported
const hasExistingProperty = obj.properties.some(
(prop: ObjectMethod | ObjectProperty | SpreadElement) =>
t.isObjectProperty(prop) &&
t.isIdentifier(prop.key) &&
prop.key.name === snakeCaseModuleName
)
if (!hasExistingProperty) {
const moduleImport = t.awaitExpression(
t.callExpression(t.import(), [
t.stringLiteral(`@lib/${moduleName}/server/schema`)
])
)
const memberExpression = t.memberExpression(
moduleImport,
t.identifier('default')
)
const newProperty = t.objectProperty(
t.identifier(snakeCaseModuleName),
memberExpression
)
obj.properties.push(newProperty)
}
}
const { code } = generate(ast, AST_GENERATION_OPTIONS)
fs.writeFileSync(schemaPath, code)
CLILoggingService.info(
`Injected schema for module "${moduleName}" into ${schemaPath}`
)
} 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 { schemaPath } = initRouteAndSchemaFiles()
if (!fs.existsSync(schemaPath)) {
CLILoggingService.warn(`Schema config file not found at ${schemaPath}`)
return
}
const schemaContent = fs.readFileSync(schemaPath, 'utf8')
try {
const ast = parse(schemaContent, {
sourceType: 'module',
plugins: ['typescript']
})
let modified = false
// Find and remove the module property from the SCHEMAS object
traverse(ast, {
VariableDeclarator(path) {
if (
t.isIdentifier(path.node.id, { name: 'SCHEMAS' }) &&
t.isObjectExpression(path.node.init)
) {
const objectExpression = path.node.init
const originalLength = objectExpression.properties.length
// Convert module name to snake_case for the key
const snakeCaseModuleName = moduleName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '')
objectExpression.properties = objectExpression.properties.filter(
prop => {
if (
t.isObjectProperty(prop) &&
t.isIdentifier(prop.key) &&
(prop.key.name === snakeCaseModuleName ||
(t.isAwaitExpression(prop.value) &&
t.isCallExpression(prop.value.argument) &&
t.isImport(prop.value.argument.callee) &&
prop.value.argument.arguments.length > 0 &&
t.isStringLiteral(prop.value.argument.arguments[0]) &&
prop.value.argument.arguments[0].value.includes(
moduleName
)))
) {
return false // Remove this property
}
return true // Keep other properties
}
)
if (objectExpression.properties.length < originalLength) {
modified = true
}
}
}
})
if (modified) {
const { code } = generate(ast, AST_GENERATION_OPTIONS)
fs.writeFileSync(schemaPath, code)
CLILoggingService.info(
`Removed schema for module "${moduleName}" from ${schemaPath}`
)
} else {
CLILoggingService.info(
`No schema found for module "${moduleName}" in ${schemaPath}`
)
}
} catch (error) {
CLILoggingService.error(
`Failed to remove schema for module "${moduleName}": ${error}`
)
}
}

View File

@@ -1,39 +0,0 @@
import CLILoggingService from '@/utils/logging'
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('/')
if (!moduleName.startsWith('lifeforge-module-')) {
CLILoggingService.error(
`Module name must start with 'lifeforge-module-'. Received: ${moduleName}`
)
process.exit(1)
}
const finalModuleName = moduleName.replace(/^lifeforge-module-/, '')
return {
tempDir: '.temp',
moduleDir: `apps/${finalModuleName}`,
author,
moduleName: finalModuleName,
repoUrl: `https://github.com/${author}/${moduleName}.git`
}
}

View File

@@ -0,0 +1,18 @@
{
"extends": "./client/tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"composite": false,
"paths": {
"@": [
"./client/index"
],
"@/*": [
"./client/*"
]
}
},
"include": [
"./manifest.ts"
]
}

View File

@@ -3,7 +3,12 @@
// JSX and Language Settings
"jsx": "react-jsx",
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable", "ES2024.Object"],
"lib": [
"ES2020",
"DOM",
"DOM.Iterable",
"ES2024.Object"
],
// Module Resolution
"module": "ESNext",
"moduleResolution": "bundler",
@@ -22,12 +27,20 @@
"noFallthroughCasesInSwitch": true,
// Path Mapping
"paths": {
"@": ["./src/index"],
"@/*": ["./src/*"],
"@server/*": ["../../../server/src/*"]
"@": [
"./index"
],
"@/*": [
"./*"
],
"@server/*": [
"../../../server/src/*"
]
}
},
"include": ["./src/**/*", "./manifest.ts"],
"include": [
"./**/*"
],
"references": [
{
"path": "../../../server/tsconfig.json"

View File

@@ -1,5 +1,5 @@
{
"name": "{{kebab moduleName.en}}",
"name": "@lifeforge/lifeforge--{{kebab moduleName.en}}",
"version": "0.0.0",
"description": "{{moduleDesc.en}}",
"scripts": {
@@ -7,23 +7,29 @@
},
"dependencies": {
"@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-query": "^5.90.2",
"@uidotdev/usehooks": "^2.4.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.18",
"lifeforge-ui": "workspace:*",
"lodash": "^4.17.21",
"react": "^19.2.0",
"react-i18next": "^15.1.1",
"react-toastify": "^11.0.5",
"shared": "workspace:*",
"tailwindcss": "^4.1.14",
"vite": "^7.1.9",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/lodash": "^4.17.21",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"vite": "^7.1.9"
"@types/react-dom": "^19.2.0"
},
"exports": {
"./server": "./server/index.ts",
"./manifest": {
"types": "./manifest.d.ts",
"default": "./manifest.ts"
},
"./server/schema": "./server/schema.ts"
}
}

View File

@@ -0,0 +1,18 @@
{
"extends": "./client/tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"composite": false,
"paths": {
"@": [
"./client/index"
],
"@/*": [
"./client/*"
]
}
},
"include": [
"./manifest.ts"
]
}

View File

@@ -3,7 +3,12 @@
// JSX and Language Settings
"jsx": "react-jsx",
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable", "ES2024.Object"],
"lib": [
"ES2020",
"DOM",
"DOM.Iterable",
"ES2024.Object"
],
// Module Resolution
"module": "ESNext",
"moduleResolution": "bundler",
@@ -22,12 +27,20 @@
"noFallthroughCasesInSwitch": true,
// Path Mapping
"paths": {
"@": ["./src/index"],
"@/*": ["./src/*"],
"@server/*": ["../../../server/src/*"]
"@": [
"./index"
],
"@/*": [
"./*"
],
"@server/*": [
"../../../server/src/*"
]
}
},
"include": ["./src/**/*", "./manifest.ts"],
"include": [
"./**/*"
],
"references": [
{
"path": "../../../server/tsconfig.json"

View File

@@ -1,5 +1,5 @@
{
"name": "{{kebab moduleName.en}}",
"name": "@lifeforge/lifeforge--{{kebab moduleName.en}}",
"version": "0.0.0",
"description": "{{moduleDesc.en}}",
"scripts": {
@@ -7,23 +7,27 @@
},
"dependencies": {
"@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-query": "^5.90.2",
"@uidotdev/usehooks": "^2.4.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.18",
"lodash": "^4.17.21",
"lifeforge-ui": "workspace:*",
"react": "^19.2.0",
"react-i18next": "^15.1.1",
"react-toastify": "^11.0.5",
"shared": "workspace:*",
"tailwindcss": "^4.1.14",
"vite": "^7.1.9",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/lodash": "^4.17.21",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"vite": "^7.1.9"
"@types/react-dom": "^19.2.0"
},
"exports": {
"./manifest": {
"types": "./manifest.d.ts",
"default": "./manifest.ts"
}
}
}

View File

@@ -1,19 +0,0 @@
node_modules
.env
dist
build
tsbuild
*.js
*.tsbuildinfo
.DS_Store
*.local
.DS_Store
.idea
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,36 +0,0 @@
{
"compilerOptions": {
// JSX and Language Settings
"jsx": "react-jsx",
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable", "ES2024.Object"],
// Module Resolution
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
// Build and Output
"composite": true,
"noEmit": true,
"skipLibCheck": true,
// Type Checking
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"useDefineForClassFields": true,
"noFallthroughCasesInSwitch": true,
// Path Mapping
"paths": {
"@": ["./src/index"],
"@/*": ["./src/*"],
"@server/*": ["../../../server/src/*"]
}
},
"include": ["./src/**/*", "./manifest.ts"],
"references": [
{
"path": "../../../server/tsconfig.json"
}
]
}

View File

@@ -1,24 +0,0 @@
import { Widget } from 'lifeforge-ui'
import type { WidgetConfig } from 'shared'
function ExampleWidget() {
return (
<Widget
icon="{{moduleIcon}}"
namespace="apps.{{camel moduleName.en}}"
title="{{moduleName.en}}"
>
Hello World!
</Widget>
)
}
export default ExampleWidget
export const config: WidgetConfig = {
namespace: 'apps.{{camel moduleName.en}}',
id: '{{kebab moduleName.en}}',
icon: '{{moduleIcon}}',
minH: 1,
minW: 1
}

View File

@@ -1,8 +0,0 @@
{
"widgets": {
"{{camel moduleName.en}}": {
"title": "{{moduleName.en}}",
"description": "Widget for {{moduleDesc.en}}"
}
}
}

View File

@@ -1,8 +0,0 @@
{
"widgets": {
"{{camel moduleName.en}}": {
"title": "{{moduleName.ms}}",
"description": "Widget untuk {{moduleDesc.ms}}"
}
}
}

View File

@@ -1,8 +0,0 @@
{
"widgets": {
"{{camel moduleName.en}}": {
"title": "{{moduleName.zhCN}}",
"description": "为{{moduleDesc.zhCN}}而量身定制的小部件"
}
}
}

View File

@@ -1,8 +0,0 @@
{
"widgets": {
"{{camel moduleName.en}}": {
"title": "{{moduleName.zhTW}}",
"description": "為{{moduleDesc.zhTW}}量身定制的小工具"
}
}
}

View File

@@ -1,29 +0,0 @@
{
"name": "{{kebab moduleName.en}}",
"version": "0.0.0",
"description": "{{moduleDesc.en}}",
"scripts": {
"types": "cd client && bun tsc"
},
"dependencies": {
"@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.90.11",
"@uidotdev/usehooks": "^2.4.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.18",
"lifeforge-ui": "workspace:*",
"lodash": "^4.17.21",
"react": "^19.2.0",
"react-i18next": "^15.1.1",
"react-toastify": "^11.0.5",
"shared": "workspace:*",
"vite": "^7.1.9",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/lodash": "^4.17.21",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"vite": "^7.1.9"
}
}

View File

@@ -0,0 +1,18 @@
{
"extends": "./client/tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"composite": false,
"paths": {
"@": [
"./client/index"
],
"@/*": [
"./client/*"
]
}
},
"include": [
"./manifest.ts"
]
}

View File

@@ -3,7 +3,12 @@
// JSX and Language Settings
"jsx": "react-jsx",
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable", "ES2024.Object"],
"lib": [
"ES2020",
"DOM",
"DOM.Iterable",
"ES2024.Object"
],
// Module Resolution
"module": "ESNext",
"moduleResolution": "bundler",
@@ -22,12 +27,20 @@
"noFallthroughCasesInSwitch": true,
// Path Mapping
"paths": {
"@": ["./src/index"],
"@/*": ["./src/*"],
"@server/*": ["../../../server/src/*"]
"@": [
"./index"
],
"@/*": [
"./index/*"
],
"@server/*": [
"../../../server/src/*"
]
}
},
"include": ["./src/**/*", "./manifest.ts"],
"include": [
"./**/*"
],
"references": [
{
"path": "../../../server/tsconfig.json"

View File

@@ -1,29 +1,35 @@
{
"name": "{{kebab moduleName.en}}",
"name": "@lifeforge/lifeforge--{{kebab moduleName.en}}",
"version": "0.0.0",
"description": "Nice",
"description": "{{moduleDesc.en}}",
"scripts": {
"types": "cd client && bun tsc"
},
"dependencies": {
"@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-query": "^5.90.2",
"@uidotdev/usehooks": "^2.4.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.18",
"lodash": "^4.17.21",
"lifeforge-ui": "workspace:*",
"react": "^19.2.0",
"react-i18next": "^15.1.1",
"react-toastify": "^11.0.5",
"shared": "workspace:*",
"tailwindcss": "^4.1.14",
"vite": "^7.1.9",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/lodash": "^4.17.21",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"vite": "^7.1.9"
"@types/react-dom": "^19.2.0"
},
"exports": {
"./server": "./server/index.ts",
"./manifest": {
"types": "./manifest.d.ts",
"default": "./manifest.ts"
},
"./server/schema": "./server/schema.ts"
}
}

View File

@@ -0,0 +1,18 @@
{
"extends": "./client/tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"composite": false,
"paths": {
"@": [
"./client/index"
],
"@/*": [
"./client/*"
]
}
},
"include": [
"./manifest.ts"
]
}

View File

@@ -3,7 +3,12 @@
// JSX and Language Settings
"jsx": "react-jsx",
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable", "ES2024.Object"],
"lib": [
"ES2020",
"DOM",
"DOM.Iterable",
"ES2024.Object"
],
// Module Resolution
"module": "ESNext",
"moduleResolution": "bundler",
@@ -22,12 +27,20 @@
"noFallthroughCasesInSwitch": true,
// Path Mapping
"paths": {
"@": ["./src/index"],
"@/*": ["./src/*"],
"@server/*": ["../../../server/src/*"]
"@": [
"./index"
],
"@/*": [
"./*"
],
"@server/*": [
"../../../server/src/*"
]
}
},
"include": ["./src/**/*", "./manifest.ts"],
"include": [
"./**/*"
],
"references": [
{
"path": "../../../server/tsconfig.json"

Some files were not shown because too many files have changed in this diff Show More