feat(tools): WIP

This commit is contained in:
Melvin Chia
2026-01-18 21:26:32 +08:00
parent 4e18e9a538
commit cab4276ade
28 changed files with 259 additions and 175 deletions

View File

@@ -23,7 +23,7 @@
"prompts": "^2.4.2",
"semver": "^7.7.3",
"shared": "workspace:*",
"zod": "^4.3.5",
"zod": "4.3.5",
"@lifeforge/log": "workspace:*"
},
"devDependencies": {

View File

@@ -53,7 +53,7 @@ async function processCollectionSchema(
* // const schemas = {
* // events: {
* // schema: z.object({ title: z.string(), ... }),
* // raw: { name: 'calendar__events', ... }
* // raw: { name: 'events', ... }
* // },
* // }
* //
@@ -70,10 +70,11 @@ export default async function generateSchemaContent(
}
return `import z from 'zod'
import { cleanSchemas } from '@lifeforge/server-utils'
const schemas = {
export const schemas = {
${schemaEntries.join('\n')}
}
export default schemas`
export default cleanSchemas(schemas)`
}

View File

@@ -18,7 +18,7 @@ import getPBInstance from '@/utils/pocketbase'
*
* @example
* const collections = await getCollectionsFromPB()
* // Returns: [{ name: 'calendar__events', type: 'base', ... }, ...]
* // Returns: [{ name: 'events', type: 'base', ... }, ...]
*/
export default async function getCollectionsFromPB() {
logger.debug('Connecting to PocketBase...')

View File

@@ -7,8 +7,8 @@ import { parsePackageName } from '@/commands/modules/functions/parsePackageName'
* Finds the module that owns a given PocketBase collection.
*
* PocketBase collection names follow a naming convention:
* - First-party: `moduleName__collectionName` (e.g., `calendar__events`)
* - Third-party: `username___moduleName__collectionName` (e.g., `melvinchia3636___melvinchia3636$invoiceMaker__clients`)
* - First-party: `moduleName__collectionName` (e.g., `events`)
* - Third-party: `username___moduleName__collectionName` (e.g., `melvinchia3636___clients`)
*
* This function parses the collection name and matches it against registered modules
* by comparing the username and module name prefixes.
@@ -20,8 +20,8 @@ import { parsePackageName } from '@/commands/modules/functions/parsePackageName'
* @returns Matching module directory path, or undefined if no match found
*
* @example
* // Collection 'calendar__events' matches module at '/apps/lifeforge--calendar'
* // Collection 'melvinchia3636___melvinchia3636$invoiceMaker__clients' matches '/apps/melvinchia3636--invoice-maker'
* // Collection 'events' matches module at '/apps/lifeforge--calendar'
* // Collection 'melvinchia3636___clients' matches '/apps/melvinchia3636--invoice-maker'
*/
export async function matchCollectionToModule(
allModules: string[],

View File

@@ -90,7 +90,7 @@ export async function importSchemaModules(targetModule?: string): Promise<
return {
moduleName: getModuleName(schemaPath) || 'unknown-module',
schema: module.default
schema: module.schemas
}
})
)

View File

@@ -29,7 +29,7 @@ export default function generateRouteRegistry() {
.join('\n')
const registry = `// AUTO-GENERATED - DO NOT EDIT
import { forgeRouter } from '@functions/routes'
import { forgeRouter } from '@lifeforge/server-utils'
const appRoutes = forgeRouter({
${imports}

View File

@@ -8,16 +8,28 @@ import normalizePackage from '@/utils/normalizePackage'
import listModules from '../functions/listModules'
interface BuildOptions {
docker?: boolean
}
/**
* Builds module client and server bundles.
*
* For each module:
* - If client/vite.config.ts exists: Runs `bun run build:client`
* - If server/index.ts exists: Runs `bun run build:server`
*
* @param moduleName - Optional module name to build (builds all if omitted)
* @param options.docker - If true, builds for Docker (outputs to dist-docker with /api base)
*/
export async function buildModuleHandler(moduleName?: string): Promise<void> {
export async function buildModuleHandler(
moduleName?: string,
options?: BuildOptions
): Promise<void> {
const modules = listModules()
const isDocker = options?.docker ?? false
const moduleNames = moduleName
? [normalizePackage(moduleName).fullName]
: Object.keys(modules)
@@ -26,6 +38,10 @@ export async function buildModuleHandler(moduleName?: string): Promise<void> {
let serverBuiltCount = 0
let skippedCount = 0
if (isDocker) {
logger.info('Building for Docker (output: dist-docker, base: /api)')
}
for (const mod of moduleNames) {
const { targetDir, shortName } = normalizePackage(mod)
@@ -56,7 +72,8 @@ export async function buildModuleHandler(moduleName?: string): Promise<void> {
try {
executeCommand('bun run build:client', {
cwd: targetDir,
stdio: 'pipe'
stdio: 'pipe',
env: isDocker ? { ...process.env, DOCKER_BUILD: 'true' } : undefined
})
clientBuiltCount++
} catch (error) {

View File

@@ -55,6 +55,10 @@ export default function setup(program: Command): void {
.alias('b')
.description('Build module client bundles for federation')
.argument('[module]', 'Module to build (optional, builds all if omitted)')
.option(
'--docker',
'Build for Docker (outputs to dist-docker with /api base)'
)
.action(buildModuleHandler)
command

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env bun
/**
* Script to add build:server script to all module package.json files
*/
import fs from 'fs'
import path from 'path'
// Resolve from the lifeforge root (where this script is run from)
const ROOT_DIR = process.cwd()
const APPS_DIR = path.resolve(ROOT_DIR, 'apps')
const BUILD_SERVER_SCRIPT =
'bun build ./server/index.ts --outdir ./server/dist --target bun --external @lifeforge/server-utils --external zod'
async function main() {
const moduleDirs = fs.readdirSync(APPS_DIR).filter(name => {
const fullPath = path.join(APPS_DIR, name)
return (
fs.statSync(fullPath).isDirectory() &&
!name.startsWith('.') &&
name !== 'node_modules'
)
})
let updatedCount = 0
let skippedCount = 0
let noServerCount = 0
for (const moduleName of moduleDirs) {
const moduleDir = path.join(APPS_DIR, moduleName)
const packageJsonPath = path.join(moduleDir, 'package.json')
const serverIndexPath = path.join(moduleDir, 'server', 'index.ts')
// Check if server/index.ts exists
if (!fs.existsSync(serverIndexPath)) {
console.log(`⏭️ Skipping ${moduleName} (no server/index.ts)`)
noServerCount++
continue
}
if (!fs.existsSync(packageJsonPath)) {
console.log(`⚠️ ${moduleName}: No package.json found`)
skippedCount++
continue
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'))
// Initialize scripts if not present
if (!packageJson.scripts) {
packageJson.scripts = {}
}
// Check if build:server already exists
if (packageJson.scripts['build:server']) {
console.log(`⏭️ ${moduleName}: build:server already exists`)
skippedCount++
continue
}
// Add build:server script
packageJson.scripts['build:server'] = BUILD_SERVER_SCRIPT
// Write back
fs.writeFileSync(
packageJsonPath,
JSON.stringify(packageJson, null, 2) + '\n'
)
console.log(`${moduleName}: Added build:server script`)
updatedCount++
}
console.log('\n--- Summary ---')
console.log(`Updated: ${updatedCount}`)
console.log(
`Skipped (already had script or no package.json): ${skippedCount}`
)
console.log(`Skipped (no server): ${noServerCount}`)
}
main().catch(console.error)

View File

@@ -34,16 +34,11 @@
"./*"
],
"@server/*": [
"../../../server/src/*"
"../server/*"
]
}
},
"include": [
"./**/*"
],
"references": [
{
"path": "../../../server/tsconfig.json"
}
]
}

View File

@@ -1,10 +1,11 @@
import { type AppRoutes } from '@server/core/routes/routes.type'
import { createForgeAPIClient } from 'shared'
import routes from '@server/index'
import routes from '@server/index'
import { createForgeProxy } from 'shared'
if (!import.meta.env.VITE_API_HOST) {
throw new Error('VITE_API_HOST is not defined')
}
const forgeAPI = createForgeAPIClient<AppRoutes>(import.meta.env.VITE_API_HOST)
const forgeAPI = createForgeProxy<typeof routes>(import.meta.env.VITE_API_HOST)
export default forgeAPI

View File

@@ -18,7 +18,7 @@
"shared": "workspace:*",
"tailwindcss": "^4.1.14",
"vite": "^7.1.9",
"zod": "^4.3.5"
"zod": "4.3.5"
},
"devDependencies": {
"@types/react": "^19.2.0",
@@ -32,4 +32,4 @@
},
"./server/schema": "./server/schema.ts"
}
}
}

View File

@@ -1,9 +1,4 @@
{
"extends": "../../../server/tsconfig.json",
"include": ["./**/*.ts"],
"references": [
{
"path": "../../../server/tsconfig.json"
}
]
"include": ["./**/*.ts"]
}

View File

@@ -1,3 +1,6 @@
import { forgeRouter } from '@functions/routes'
import { forgeRouter } from '@lifeforge/server-utils'
import { createForge } from '@lifeforge/server-utils'
const forge = createForge(schema)
export default forgeRouter({})

View File

@@ -34,16 +34,11 @@
"./*"
],
"@server/*": [
"../../../server/src/*"
"../server/*"
]
}
},
"include": [
"./**/*"
],
"references": [
{
"path": "../../../server/tsconfig.json"
}
]
}

