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 medium
.temp .temp
server/src/core/routes/app.routes.ts server/src/core/routes/app.routes.ts
server/src/core/routes/generated-routes.ts
server/src/core/schema.ts server/src/core/schema.ts
client/src/module-registry.ts
# system files # system files
Thumbs.db Thumbs.db
@@ -52,3 +54,10 @@ env/*
!env/.env.example !env/.env.example
!env/.env.docker.example !env/.env.docker.example
keys 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,42 +1,25 @@
import type { ModuleCategory, ModuleConfig } from 'shared' import type { ModuleCategory, ModuleConfig } from 'shared'
import { modules } from '../module-registry'
let ROUTES: ModuleCategory[] = [] let ROUTES: ModuleCategory[] = []
const categoryFile = import.meta.glob('../../../apps/cat.config.json', { // Process modules from generated registry
eager: true for (const mod of modules as (ModuleConfig & { category?: string })[]) {
}) const category = mod.category || 'Miscellaneous'
let categoriesSeq: string[] = [] const categoryIndex = ROUTES.findIndex(cat => cat.title === category)
if (categoryFile['../../../apps/cat.config.json']) { if (categoryIndex > -1) {
categoriesSeq = ( ROUTES[categoryIndex].items.push(mod)
categoryFile['../../../apps/cat.config.json'] as { default: string[] } } else {
).default ROUTES.push({
title: category,
items: [mod]
})
}
} }
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'
const categoryIndex = ROUTES.findIndex(cat => cat.title === category)
if (categoryIndex > -1) {
ROUTES[categoryIndex].items.push(mod.default)
} else {
ROUTES.push({
title: category,
items: [mod.default]
})
}
})
)
ROUTES = ROUTES.sort((a, b) => { ROUTES = ROUTES.sort((a, b) => {
const order = ['<START>', 'Miscellaneous', 'Settings', 'SSO', '<END>'] 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 (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 // Default to alphabetical
return a.title.localeCompare(b.title) return a.title.localeCompare(b.title)
}).map(cat => ({ }).map(cat => ({
@@ -89,8 +51,4 @@ ROUTES = ROUTES.sort((a, b) => {
items: cat.items.sort((a, b) => a.name.localeCompare(b.name)) items: cat.items.sort((a, b) => a.name.localeCompare(b.name))
})) }))
import.meta.glob('../../../apps/**/client/index.css', {
eager: true
})
export default ROUTES export default ROUTES

View File

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

View File

@@ -87,20 +87,5 @@
</div> </div>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <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> </body>
</html> </html>

View File

@@ -17,6 +17,7 @@
"./shared", "./shared",
"./packages/*", "./packages/*",
"./apps/*", "./apps/*",
"./locales/*",
"./tools/*" "./tools/*"
], ],
"scripts": { "scripts": {
@@ -31,19 +32,13 @@
"lifeforge" "lifeforge"
], ],
"devDependencies": { "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", "@eslint/js": "^9.26.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2", "@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/lodash": "^4.17.20", "@types/lodash": "^4.17.20",
"@types/prettier": "^3.0.0", "@types/prettier": "^3.0.0",
"@types/prompts": "^2.4.9",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.18.0",
"bun-types": "latest", "bun-types": "latest",
"commander": "^14.0.1",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"eslint": "^9.26.0", "eslint": "^9.26.0",
"eslint-config-standard-with-typescript": "^40.0.0", "eslint-config-standard-with-typescript": "^40.0.0",
@@ -55,14 +50,17 @@
"eslint-plugin-sonarjs": "^3.0.2", "eslint-plugin-sonarjs": "^3.0.2",
"eslint-plugin-unused-imports": "^4.2.0", "eslint-plugin-unused-imports": "^4.2.0",
"globals": "^16.5.0", "globals": "^16.5.0",
"inquirer-autocomplete-prompt": "^3.0.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.7.1",
"prompts": "^2.4.2",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.31.1" "typescript-eslint": "^8.31.1"
}, },
"dependencies": { "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 { registerRoutes } from '@functions/routes/functions/forgeRouter'
import { clientError } from '@functions/routes/utils/response' import { clientError } from '@functions/routes/utils/response'
import appRoutes from './app.routes'
import coreRoutes from './core.routes' import coreRoutes from './core.routes'
import appRoutes from './generated-routes'
const router = express.Router() const router = express.Router()

View File

@@ -7,12 +7,6 @@
"types": "tsc --noEmit" "types": "tsc --noEmit"
}, },
"dependencies": { "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", "axios": "^1.12.2",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"commander": "^14.0.2", "commander": "^14.0.2",
@@ -33,4 +27,4 @@
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/lodash": "^4.17.21" "@types/lodash": "^4.17.21"
} }
} }

