mirror of
https://github.com/Lifeforge-app/lifeforge.git
synced 2026-03-03 03:07:01 +00:00
feat: very crude implementation
This commit is contained in:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -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
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
registry=https://registry.npmjs.org/
|
||||||
|
@lifeforge:registry=https://registry.lifeforge.dev/
|
||||||
3
bunfig.toml
Normal file
3
bunfig.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[install]
|
||||||
|
[install.scopes]
|
||||||
|
"@lifeforge" = "https://registry.lifeforge.dev/"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -52,3 +52,4 @@ services:
|
|||||||
- "80:80"
|
- "80:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
- server
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -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:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
8
tools/forgeCLI/src/commands/locales/constants/index.tsx
Normal file
8
tools/forgeCLI/src/commands/locales/constants/index.tsx
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
63
tools/forgeCLI/src/commands/locales/functions/getUpgrades.ts
Normal file
63
tools/forgeCLI/src/commands/locales/functions/getUpgrades.ts
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}`))
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
26
tools/forgeCLI/src/commands/locales/handlers/list-locales.ts
Normal file
26
tools/forgeCLI/src/commands/locales/handlers/list-locales.ts
Normal 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})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export {
|
|
||||||
generateDatabaseSchemas,
|
|
||||||
generateSchemaMigrations,
|
|
||||||
removeModuleMigrations
|
|
||||||
} from './module-migrations'
|
|
||||||
@@ -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'
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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')
|
|
||||||
}
|
|
||||||
@@ -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}
|
||||||
|
]
|
||||||
|
`
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
`
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(
|
||||||
|
|||||||
155
tools/forgeCLI/src/commands/modules/handlers/install-module.ts
Normal file
155
tools/forgeCLI/src/commands/modules/handlers/install-module.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
38
tools/forgeCLI/src/commands/modules/handlers/login-module.ts
Normal file
38
tools/forgeCLI/src/commands/modules/handlers/login-module.ts
Normal 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"`
|
||||||
|
)
|
||||||
|
}
|
||||||
283
tools/forgeCLI/src/commands/modules/handlers/migrate-module.ts
Normal file
283
tools/forgeCLI/src/commands/modules/handlers/migrate-module.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
125
tools/forgeCLI/src/commands/modules/handlers/uninstall-module.ts
Normal file
125
tools/forgeCLI/src/commands/modules/handlers/uninstall-module.ts
Normal 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!`
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
292
tools/forgeCLI/src/commands/modules/handlers/upgrade-module.ts
Normal file
292
tools/forgeCLI/src/commands/modules/handlers/upgrade-module.ts
Normal 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)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'))
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
18
tools/forgeCLI/src/templates/bare-bones/_tsconfig.json
Normal file
18
tools/forgeCLI/src/templates/bare-bones/_tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "./client/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"composite": false,
|
||||||
|
"paths": {
|
||||||
|
"@": [
|
||||||
|
"./client/index"
|
||||||
|
],
|
||||||
|
"@/*": [
|
||||||
|
"./client/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"./manifest.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
18
tools/forgeCLI/src/templates/client-only/_tsconfig.json
Normal file
18
tools/forgeCLI/src/templates/client-only/_tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "./client/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"composite": false,
|
||||||
|
"paths": {
|
||||||
|
"@": [
|
||||||
|
"./client/index"
|
||||||
|
],
|
||||||
|
"@/*": [
|
||||||
|
"./client/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"./manifest.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
19
tools/forgeCLI/src/templates/widget/.gitignore
vendored
19
tools/forgeCLI/src/templates/widget/.gitignore
vendored
@@ -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?
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"widgets": {
|
|
||||||
"{{camel moduleName.en}}": {
|
|
||||||
"title": "{{moduleName.en}}",
|
|
||||||
"description": "Widget for {{moduleDesc.en}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"widgets": {
|
|
||||||
"{{camel moduleName.en}}": {
|
|
||||||
"title": "{{moduleName.ms}}",
|
|
||||||
"description": "Widget untuk {{moduleDesc.ms}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"widgets": {
|
|
||||||
"{{camel moduleName.en}}": {
|
|
||||||
"title": "{{moduleName.zhCN}}",
|
|
||||||
"description": "为{{moduleDesc.zhCN}}而量身定制的小部件"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"widgets": {
|
|
||||||
"{{camel moduleName.en}}": {
|
|
||||||
"title": "{{moduleName.zhTW}}",
|
|
||||||
"description": "為{{moduleDesc.zhTW}}量身定制的小工具"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
18
tools/forgeCLI/src/templates/with-crud/_tsconfig.json
Normal file
18
tools/forgeCLI/src/templates/with-crud/_tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "./client/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"composite": false,
|
||||||
|
"paths": {
|
||||||
|
"@": [
|
||||||
|
"./client/index"
|
||||||
|
],
|
||||||
|
"@/*": [
|
||||||
|
"./client/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"./manifest.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
18
tools/forgeCLI/src/templates/with-routes/_tsconfig.json
Normal file
18
tools/forgeCLI/src/templates/with-routes/_tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "./client/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"composite": false,
|
||||||
|
"paths": {
|
||||||
|
"@": [
|
||||||
|
"./client/index"
|
||||||
|
],
|
||||||
|
"@/*": [
|
||||||
|
"./client/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"./manifest.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user