View File

@@ -1,33 +1,33 @@
{
"name": "@lifeforge/lifeforge--{{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.2",
"@uidotdev/usehooks": "^2.4.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.18",
"lifeforge-ui": "workspace:*",
"react": "^19.2.0",
"react-i18next": "^15.1.1",
"react-toastify": "^11.0.5",
"shared": "workspace:*",
"tailwindcss": "^4.1.14",
"vite": "^7.1.9",
"zod": "^4.3.5"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0"
},
"exports": {
"./manifest": {
"types": "./manifest.d.ts",
"default": "./manifest.ts"
}
"name": "@lifeforge/lifeforge--{{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.2",
"@uidotdev/usehooks": "^2.4.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.18",
"lifeforge-ui": "workspace:*",
"react": "^19.2.0",
"react-i18next": "^15.1.1",
"react-toastify": "^11.0.5",
"shared": "workspace:*",
"tailwindcss": "^4.1.14",
"vite": "^7.1.9",
"zod": "4.3.5"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0"
},
"exports": {
"./manifest": {
"types": "./manifest.d.ts",
"default": "./manifest.ts"
}
}
}
}

View File

@@ -34,16 +34,11 @@
"./index/*"
],
"@server/*": [
"../../../server/src/*"
"../server/*"
]
}
},
"include": [
"./**/*"
],
"references": [
{
"path": "../../../server/tsconfig.json"
}
]
}