View File

@@ -141,13 +141,13 @@ export async function createStructureMigration(
* Runs migrate up to apply pending migrations * Runs migrate up to apply pending migrations
*/ */
export function runMigrateUp(): void { export function runMigrateUp(): void {
CLILoggingService.info('Applying pending migrations...') CLILoggingService.debug('Applying pending migrations...')
execSync(`${PB_BINARY_PATH} migrate up ${PB_KWARGS.join(' ')}`, { execSync(`${PB_BINARY_PATH} migrate up ${PB_KWARGS.join(' ')}`, {
stdio: ['pipe', 'pipe', 'pipe'] 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) const schemaFiles = getSchemaFiles(targetModule)
CLILoggingService.info( CLILoggingService.debug(
targetModule targetModule
? `Processing module: ${chalk.bold.blue(targetModule)}` ? `Processing module: ${chalk.bold.blue(targetModule)}`
: `Found ${chalk.bold.blue(schemaFiles.length)} schema files.` : `Found ${chalk.bold.blue(schemaFiles.length)} schema files.`
@@ -76,7 +76,7 @@ export async function generateMigrationsHandler(
) )
// Phase 1: Generate all skeleton migrations // 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) { for (const { moduleName, schema } of importedSchemas) {
const result = await createSkeletonMigration(moduleName, schema) const result = await createSkeletonMigration(moduleName, schema)
@@ -90,16 +90,16 @@ export async function generateMigrationsHandler(
} }
} }
CLILoggingService.success( CLILoggingService.debug(
`Created ${importedSchemas.length} skeleton migrations` `Created ${importedSchemas.length} skeleton migrations`
) )
// Phase 2: Run migrate up to apply 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() runMigrateUp()
// Phase 3: Generate all structure migrations // 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) { for (const { moduleName, schema } of importedSchemas) {
const result = await createStructureMigration( const result = await createStructureMigration(
@@ -117,16 +117,16 @@ export async function generateMigrationsHandler(
} }
} }
CLILoggingService.success( CLILoggingService.debug(
`Created ${importedSchemas.length} structure migrations` `Created ${importedSchemas.length} structure migrations`
) )
// Phase 4: Run migrate up to apply 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() runMigrateUp()
// Phase 5: Generate view query migrations (for modules with view collections) // 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 let viewMigrationCount = 0
@@ -147,23 +147,23 @@ export async function generateMigrationsHandler(
} }
if (viewMigrationCount > 0) { if (viewMigrationCount > 0) {
CLILoggingService.success( CLILoggingService.debug(
`Created ${viewMigrationCount} view query migrations` `Created ${viewMigrationCount} view query migrations`
) )
// Phase 6: Apply 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() runMigrateUp()
} else { } else {
CLILoggingService.info( CLILoggingService.debug(
'No view collections found, skipping view migrations' 'No view collections found, skipping view migrations'
) )
} }
// Summary // Summary
const message = targetModule const message = targetModule
? `Migration script completed for module ${chalk.bold.blue(targetModule)}` ? `Database migrations applied for ${chalk.bold.blue(targetModule)}`
: 'Migration script completed for all modules' : 'Database migrations applied for all modules'
CLILoggingService.success(message) CLILoggingService.success(message)
} catch (error) { } 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 { PB_BINARY_PATH, PB_KWARGS, PB_MIGRATIONS_DIR } from '@/constants/db'
import CLILoggingService from '@/utils/logging' import CLILoggingService from '@/utils/logging'
/** /**
* Cleans up old migrations * Cleans up old migrations
*/ */
@@ -15,7 +13,7 @@ export async function cleanupOldMigrations(
targetModule?: string targetModule?: string
): Promise<void> { ): Promise<void> {
try { try {
CLILoggingService.warn('Cleaning up old migrations directory...') CLILoggingService.debug('Cleaning up old migrations directory...')
if (!targetModule) { if (!targetModule) {
fs.rmSync(PB_MIGRATIONS_DIR, { recursive: true, force: true }) 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( `Removed ${chalk.bold.blue(
migrationFiles.filter(file => file.endsWith(`_${targetModule}.js`)) migrationFiles.filter(file => file.endsWith(`_${targetModule}.js`))
.length .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 { loginModuleHandler } from '../modules/handlers/login-module'
import { listLocalesHandler } from './handlers/listLocalesHandler' import { installLocaleHandler } from './handlers/install-locale'
import { removeLocaleHandler } from './handlers/removeLocaleHandler' 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 const command = program
.command('locales') .command('locales')
.description('Manage LifeForge language packs') .description('Manage LifeForge language packs')
command
.command('login')
.description('Login to the locale registry')
.action(loginModuleHandler)
command command
.command('list') .command('list')
.description('List all installed language packs') .description('List all installed language packs')
.action(listLocalesHandler) .action(listLocalesHandler)
command command
.command('add') .command('install')
.description('Download and install a language pack') .alias('i')
.description('Install a language pack from the registry')
.argument('<lang>', 'Language code, e.g., en, ms, zh-CN, zh-TW') .argument('<lang>', 'Language code, e.g., en, ms, zh-CN, zh-TW')
.action(addLocaleHandler) .action(installLocaleHandler)
command command
.command('remove') .command('uninstall')
.description('Remove an installed language pack') .alias('un')
.description('Uninstall a language pack')
.argument('<lang>', 'Language code to remove') .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 // Git operations
export * from './git' export * from './git-status'
export * from '../../../utils/github-cli'
// Module lifecycle operations // Module operations
export * from './module-lifecycle' export * from './install-dependencies'
export * from './module-migrations'
// Migration operations
export * from './migrations'
// Interactive prompts // Interactive prompts
export * from './prompts' export * from './prompts'
// Template operations // Template operations
export * from './templates' 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 CLILoggingService from '@/utils/logging'
import { checkRunningPBInstances } from '@/utils/pocketbase' import { checkRunningPBInstances } from '@/utils/pocketbase'
import { generateDatabaseSchemas } from '../functions/migrations' import { installDependencies } from '../functions/install-dependencies'
import { installDependencies } from '../functions/module-lifecycle' import { generateDatabaseSchemas } from '../functions/module-migrations'
import { import {
checkModuleTypeAvailability, checkModuleTypeAvailability,
promptForModuleName, promptForModuleName,
@@ -15,14 +15,13 @@ import {
promptModuleType, promptModuleType,
selectIcon selectIcon
} from '../functions/prompts' } from '../functions/prompts'
import { generateModuleRegistries } from '../functions/registry/generator'
import { import {
type ModuleMetadata, type ModuleMetadata,
copyTemplateFiles, copyTemplateFiles,
initializeGitRepository, initializeGitRepository,
registerHandlebarsHelpers registerHandlebarsHelpers
} from '../functions/templates' } from '../functions/templates'
import { injectModuleRoute } from '../utils/route-injection'
import { injectModuleSchema } from '../utils/schema-injection'
registerHandlebarsHelpers() registerHandlebarsHelpers()
@@ -57,8 +56,8 @@ export async function createModuleHandler(moduleName?: string): Promise<void> {
installDependencies(`${process.cwd()}/apps`) installDependencies(`${process.cwd()}/apps`)
injectModuleRoute(camelizedModuleName) // Regenerate registries to include the new module
injectModuleSchema(camelizedModuleName) generateModuleRegistries()
if ( if (
fs.existsSync( 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 CLILoggingService from '@/utils/logging'
import { checkRunningPBInstances } from '@/utils/pocketbase'
import { import {
checkGitCleanliness, checkAuth,
checkGithubCLI, getRegistryUrl,
createGithubRepo, openRegistryLogin
replaceRepoWithSubmodule } from '../../../utils/registry'
} from '../functions/git' import { validateMaintainerAccess } from '../functions'
import { getInstalledModules } from '../utils/file-system'
export async function publishModuleHandler(moduleName: string): Promise<void> { const LIFEFORGE_SCOPE = '@lifeforge'
const availableModules = getInstalledModules()
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( CLILoggingService.actionableError(
`Module ${moduleName} is not installed.`, `Module "${moduleName}" not found in apps/`,
`Available modules: ${availableModules.join(', ')}` 'Make sure the module exists in the apps directory'
) )
process.exit(1) process.exit(1)
} }
checkRunningPBInstances() CLILoggingService.step(`Validating module "${moduleName}"...`)
checkGitCleanliness(moduleName)
checkGithubCLI()
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 type { Command } from 'commander'
import { addModuleHandler } from './handlers/add-module'
import { createModuleHandler } from './handlers/create-module' import { createModuleHandler } from './handlers/create-module'
import { installModuleHandler } from './handlers/install-module'
import { listModulesHandler } from './handlers/list-modules' 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 { publishModuleHandler } from './handlers/publish-module'
import { removeModuleHandler } from './handlers/remove-module' import { uninstallModuleHandler } from './handlers/uninstall-module'
import { updateModuleHandler } from './handlers/update-module' import { upgradeModuleHandler } from './handlers/upgrade-module'
export default function setup(program: Command): void { export default function setup(program: Command): void {
const command = program const command = program
.command('modules') .command('modules')
.description('Manage LifeForge modules') .description('Manage LifeForge modules')
command
.command('login')
.description('Login to the module registry')
.action(loginModuleHandler)
command command
.command('list') .command('list')
.description('List all installed modules') .description('List all installed modules')
.action(listModulesHandler) .action(listModulesHandler)
command command
.command('add') .command('install')
.description('Download and install a module') .alias('i')
.argument('<module>', 'Module to add, e.g., lifeforge-app/wallet') .description('Install a module from the LifeForge registry')
.action(addModuleHandler)
command
.command('update')
.description('Update an installed module')
.argument( .argument(
'[module]', '<module>',
'Module to update, e.g., wallet (optional, will update all if not provided)' 'Module to install, e.g., @lifeforge/lifeforge--calendar'
) )
.action(updateModuleHandler) .action(installModuleHandler)
command command
.command('remove') .command('uninstall')
.description('Remove an installed module') .alias('un')
.argument( .description('Uninstall a module')
'[module]', .argument('<module>', 'Module to uninstall, e.g., achievements')
'Module to remove, e.g., wallet (optional, will show list if not provided)' .action(uninstallModuleHandler)
)
.action(removeModuleHandler) 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
.command('create') .command('create')
.description('Create a new LifeForge module scaffold') .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.' 'Name of the module to create. Leave empty to prompt.'
) )
.action(createModuleHandler) .action(createModuleHandler)
command command
.command('publish') .command('publish')
.description('Publish a LifeForge module to your GitHub account') .description('Publish a LifeForge module to the registry')
.argument('<module>', 'Unpublished installed module to publish') .argument('<module>', 'Module to publish from apps/')
.option(
'--official',
'Publish as official module (requires maintainer access)'
)
.action(publishModuleHandler) .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 and Language Settings
"jsx": "react-jsx", "jsx": "react-jsx",
"target": "ES2020", "target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable", "ES2024.Object"], "lib": [
"ES2020",
"DOM",
"DOM.Iterable",
"ES2024.Object"
],
// Module Resolution // Module Resolution
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
@@ -22,15 +27,23 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
// Path Mapping // Path Mapping
"paths": { "paths": {
"@": ["./src/index"], "@": [
"@/*": ["./src/*"], "./index"
"@server/*": ["../../../server/src/*"] ],
"@/*": [
"./*"
],
"@server/*": [
"../../../server/src/*"
]
} }
}, },
"include": ["./src/**/*", "./manifest.ts"], "include": [
"./**/*"
],
"references": [ "references": [
{ {
"path": "../../../server/tsconfig.json" "path": "../../../server/tsconfig.json"
} }
] ]
} }

View File

@@ -1,5 +1,5 @@
{ {
"name": "{{kebab moduleName.en}}", "name": "@lifeforge/lifeforge--{{kebab moduleName.en}}",
"version": "0.0.0", "version": "0.0.0",
"description": "{{moduleDesc.en}}", "description": "{{moduleDesc.en}}",
"scripts": { "scripts": {
@@ -7,23 +7,29 @@
}, },
"dependencies": { "dependencies": {
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.90.11", "@tanstack/react-query": "^5.90.2",
"@uidotdev/usehooks": "^2.4.1", "@uidotdev/usehooks": "^2.4.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"lifeforge-ui": "workspace:*", "lifeforge-ui": "workspace:*",
"lodash": "^4.17.21",
"react": "^19.2.0", "react": "^19.2.0",
"react-i18next": "^15.1.1", "react-i18next": "^15.1.1",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"shared": "workspace:*", "shared": "workspace:*",
"tailwindcss": "^4.1.14",
"vite": "^7.1.9", "vite": "^7.1.9",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@types/lodash": "^4.17.21",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0"
"vite": "^7.1.9" },
"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 and Language Settings
"jsx": "react-jsx", "jsx": "react-jsx",
"target": "ES2020", "target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable", "ES2024.Object"], "lib": [
"ES2020",
"DOM",
"DOM.Iterable",
"ES2024.Object"
],
// Module Resolution // Module Resolution
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
@@ -22,15 +27,23 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
// Path Mapping // Path Mapping
"paths": { "paths": {
"@": ["./src/index"], "@": [
"@/*": ["./src/*"], "./index"
"@server/*": ["../../../server/src/*"] ],
"@/*": [
"./*"
],
"@server/*": [
"../../../server/src/*"
]
} }
}, },
"include": ["./src/**/*", "./manifest.ts"], "include": [
"./**/*"
],
"references": [ "references": [
{ {
"path": "../../../server/tsconfig.json" "path": "../../../server/tsconfig.json"
} }
] ]
} }

View File

@@ -1,29 +1,33 @@
{ {
"name": "{{kebab moduleName.en}}", "name": "@lifeforge/lifeforge--{{kebab moduleName.en}}",
"version": "0.0.0", "version": "0.0.0",
"description": "{{moduleDesc.en}}", "description": "{{moduleDesc.en}}",
"scripts": { "scripts": {
"types": "cd client && bun tsc" "types": "cd client && bun tsc"
}, },
"dependencies": { "dependencies": {
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.90.11", "@tanstack/react-query": "^5.90.2",
"@uidotdev/usehooks": "^2.4.1", "@uidotdev/usehooks": "^2.4.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"lodash": "^4.17.21", "lifeforge-ui": "workspace:*",
"lifeforge-ui": "workspace:*", "react": "^19.2.0",
"react": "^19.2.0", "react-i18next": "^15.1.1",
"react-i18next": "^15.1.1", "react-toastify": "^11.0.5",
"react-toastify": "^11.0.5", "shared": "workspace:*",
"shared": "workspace:*", "tailwindcss": "^4.1.14",
"vite": "^7.1.9", "vite": "^7.1.9",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@types/lodash": "^4.17.21", "@types/react": "^19.2.0",
"@types/react": "^19.2.0", "@types/react-dom": "^19.2.0"
"@types/react-dom": "^19.2.0", },
"vite": "^7.1.9" "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 and Language Settings
"jsx": "react-jsx", "jsx": "react-jsx",
"target": "ES2020", "target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable", "ES2024.Object"], "lib": [
"ES2020",
"DOM",
"DOM.Iterable",
"ES2024.Object"
],
// Module Resolution // Module Resolution
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
@@ -22,15 +27,23 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
// Path Mapping // Path Mapping
"paths": { "paths": {
"@": ["./src/index"], "@": [
"@/*": ["./src/*"], "./index"
"@server/*": ["../../../server/src/*"] ],
"@/*": [
"./index/*"
],
"@server/*": [
"../../../server/src/*"
]
} }
}, },
"include": ["./src/**/*", "./manifest.ts"], "include": [
"./**/*"
],
"references": [ "references": [
{ {
"path": "../../../server/tsconfig.json" "path": "../../../server/tsconfig.json"
} }
] ]
} }

View File

@@ -1,29 +1,35 @@
{ {
"name": "{{kebab moduleName.en}}", "name": "@lifeforge/lifeforge--{{kebab moduleName.en}}",
"version": "0.0.0", "version": "0.0.0",
"description": "Nice", "description": "{{moduleDesc.en}}",
"scripts": { "scripts": {
"types": "cd client && bun tsc" "types": "cd client && bun tsc"
}, },
"dependencies": { "dependencies": {
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.90.11", "@tanstack/react-query": "^5.90.2",
"@uidotdev/usehooks": "^2.4.1", "@uidotdev/usehooks": "^2.4.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"lodash": "^4.17.21", "lifeforge-ui": "workspace:*",
"lifeforge-ui": "workspace:*", "react": "^19.2.0",
"react": "^19.2.0", "react-i18next": "^15.1.1",
"react-i18next": "^15.1.1", "react-toastify": "^11.0.5",
"react-toastify": "^11.0.5", "shared": "workspace:*",
"shared": "workspace:*", "tailwindcss": "^4.1.14",
"vite": "^7.1.9", "vite": "^7.1.9",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@types/lodash": "^4.17.21", "@types/react": "^19.2.0",
"@types/react": "^19.2.0", "@types/react-dom": "^19.2.0"
"@types/react-dom": "^19.2.0", },
"vite": "^7.1.9" "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 and Language Settings
"jsx": "react-jsx", "jsx": "react-jsx",
"target": "ES2020", "target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable", "ES2024.Object"], "lib": [
"ES2020",
"DOM",
"DOM.Iterable",
"ES2024.Object"
],
// Module Resolution // Module Resolution
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
@@ -22,15 +27,23 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
// Path Mapping // Path Mapping
"paths": { "paths": {
"@": ["./src/index"], "@": [
"@/*": ["./src/*"], "./index"
"@server/*": ["../../../server/src/*"] ],
"@/*": [
"./*"
],
"@server/*": [
"../../../server/src/*"
]
} }
}, },
"include": ["./src/**/*", "./manifest.ts"], "include": [
"./**/*"
],
"references": [ "references": [
{ {
"path": "../../../server/tsconfig.json" "path": "../../../server/tsconfig.json"
} }
] ]
} }

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