View File

@@ -1,10 +1,11 @@
import { type AppRoutes } from '@server/core/routes/routes.type'
import { createForgeAPIClient } from 'shared'
import routes from '@server/index'
import routes from '@server/index'
import { createForgeProxy } from 'shared'
if (!import.meta.env.VITE_API_HOST) {
throw new Error('VITE_API_HOST is not defined')
}
const forgeAPI = createForgeAPIClient<AppRoutes>(import.meta.env.VITE_API_HOST)
const forgeAPI = createForgeProxy<typeof routes>(import.meta.env.VITE_API_HOST)
export default forgeAPI

View File

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

View File

@@ -1,9 +1,4 @@
{
"extends": "../../../server/tsconfig.json",
"include": ["./**/*.ts"],
"references": [
{
"path": "../../../server/tsconfig.json"
}
]
"include": ["./**/*.ts"]
}

View File

@@ -1,7 +1,10 @@
import { forgeRouter } from '@functions/routes'
import { forgeRouter } from '@lifeforge/server-utils'
import { createForge } from '@lifeforge/server-utils'
import * as entriesRoutes from './routes/entries'
const forge = createForge(schema)
export default forgeRouter({
entries: entriesRoutes
})

View File

@@ -1,14 +1,14 @@
import { forgeController } from '@functions/routes'
import { SCHEMAS } from '@schema'
import z from 'zod'
export const list = forgeController
export const list = forge
.query()
.description('List all entries')
.input({})
.callback(({ pb }) => pb.getFullList.collection('{{snake moduleName.en}}__entries').execute())
export const getById = forgeController
export const getById = forge
.query()
.description('Get entry by ID')
.input({
@@ -23,24 +23,24 @@ export const getById = forgeController
pb.getOne.collection('{{snake moduleName.en}}__entries').id(id).execute()
)
export const create = forgeController
export const create = forge
.mutation()
.description('Create a new entry')
.input({
body: SCHEMAS.{{snake moduleName.en}}.entries.schema.omit({ created: true, updated: true })
body: SCHEMAS.{{snake moduleName.en}}.entries.omit({ created: true, updated: true })
})
.callback(({ pb, body }) =>
pb.create.collection('{{snake moduleName.en}}__entries').data(body).execute()
)
export const update = forgeController
export const update = forge
.mutation()
.description('Update an existing entry')
.input({
query: z.object({
id: z.string()
}),
body: SCHEMAS.{{snake moduleName.en}}.entries.schema
body: SCHEMAS.{{snake moduleName.en}}.entries
.partial()
.omit({ created: true, updated: true })
})
@@ -51,7 +51,7 @@ export const update = forgeController
pb.update.collection('{{snake moduleName.en}}__entries').id(id).data(body).execute()
)
export const remove = forgeController
export const remove = forge
.mutation()
.description('Delete an entry')
.input({

View File

@@ -34,16 +34,11 @@
"./*"
],
"@server/*": [
"../../../server/src/*"
"../server/*"
]
}
},
"include": [
"./**/*"
],
"references": [
{
"path": "../../../server/tsconfig.json"
}
]
}

View File

@@ -1,10 +1,11 @@
import { type AppRoutes } from '@server/core/routes/routes.type'
import { createForgeAPIClient } from 'shared'
import routes from '@server/index'
import routes from '@server/index'
import { createForgeProxy } from 'shared'
if (!import.meta.env.VITE_API_HOST) {
throw new Error('VITE_API_HOST is not defined')
}
const forgeAPI = createForgeAPIClient<AppRoutes>(import.meta.env.VITE_API_HOST)
const forgeAPI = createForgeProxy<typeof routes>(import.meta.env.VITE_API_HOST)
export default forgeAPI

View File

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

View File

@@ -1,9 +1,4 @@
{
"extends": "../../../server/tsconfig.json",
"include": ["./**/*.ts"],
"references": [
{
"path": "../../../server/tsconfig.json"
}
]
"include": ["./**/*.ts"]
}

View File

@@ -1,3 +1,6 @@
import { forgeRouter } from '@functions/routes'
import { forgeRouter } from '@lifeforge/server-utils'
import { createForge } from '@lifeforge/server-utils'
const forge = createForge(schema)
export default forgeRouter({})

View File

@@ -26,7 +26,7 @@ export async function getAPIKey(): Promise<string | null> {
const { pb, killPB } = await getPBInstance()
const apiKey = await pb
.collection('api_keys__entries')
.collection('entries')
.getFirstListItem('keyId="openai"')
.catch(() => {})