refactor(forgeCLI): force myself to be a vibe code cleanup specialist

This commit is contained in:
Melvin Chia
2026-01-04 20:47:10 +08:00
parent 8671b7f408
commit 4a0ffcba1b
76 changed files with 1538 additions and 1598 deletions

View File

@@ -23,7 +23,7 @@
"prompts": "^2.4.2",
"semver": "^7.7.3",
"shared": "workspace:*",
"zod": "^4.3.4"
"zod": "^4.1.12"
},
"devDependencies": {
"@types/crypto-js": "^4.2.2",

View File

@@ -51,5 +51,5 @@ export default function createChangelogHandler(year?: string, week?: string) {
fs.writeFileSync(filePath, boilerPlate)
Logging.success(`Changelog file created at path: ${filePath}`)
Logging.success(`Created changelog file at path: ${filePath}`)
}

View File

@@ -1,10 +1,10 @@
import chalk from 'chalk'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { PB_BINARY_PATH, PB_DIR } from '@/constants/db'
import { executeCommand, isDockerMode } from '@/utils/helpers'
import executeCommand from '@/utils/commands'
import { isDockerMode } from '@/utils/helpers'
import Logging from '@/utils/logging'
const PB_VERSION = '0.35.0'
@@ -27,9 +27,6 @@ export async function downloadPocketBaseBinary(): Promise<void> {
return
}
Logging.step('PocketBase binary not found, downloading...')
// Detect OS
const platform = os.platform()
let osName: string
@@ -45,7 +42,10 @@ export async function downloadPocketBaseBinary(): Promise<void> {
osName = 'windows'
break
default:
Logging.error(`Unsupported platform: ${platform}`)
Logging.actionableError(
`Unsupported platform: ${platform}`,
'PocketBase supports darwin, linux, and windows'
)
process.exit(1)
}
@@ -62,14 +62,17 @@ export async function downloadPocketBaseBinary(): Promise<void> {
archName = 'amd64'
break
default:
Logging.error(`Unsupported architecture: ${arch}`)
Logging.actionableError(
`Unsupported architecture: ${arch}`,
'PocketBase supports arm64 and amd64'
)
process.exit(1)
}
const downloadUrl = `https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_${osName}_${archName}.zip`
Logging.info(
`Downloading PocketBase v${PB_VERSION} for ${osName}/${archName}...`
`Downloading PocketBase ${Logging.highlight(`v${PB_VERSION}`)} for ${osName}/${archName}...`
)
try {
@@ -98,7 +101,7 @@ export async function downloadPocketBaseBinary(): Promise<void> {
// Clean up zip file and unnecessary files
fs.unlinkSync(zipPath)
const changelogPath = path.join(PB_DIR, 'CHANGELOG.md')
const changelogPath = path.join(PB_DIR, 'CHANGELogging.md')
const licensePath = path.join(PB_DIR, 'LICENSE.md')
@@ -111,11 +114,12 @@ export async function downloadPocketBaseBinary(): Promise<void> {
}
Logging.success(
`PocketBase v${PB_VERSION} downloaded to ${chalk.bold.blue(PB_BINARY_PATH)}`
`Downloaded PocketBase ${Logging.highlight(`v${PB_VERSION}`)}`
)
} catch (error) {
Logging.error(
`Failed to download PocketBase: ${error instanceof Error ? error.message : 'Unknown error'}`
Logging.actionableError(
`Failed to download PocketBase: ${error instanceof Error ? error.message : 'Unknown error'}`,
'Check your internet connection and try again'
)
process.exit(1)
}

View File

@@ -1,27 +0,0 @@
import { PB_BINARY_PATH, PB_KWARGS } from '@/constants/db'
import { executeCommand } from '@/utils/helpers'
import Logging from '@/utils/logging'
/**
* Runs database migrations
*/
export function runDatabaseMigrations(): void {
try {
Logging.step('Migrating database schema to latest state...')
executeCommand(`bun forge db push`, {
stdio: ['pipe', 'pipe', 'pipe']
})
Logging.success('Initial migration generated successfully.')
executeCommand(`${PB_BINARY_PATH} migrate up ${PB_KWARGS.join(' ')}`, {
stdio: ['pipe', 'pipe', 'pipe']
})
Logging.success('Database schema migrated successfully.')
} catch (error) {
Logging.error(
`Failed to generate initial migration: ${
error instanceof Error ? error.message : 'Unknown error'
}`
)
process.exit(1)
}
}

View File

@@ -1,8 +1,7 @@
import chalk from 'chalk'
import fs from 'fs'
import { PB_BINARY_PATH, PB_DATA_DIR, PB_KWARGS } from '@/constants/db'
import { executeCommand } from '@/utils/helpers'
import executeCommand from '@/utils/commands'
import Logging from '@/utils/logging'
/**
@@ -15,7 +14,7 @@ export function validatePocketBaseNotInitialized(
if (pbInitialized && exitOnFailure) {
Logging.actionableError(
`PocketBase is already initialized in ${PB_DATA_DIR}, aborting.`,
`PocketBase is already initialized in ${Logging.highlight(PB_DATA_DIR)}, aborting.`,
'If you want to re-initialize, please remove the existing pb_data folder in the database directory.'
)
process.exit(1)
@@ -31,11 +30,9 @@ export function createPocketBaseSuperuser(
email: string,
password: string
): void {
try {
Logging.step(
`Initializing PocketBase database for ${chalk.bold.blue(email)}`
)
Logging.debug(`Creating superuser with email ${Logging.highlight(email)}...`)
try {
const result = executeCommand(
`${PB_BINARY_PATH} superuser create ${PB_KWARGS.join(' ')}`,
{
@@ -48,14 +45,11 @@ export function createPocketBaseSuperuser(
throw new Error(result.replace(/^Error:\s*/, ''))
}
Logging.success(
'PocketBase initialized and superuser created successfully.'
)
Logging.success('Created superuser')
} catch (error) {
Logging.error(
`Failed to create superuser: ${
error instanceof Error ? error.message : 'Unknown error'
}`
Logging.actionableError(
`Failed to create superuser: ${error instanceof Error ? error.message : 'Unknown error'}`,
'Check your PocketBase configuration and try again'
)
process.exit(1)
}

View File

@@ -0,0 +1,21 @@
import { PB_BINARY_PATH, PB_KWARGS } from '@/constants/db'
import executeCommand from '@/utils/commands'
import Logging, { LEVEL_ORDER } from '@/utils/logging'
/**
* Runs `pocketbase migrate up` to apply all pending migrations in the migrations directory.
*
* In debug mode, outputs the full migration log to the console.
* Otherwise, suppresses output for cleaner logs.
*
* @throws Error if the migration command fails
*/
export default function applyStagedMigration(): void {
Logging.debug('Applying pending migrations...')
executeCommand(`${PB_BINARY_PATH} migrate up ${PB_KWARGS.join(' ')}`, {
stdio: Logging.level > LEVEL_ORDER.debug ? 'pipe' : 'inherit'
})
Logging.debug('Migrations applied successfully')
}

View File

@@ -0,0 +1,51 @@
import path from 'path'
import Logging from '@/utils/logging'
import { getSchemaFiles } from '../../utils'
/**
* Builds a mapping from PocketBase collection IDs to their human-readable names.
*
* This map is essential for resolving relation fields during migration generation,
* as relations store collection IDs but we need to reference collections by name
* in the generated `schema.ts` for portability across environments.
*
* @param targetModule - Optional module name to filter schemas. If omitted, processes all modules.
* @returns A Map where keys are collection IDs and values are collection names
*
* @example
* const idMap = await buildIdToNameMap()
* // Map { 'abc123' => 'users', 'def456' => 'posts' }
*/
export default async function buildIdToNameMap(
targetModule?: string
): Promise<Map<string, string>> {
const schemaFiles = getSchemaFiles(targetModule)
const idToNameMap = new Map<string, string>()
for (const schemaPath of schemaFiles) {
const schema: Record<
string,
{
raw: {
id: string
name: string
}
}
> = await import(path.resolve(schemaPath))
for (const entry of Object.values(schema)) {
const raw = entry?.raw
if (raw?.id && raw?.name) {
idToNameMap.set(raw.id as string, raw.name as string)
}
}
}
Logging.debug(`Built ID-to-name map with ${idToNameMap.size} collections`)
return idToNameMap
}

View File

@@ -0,0 +1,71 @@
import { execSync } from 'child_process'
import fs from 'fs'
import prettier from 'prettier'
import { PB_BINARY_PATH, PB_KWARGS } from '@/constants/db'
import { PRETTIER_OPTIONS } from '../../utils'
/**
* Creates a single PocketBase migration file with custom up migration content.
*
* This function:
* 1. Uses PocketBase CLI to generate a new migration file scaffold
* 2. Parses the output to find the created file path
* 3. Replaces the placeholder comments with the provided migration content
* 4. Formats the file with Prettier for consistent code style
*
* Down migrations are intentionally left as comments since automatic rollback
* could cause data loss - users should manually handle rollbacks.
*
* @param name - Name for the migration file (e.g., 'skeleton_users', 'structure_posts')
* @param upContent - JavaScript code to execute during `migrate up`. If empty, skips creation.
*
* @throws Error if PocketBase CLI fails to create the migration file
* @throws Error if the created file path cannot be parsed from the CLI output
* @throws Error if the created file doesn't exist at the expected path
*/
export default async function createSingleMigration(
name: string,
upContent: string
): Promise<void> {
if (!upContent) {
return
}
const response = execSync(
`${PB_BINARY_PATH} migrate create ${name} ${PB_KWARGS.join(' ')}`,
{
input: 'y\n',
stdio: ['pipe', 'pipe', 'pipe']
}
)
const resString = response.toString()
const match = resString.match(/Successfully created file "(.*)"/)
if (!match || match.length < 2) {
throw new Error('Failed to parse migration file path from response.')
}
const migrationFilePath = match[1]
if (!fs.existsSync(migrationFilePath)) {
throw new Error(`Migration file not found at path: ${migrationFilePath}`)
}
let content = fs.readFileSync(migrationFilePath, 'utf-8')
content = content
.replace('../pb_data/types.d.ts', '../types.d.ts')
.replace('// add up queries...', upContent)
.replace(
'// add down queries...',
'// Users need to manually undo changes to prevent data loss'
)
const formattedContent = await prettier.format(content, PRETTIER_OPTIONS)
fs.writeFileSync(migrationFilePath, formattedContent, 'utf-8')
}

View File

@@ -0,0 +1,71 @@
/**
* Generates skeleton migration content for the first phase of migration.
*
* Skeleton migrations create minimal stub collections that only include:
* - Collection name and type
* - Access rules (list, view, create, update, delete)
* - Placeholder viewQuery for view collections
*
* This phase runs first to ensure all collections exist before the structure
* phase attempts to create relations between them. Without skeleton collections,
* relations would fail because the target collection doesn't exist yet. If the
* collection already exists, the migration will be skipped.
*
* @param schema - Module schema with collection definitions
* @returns JavaScript code string for the up migration
*
* @example
* // Generated migration creates collections only if they don't exist:
* // let usersExisting = null;
* // try { usersExisting = app.findCollectionByNameOrId('users'); } catch (e) {}
* // if (!usersExisting) { app.importCollections([usersStub], false); }
*/
export default function generateContent(
schema: Record<string, { raw: Record<string, unknown> }>
): string {
const lines: string[] = []
lines.push('// Create skeleton collections (only if they do not exist)')
for (const [name, { raw }] of Object.entries(schema)) {
const collectionName = raw.name as string
lines.push(`
// ${collectionName}
let ${name}Existing = null;
try {
${name}Existing = app.findCollectionByNameOrId('${collectionName}');
} catch (e) {
// Collection doesn't exist yet
}
if (!${name}Existing) {`)
const stubCollection: Record<string, unknown> = {
name: collectionName,
type: raw.type || 'base',
listRule: raw.listRule,
viewRule: raw.viewRule,
createRule: raw.createRule,
updateRule: raw.updateRule,
deleteRule: raw.deleteRule
}
// View collections require a viewQuery
if (raw.type === 'view') {
// Use placeholder query with required id column - real query will be set in structure migration
stubCollection.viewQuery = 'SELECT (ROW_NUMBER() OVER()) as id'
}
lines.push(
` const ${name}Stub = ${JSON.stringify(stubCollection, null, 2)
.split('\n')
.map((l, i) => (i === 0 ? l : ' ' + l))
.join('\n')};`
)
lines.push(` app.importCollections([${name}Stub], false);`)
lines.push(` }`)
}
return lines.join('\n')
}

View File

@@ -0,0 +1,134 @@
/**
* Removes auto-generated IDs and timestamps from a raw collection config.
*
* PocketBase generates unique IDs for collections and fields, but these
* aren't portable across environments. By stripping them, the migration
* code works consistently regardless of the source database.
*
* Normally in generated `schema.ts`, these fields are already stripped.
* But in case the schema is generated from a different source, this serves
* as another layer of safety to ensure the migration code works.
*
* @param raw - The raw collection configuration from the schema
* @returns Cleaned config without id, created, updated fields
*/
function stripIdsFromRaw(
raw: Record<string, unknown>
): Record<string, unknown> {
const cleaned = { ...raw }
delete cleaned.id
delete cleaned.created
delete cleaned.updated
if (cleaned.fields && Array.isArray(cleaned.fields)) {
cleaned.fields = cleaned.fields.map((field: Record<string, unknown>) => {
const cleanedField = { ...field }
delete cleanedField.id
return cleanedField
})
}
return cleaned
}
/**
* Extracts relation fields from a collection that need dynamic collectionId resolution.
*
* Relation fields store the target collection's ID, but IDs aren't portable.
* This function identifies which fields need their collectionId resolved
* at migration runtime using `findCollectionByNameOrId`.
*
* @param raw - The raw collection configuration
* @returns Array of relation field info with field name and target collection name
*/
function extractRelationFields(
raw: Record<string, unknown>
): Array<{ fieldName: string; collectionName: string }> {
const relations: Array<{ fieldName: string; collectionName: string }> = []
const fields = raw.fields as Array<Record<string, unknown>> | undefined
if (fields) {
for (const field of fields) {
if (field.type === 'relation' && field.collectionId) {
relations.push({
fieldName: field.name as string,
collectionName: field.collectionId as string // Already converted to name in schema
})
}
}
}
return relations
}
/**
* Generates structure migration content for the second phase of migration.
*
* Structure migrations update existing collections with their full schema including:
* - All field definitions (text, number, relation, file, etc.)
* - Field options and validation rules
* - Dynamically resolved relation collectionIds using `findCollectionByNameOrId`
*
* View collections are EXCLUDED from this phase because their viewQuery may
* reference other collections that aren't fully set up yet. Views are handled
* in the separate views phase.
*
* @param schema - Module schema with collection definitions
* @param idToNameMap - Map of collection IDs to names for resolving relations
* @returns JavaScript code string for the up migration
*/
export default function generateContent(
schema: Record<string, { raw: Record<string, unknown> }>,
idToNameMap: Map<string, string>
): string {
// Filter out view collections - they are handled in view query migration
const nonViewCollections = Object.entries(schema).filter(
([, { raw }]) => raw.type !== 'view'
)
const lines: string[] = []
lines.push('// Update collections with full schema (excluding views)')
for (const [name, { raw }] of nonViewCollections) {
const cleanedRaw = stripIdsFromRaw(raw)
const relations = extractRelationFields(cleanedRaw)
lines.push('')
lines.push(` // Get existing collection and update it`)
lines.push(
` const ${name}Existing = app.findCollectionByNameOrId('${raw.name}');`
)
lines.push(
` const ${name}Collection = ${JSON.stringify(cleanedRaw, null, 2)
.split('\n')
.map((l, i) => (i === 0 ? l : ' ' + l))
.join('\n')};`
)
// For each relation field, resolve the collectionId dynamically using NAME not ID
for (const { fieldName, collectionName } of relations) {
// Convert ID to name if needed
const resolvedName = idToNameMap.get(collectionName) || collectionName
lines.push(
` const ${name}_${fieldName}_rel = app.findCollectionByNameOrId('${resolvedName}');`
)
lines.push(
` ${name}Collection.fields.find(f => f.name === '${fieldName}').collectionId = ${name}_${fieldName}_rel.id;`
)
}
lines.push(` ${name}Collection.id = ${name}Existing.id;`)
lines.push(` app.importCollections([${name}Collection], false);`)
}
const upContent = lines.join('\n')
return upContent
}

View File

@@ -0,0 +1,52 @@
/**
* Generates view query migration content for the third phase of migration.
*
* View collections in PocketBase are defined by SQL queries that reference
* other collections. This phase runs LAST because:
* 1. All referenced collections must exist (created in skeleton phase)
* 2. All fields must be defined (set in structure phase)
* 3. The viewQuery can now safely reference any table/field
*
* During skeleton phase, views are created with a placeholder query:
* `SELECT (ROW_NUMBER() OVER()) as id`
*
* This phase replaces that placeholder with the actual viewQuery from the schema.
*
* @param schema - Module schema with collection definitions
* @returns JavaScript code string for the up migration, or empty string if no views
*
* @example
* // For a view collection 'users_aggregated':
* // const users_aggregated = app.findCollectionByNameOrId('users_aggregated');
* // users_aggregated.viewQuery = 'SELECT ... FROM users GROUP BY ...';
* // app.save(users_aggregated);
*/
export default function generateContent(
schema: Record<string, { raw: Record<string, unknown> }>
): string {
const viewCollections = Object.entries(schema).filter(
([, { raw }]) => raw.type === 'view'
)
if (viewCollections.length === 0) {
return ''
}
const lines: string[] = []
lines.push('// Update view collections with their actual viewQuery')
for (const [name, { raw }] of viewCollections) {
lines.push('')
lines.push(` // ${raw.name}`)
lines.push(
` const ${name}Collection = app.findCollectionByNameOrId('${raw.name}');`
)
lines.push(
` ${name}Collection.viewQuery = ${JSON.stringify(raw.viewQuery)};`
)
lines.push(` app.save(${name}Collection);`)
}
return lines.join('\n')
}

View File

@@ -1,224 +0,0 @@
/**
* Generates skeleton migration content - creates stub collections if they don't exist
*/
export function generateSkeletonMigrationContent(
schema: Record<string, { raw: Record<string, unknown> }>
): {
upContent: string
downContent: string
} {
const lines: string[] = []
lines.push('// Create skeleton collections (only if they do not exist)')
for (const [name, { raw }] of Object.entries(schema)) {
const collectionName = raw.name as string
lines.push('')
lines.push(` // ${collectionName}`)
lines.push(` let ${name}Existing = null;`)
lines.push(` try {`)
lines.push(
` ${name}Existing = app.findCollectionByNameOrId('${collectionName}');`
)
lines.push(` } catch (e) {`)
lines.push(` // Collection doesn't exist yet`)
lines.push(` }`)
lines.push('')
lines.push(` if (!${name}Existing) {`)
const stubCollection: Record<string, unknown> = {
name: collectionName,
type: raw.type || 'base',
listRule: raw.listRule,
viewRule: raw.viewRule,
createRule: raw.createRule,
updateRule: raw.updateRule,
deleteRule: raw.deleteRule
}
// View collections require a viewQuery
if (raw.type === 'view') {
// Use placeholder query with required id column - real query will be set in structure migration
stubCollection.viewQuery = 'SELECT (ROW_NUMBER() OVER()) as id'
}
lines.push(
` const ${name}Stub = ${JSON.stringify(stubCollection, null, 2)
.split('\n')
.map((l, i) => (i === 0 ? l : ' ' + l))
.join('\n')};`
)
lines.push(` app.importCollections([${name}Stub], false);`)
lines.push(` }`)
}
const upContent = lines.join('\n')
// Down migration: delete only if we created them (no-op for safety)
const downContent = '// Skeleton collections are not deleted to preserve data'
return { upContent, downContent }
}
/**
* Strips IDs from raw collection config
*/
function stripIdsFromRaw(
raw: Record<string, unknown>
): Record<string, unknown> {
const cleaned = { ...raw }
delete cleaned.id
delete cleaned.created
delete cleaned.updated
if (cleaned.fields && Array.isArray(cleaned.fields)) {
cleaned.fields = cleaned.fields.map((field: Record<string, unknown>) => {
const cleanedField = { ...field }
delete cleanedField.id
return cleanedField
})
}
return cleaned
}
/**
* Extracts relation fields that need dynamic collectionId resolution
*/
function extractRelationFields(
raw: Record<string, unknown>
): Array<{ fieldName: string; collectionName: string }> {
const relations: Array<{ fieldName: string; collectionName: string }> = []
const fields = raw.fields as Array<Record<string, unknown>> | undefined
if (fields) {
for (const field of fields) {
if (field.type === 'relation' && field.collectionId) {
relations.push({
fieldName: field.name as string,
collectionName: field.collectionId as string // Already converted to name in schema
})
}
}
}
return relations
}
/**
* Generates structure migration content - updates collections with full schema
* Uses findCollectionByNameOrId to resolve relation collectionIds dynamically
* NOTE: View collections are excluded - they are handled separately in view query migration
*/
export function generateStructureMigrationContent(
schema: Record<string, { raw: Record<string, unknown> }>,
idToNameMap: Map<string, string>
): {
upContent: string
downContent: string
} {
// Filter out view collections - they are handled in view query migration
const nonViewCollections = Object.entries(schema).filter(
([, { raw }]) => raw.type !== 'view'
)
const lines: string[] = []
lines.push('// Update collections with full schema (excluding views)')
for (const [name, { raw }] of nonViewCollections) {
const cleanedRaw = stripIdsFromRaw(raw)
const relations = extractRelationFields(cleanedRaw)
lines.push('')
lines.push(` // Get existing collection and update it`)
lines.push(
` const ${name}Existing = app.findCollectionByNameOrId('${raw.name}');`
)
lines.push(
` const ${name}Collection = ${JSON.stringify(cleanedRaw, null, 2)
.split('\n')
.map((l, i) => (i === 0 ? l : ' ' + l))
.join('\n')};`
)
// For each relation field, resolve the collectionId dynamically using NAME not ID
for (const { fieldName, collectionName } of relations) {
// Convert ID to name if needed
const resolvedName = idToNameMap.get(collectionName) || collectionName
lines.push(
` const ${name}_${fieldName}_rel = app.findCollectionByNameOrId('${resolvedName}');`
)
lines.push(
` ${name}Collection.fields.find(f => f.name === '${fieldName}').collectionId = ${name}_${fieldName}_rel.id;`
)
}
lines.push(` ${name}Collection.id = ${name}Existing.id;`)
lines.push(` app.importCollections([${name}Collection], false);`)
}
const upContent = lines.join('\n')
const downContent = nonViewCollections
.map(([, { raw }]) => {
return ` const ${raw.name}Collection = app.findCollectionByNameOrId("${raw.name}");\n app.delete(${raw.name}Collection);`
})
.join('\n\n')
return { upContent, downContent }
}
/**
* Generates view query migration content - updates view collections with their actual viewQuery
* This must run AFTER all normal collection structures are in place
*/
export function generateViewQueryMigrationContent(
schema: Record<string, { raw: Record<string, unknown> }>
): {
upContent: string
downContent: string
hasViews: boolean
} {
const viewCollections = Object.entries(schema).filter(
([, { raw }]) => raw.type === 'view'
)
if (viewCollections.length === 0) {
return { upContent: '', downContent: '', hasViews: false }
}
const lines: string[] = []
lines.push('// Update view collections with their actual viewQuery')
for (const [name, { raw }] of viewCollections) {
lines.push('')
lines.push(` // ${raw.name}`)
lines.push(
` const ${name}Collection = app.findCollectionByNameOrId('${raw.name}');`
)
lines.push(
` ${name}Collection.viewQuery = ${JSON.stringify(raw.viewQuery)};`
)
lines.push(` app.save(${name}Collection);`)
}
const upContent = lines.join('\n')
// Down migration: revert to placeholder query
const downContent = viewCollections
.map(([name, { raw }]) => {
return ` const ${raw.name}Collection = app.findCollectionByNameOrId("${raw.name}");\n ${name}Collection.viewQuery = 'SELECT (ROW_NUMBER() OVER()) as id';\n app.save(${name}Collection);`
})
.join('\n\n')
return { upContent, downContent, hasViews: true }
}

View File

@@ -1,191 +0,0 @@
import chalk from 'chalk'
import { execSync } from 'child_process'
import fs from 'fs'
import prettier from 'prettier'
import { PB_BINARY_PATH, PB_KWARGS } from '@/constants/db'
import Logging from '@/utils/logging'
import { PRETTIER_OPTIONS } from '../../utils'
import {
generateSkeletonMigrationContent,
generateStructureMigrationContent,
generateViewQueryMigrationContent
} from './migration-content'
/**
* Creates a single migration file with custom content
*/
async function createSingleMigration(
name: string,
upContent: string,
downContent: string
): Promise<{ success: boolean; path?: string; error?: string }> {
try {
const response = execSync(
`${PB_BINARY_PATH} migrate create ${name} ${PB_KWARGS.join(' ')}`,
{
input: 'y\n',
stdio: ['pipe', 'pipe', 'pipe']
}
)
const resString = response.toString()
const match = resString.match(/Successfully created file "(.*)"/)
if (!match || match.length < 2) {
throw new Error('Failed to parse migration file path from response.')
}
const migrationFilePath = match[1]
if (!fs.existsSync(migrationFilePath)) {
throw new Error(`Migration file not found at path: ${migrationFilePath}`)
}
let content = fs.readFileSync(migrationFilePath, 'utf-8')
content = content
.replace('../pb_data/types.d.ts', '../types.d.ts')
.replace('// add up queries...', upContent)
.replace('// add down queries...', downContent)
const formattedContent = await prettier.format(content, PRETTIER_OPTIONS)
fs.writeFileSync(migrationFilePath, formattedContent, 'utf-8')
return { success: true, path: migrationFilePath }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
/**
* Creates skeleton migration for a module (stub collections)
*/
export async function createSkeletonMigration(
moduleName: string,
schema: Record<string, { raw: Record<string, unknown> }>
): Promise<{ success: boolean; error?: string }> {
try {
const skeleton = generateSkeletonMigrationContent(schema)
const result = await createSingleMigration(
`01_${moduleName}_skeleton`,
skeleton.upContent,
skeleton.downContent
)
if (!result.success) {
throw new Error(`Skeleton migration failed: ${result.error}`)
}
Logging.debug(
`Created skeleton migration for ${chalk.bold.blue(moduleName)}`
)
return { success: true }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
Logging.error(
`Failed to create skeleton migration for ${chalk.bold.blue(moduleName)}: ${errorMessage}`
)
return { success: false, error: errorMessage }
}
}
/**
* Creates structure migration for a module (full schema with relations)
*/
export async function createStructureMigration(
moduleName: string,
schema: Record<string, { raw: Record<string, unknown> }>,
idToNameMap: Map<string, string>
): Promise<{ success: boolean; error?: string }> {
try {
const structure = generateStructureMigrationContent(schema, idToNameMap)
const result = await createSingleMigration(
`02_${moduleName}_structure`,
structure.upContent,
structure.downContent
)
if (!result.success) {
throw new Error(`Structure migration failed: ${result.error}`)
}
Logging.debug(
`Created structure migration for ${chalk.bold.blue(moduleName)}`
)
return { success: true }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
Logging.error(
`Failed to create structure migration for ${chalk.bold.blue(moduleName)}: ${errorMessage}`
)
return { success: false, error: errorMessage }
}
}
/**
* Runs migrate up to apply pending migrations
*/
export function runMigrateUp(): void {
Logging.debug('Applying pending migrations...')
execSync(`${PB_BINARY_PATH} migrate up ${PB_KWARGS.join(' ')}`, {
stdio: ['pipe', 'pipe', 'pipe']
})
Logging.debug('Migrations applied successfully')
}
/**
* Creates view query migration for a module (updates viewQuery on view collections)
*/
export async function createViewQueryMigration(
moduleName: string,
schema: Record<string, { raw: Record<string, unknown> }>
): Promise<{ success: boolean; hasViews: boolean; error?: string }> {
try {
const viewQuery = generateViewQueryMigrationContent(schema)
if (!viewQuery.hasViews) {
return { success: true, hasViews: false }
}
const result = await createSingleMigration(
`03_${moduleName}_views`,
viewQuery.upContent,
viewQuery.downContent
)
if (!result.success) {
throw new Error(`View query migration failed: ${result.error}`)
}
Logging.debug(
`Created view query migration for ${chalk.bold.blue(moduleName)}`
)
return { success: true, hasViews: true }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
Logging.error(
`Failed to create view query migration for ${chalk.bold.blue(moduleName)}: ${errorMessage}`
)
return { success: false, hasViews: false, error: errorMessage }
}
}

View File

@@ -0,0 +1,64 @@
import path from 'path'
import Logging from '@/utils/logging'
import applyMigrations from './applyMigrations'
import createSingleMigration from './createSingleMigration'
/**
* Generates and applies migrations for a specific phase across all modules.
*
* The migration process is split into three phases that must run in order:
* 1. **skeleton** - Creates stub collections if they don't exist (minimal structure)
* 2. **structure** - Updates collections with full schema, fields, and relations
* 3. **views** - Sets the actual viewQuery on view collections
*
* This phased approach ensures:
* - Collections exist before relations reference them (skeleton phase)
* - Relations can be resolved by name after all collections are created (structure phase)
* - View queries can reference all tables after schemas are complete (views phase)
*
* @param phase - The migration phase: 'skeleton', 'structure', or 'views'
* @param index - Zero-based index of the phase (used for logging)
* @param importedSchemas - Array of module schemas to process
* @param idToNameMap - Map of collection IDs to names for resolving relations
*
* @throws Error if any module's migration fails to generate
*/
export default async function stageMigration(
phase: 'skeleton' | 'structure' | 'views',
index: number,
importedSchemas: Array<{
moduleName: string
schema: Record<string, { raw: Record<string, unknown> }>
}>,
idToNameMap: Map<string, string>
) {
Logging.debug(`Phase ${index + 1}: Creating ${phase} migrations...`)
const { default: phaseFn } = await import(
path.resolve(import.meta.dirname, 'generateContent', `${phase}.ts`)
)
for (const { moduleName, schema } of importedSchemas) {
try {
await createSingleMigration(
`${phase}_${moduleName}`,
phaseFn(schema, idToNameMap)
)
Logging.debug(
`Created ${phase} migration for ${Logging.highlight(moduleName)}`
)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
throw new Error(
`Failed to create ${phase} migration for ${Logging.highlight(moduleName)}: ${errorMessage}`
)
}
}
applyMigrations()
}

View File

@@ -0,0 +1,60 @@
import Logging from '@/utils/logging'
import { listSchemaPaths } from './listSchemaPaths'
import { matchCollectionToModule } from './matchCollectionToModule'
/**
* Creates a mapping of module paths to their associated PocketBase collections.
*
* This function is the core of the schema generation process. It:
* 1. Discovers all modules with schema files in the project
* 2. Iterates through PocketBase collections
* 3. Matches each collection to its owning module based on naming conventions
* 4. Groups collections by their module path
*
* Collections that don't match any module (e.g., orphaned or third-party) are
* logged in debug mode but excluded from the result.
*
* @param collections - Array of PocketBase collection objects from the database
* @returns Map where keys are module directory paths and values are arrays of collections
*/
export default async function buildModuleCollectionsMap(
collections: Array<Record<string, unknown>>
): Promise<Record<string, Record<string, unknown>[]>> {
const moduleCollectionsMap: Record<string, Record<string, unknown>[]> = {}
const allModules = listSchemaPaths()
Logging.debug(
`Found ${Logging.highlight(String(allModules.length))} modules with schema files`
)
let matchedCount = 0
let unmatchedCount = 0
for (const collection of collections) {
const matchingModuleDir = matchCollectionToModule(allModules, collection)
if (!matchingModuleDir) {
unmatchedCount++
Logging.debug(
`Collection ${Logging.highlight(collection.name as string)} has no matching module`
)
continue
}
matchedCount++
if (!moduleCollectionsMap[matchingModuleDir]) {
moduleCollectionsMap[matchingModuleDir] = []
}
moduleCollectionsMap[matchingModuleDir].push(collection)
}
Logging.debug(
`Matched ${Logging.highlight(String(matchedCount))} collections to modules, ${unmatchedCount} unmatched`
)
return moduleCollectionsMap
}

View File

@@ -1,95 +0,0 @@
import chalk from 'chalk'
import _ from 'lodash'
import path from 'path'
import { parseCollectionName } from '@/commands/modules/functions/registry/namespaceUtils'
import Logging from '@/utils/logging'
import { generateCollectionSchema, stripCollectionIds } from './field-converter'
/**
* Generates schema content for a module
* @param moduleName - The name of the module
* @param collections - Collections belonging to this module
* @param idToNameMap - Map of collection IDs to names for relation resolution
*/
export function generateModuleSchemaContent(
moduleName: string,
collections: Array<Record<string, unknown>>,
idToNameMap?: Map<string, string>
): string {
const schemaEntries: string[] = []
for (const collection of collections) {
const collectionName = collection.name as string
const fields = collection.fields as Array<Record<string, unknown>>
Logging.debug(
`Processing collection ${chalk.bold(collectionName)} with ${fields.length} fields`
)
const zodSchemaObject = generateCollectionSchema(collection)
const schemaObjectString = Object.entries(zodSchemaObject)
.map(([key, value]) => ` ${key}: ${value},`)
.join('\n')
// Use parseCollectionName to properly extract short name for both official and third-party modules
const shortName = parseCollectionName(collectionName).collectionName
const zodSchemaString = `z.object({\n${schemaObjectString}\n})`
// Remove IDs and convert relation collectionIds to names
const cleanedCollection = stripCollectionIds(collection, idToNameMap)
schemaEntries.push(` ${shortName}: {
schema: ${zodSchemaString},
raw: ${JSON.stringify(cleanedCollection, null, 2)}
},`)
Logging.info(
`Generated schema for collection ${chalk.bold(collectionName)}`
)
}
return `import z from 'zod'
const ${_.camelCase(moduleName)}Schemas = {
${schemaEntries.join('\n')}
}
export default ${_.camelCase(moduleName)}Schemas
`
}
/**
* Generates main schema file content
*/
export function generateMainSchemaContent(moduleDirs: string[]): string {
const imports = moduleDirs
.map(moduleDir => {
const [moduleDirPath, moduleDirName] = moduleDir.split('|')
const targetPath = path.join(
'@lib/',
moduleDirName,
moduleDirPath.split(moduleDirName).pop() || '',
'schema'
)
return ` ${_.snakeCase(moduleDirName)}: (await import('${targetPath}')).default,`
})
.join('\n')
return `import flattenSchemas from '@functions/utils/flattenSchema'
export const SCHEMAS = {
${imports}
}
const COLLECTION_SCHEMAS = flattenSchemas(SCHEMAS)
export default COLLECTION_SCHEMAS
`
}

View File

@@ -1,96 +0,0 @@
import Logging from '@/utils/logging'
import { FIELD_TYPE_MAPPING } from '../../utils'
import type { PocketBaseField } from '../../utils/constants'
/**
* Converts a PocketBase field to Zod schema string
*/
export function convertFieldToZodSchema(field: PocketBaseField): string | null {
if (field.name === 'id') {
return null // Skip auto-generated fields
}
const converter = FIELD_TYPE_MAPPING[field.type]
if (!converter) {
Logging.warn(
`Unknown field type '${field.type}' for field '${field.name}'. Skipping.`
)
return null
}
return converter(field)
}
/**
* Generates Zod schema for a collection
*/
export function generateCollectionSchema(
collection: Record<string, unknown>
): Record<string, string> {
const zodSchemaObject: Record<string, string> = {}
const fields = collection.fields as Array<Record<string, unknown>>
for (const field of fields.filter(e => !e.hidden)) {
const zodSchema = convertFieldToZodSchema(field as PocketBaseField)
if (zodSchema) {
zodSchemaObject[field.name as string] = zodSchema
}
}
return zodSchemaObject
}
/**
* Strips collection ID and field IDs from raw config
* This prevents migration conflicts when importing to different databases
* For relation fields, converts collectionId to collection name for portability
*/
export function stripCollectionIds(
collection: Record<string, unknown>,
idToNameMap?: Map<string, string>
): Record<string, unknown> {
const cleaned = { ...collection }
// Remove collection-level properties that cause conflicts
delete cleaned.id
delete cleaned.created
delete cleaned.updated
if ('oauth2' in cleaned) {
delete cleaned.oauth2
}
// Remove field IDs and convert relation collectionIds to names
if (cleaned.fields && Array.isArray(cleaned.fields)) {
cleaned.fields = cleaned.fields.map((field: Record<string, unknown>) => {
const cleanedField = { ...field }
delete cleanedField.id
// For relation fields, convert collectionId to collection name
if (
cleanedField.type === 'relation' &&
cleanedField.collectionId &&
idToNameMap
) {
const collectionName = idToNameMap.get(
cleanedField.collectionId as string
)
if (collectionName) {
// PocketBase can look up by name instead of ID
cleanedField.collectionId = collectionName
}
}
return cleanedField
})
}
return cleaned
}

View File

@@ -0,0 +1,41 @@
import Logging from '@/utils/logging'
import normalizePackage from '@/utils/normalizePackage'
/**
* Filters a module collections map to include only a specific module's collections.
*
* Used when running schema generation for a single module instead of all modules.
* Matches by the module's short name (e.g., 'calendar' matches 'lifeforge--calendar').
*
* @param moduleCollectionsMap - Full map of all modules to their collections
* @param targetModule - Optional module name to filter by. If omitted, returns the full map.
* @returns Filtered map containing only the target module, or the full map if no target specified
*
* @throws Exits process if targetModule is specified but not found in the map
*
* @example
* // Filter to only invoice-maker collections:
* filterCollectionsMap(fullMap, 'invoice-maker')
* // Returns: { '/path/to/melvinchia3636--invoice-maker': [...] }
*/
export default function filterCollectionsMap(
moduleCollectionsMap: Record<string, Record<string, unknown>[]>,
targetModule?: string
) {
const { shortName } = normalizePackage(targetModule || '', 'module')
const filteredModuleCollectionsMap = targetModule
? Object.fromEntries(
Object.entries(moduleCollectionsMap).filter(([key]) =>
key.endsWith(shortName)
)
)
: moduleCollectionsMap
if (targetModule && Object.keys(filteredModuleCollectionsMap).length === 0) {
Logging.error(`Module "${shortName}" not found or has no collections`)
process.exit(1)
}
return filteredModuleCollectionsMap
}

View File

@@ -0,0 +1,79 @@
import { parseCollectionName } from '@/utils/pocketbase'
import generateZodSchemaString from './generateZodSchema'
import stripCollectionIds from './stripCollectionIds'
/**
* Processes a single collection into schema.ts format with Zod schema and raw config.
*
* @param collection - PocketBase collection object
* @param idToNameMap - Map for converting relation IDs to collection names
* @returns Formatted schema entry string for the collection
*/
function processCollectionSchema(
collection: Record<string, unknown>,
idToNameMap: Map<string, string>
): string {
const collectionName = collection.name as string
const shortName = parseCollectionName(
collectionName,
'pb',
collectionName === 'users' ? 'users' : undefined
).collectionName
const zodSchemaString = generateZodSchemaString(collection)
const cleanedRawCollection = stripCollectionIds(collection, idToNameMap)
return ` ${shortName}: {
schema: ${zodSchemaString},
raw: ${JSON.stringify(cleanedRawCollection, null, 2)}
},`
}
/**
* Generates the complete TypeScript schema file content for a module.
*
* Creates a `schema.ts` file that exports:
* - Zod validation schemas for type-safe data access
* - Raw PocketBase collection configs for migration generation
*
* The generated file can be imported by both the migration generator (for raw configs)
* and application code (for Zod schemas).
*
* @param collections - Array of PocketBase collections belonging to this module
* @param idToNameMap - Map of collection IDs to names for resolving relation fields
* @returns Complete TypeScript file content as a string
*
* @example
* // Generated file structure:
* // import z from 'zod'
* //
* // const schemas = {
* // events: {
* // schema: z.object({ title: z.string(), ... }),
* // raw: { name: 'calendar__events', ... }
* // },
* // }
* //
* // export default schemas
*/
export default function generateSchemaContent(
collections: Array<Record<string, unknown>>,
idToNameMap: Map<string, string>
): string {
const schemaEntries: string[] = []
for (const collection of collections) {
schemaEntries.push(processCollectionSchema(collection, idToNameMap))
}
return `import z from 'zod'
const schemas = {
${schemaEntries.join('\n')}
}
export default schemas`
}

View File

@@ -0,0 +1,113 @@
import Logging from '@/utils/logging'
/**
* PocketBase field definition with common properties.
*/
interface PocketBaseField {
name: string
type: string
required?: boolean
maxSelect?: number
values?: string[]
[key: string]: unknown
}
/**
* Mapping of PocketBase field types to their Zod schema equivalents.
*
* Each entry is a function that takes a field definition and returns
* the appropriate Zod schema string. Some types (like select, file, relation)
* need to check field options to determine if they're single or multi-value.
*/
const FIELD_TYPE_MAPPING: Record<string, (field: PocketBaseField) => string> = {
text: () => 'z.string()',
richtext: () => 'z.string()',
number: () => 'z.number()',
bool: () => 'z.boolean()',
email: () => 'z.email()',
url: () => 'z.url()',
date: () => 'z.string()',
autodate: () => 'z.string()',
password: () => 'z.string()',
json: () => 'z.any()',
geoPoint: () => 'z.object({ lat: z.number(), lon: z.number() })',
select: field => {
const values = [...(field.values ?? []), ...(field.required ? [] : [''])]
const enumSchema = `z.enum(${JSON.stringify(values)})`
return (field.maxSelect ?? 1) > 1 ? `z.array(${enumSchema})` : enumSchema
},
file: field => {
const baseSchema = 'z.string()'
return (field.maxSelect ?? 1) > 1 ? `z.array(${baseSchema})` : baseSchema
},
relation: field => {
const baseSchema = 'z.string()'
return (field.maxSelect ?? 1) > 1 ? `z.array(${baseSchema})` : baseSchema
}
}
/**
* Converts a single PocketBase field to its Zod schema string representation.
*
* @param field - PocketBase field definition
* @returns Zod schema string, or null if field should be skipped
*/
function convertFieldToZodSchema(field: PocketBaseField): string | null {
if (field.name === 'id') {
return null // Skip auto-generated fields
}
const converter = FIELD_TYPE_MAPPING[field.type]
if (!converter) {
Logging.warn(
`Unknown field type '${field.type}' for field '${field.name}'. Skipping.`
)
return null
}
return converter(field)
}
/**
* Generates a Zod schema string for a PocketBase collection.
*
* Converts the collection's field definitions into a `z.object()` schema
* that can be used for runtime validation and TypeScript type inference.
*
* Hidden fields are automatically excluded from the generated schema.
*
* @param collection - PocketBase collection object with fields array
* @returns Zod schema string like `z.object({ name: z.string(), ... })`
*
* @example
* // Input collection with fields: name (text), age (number)
* // Output: z.object({
* // name: z.string(),
* // age: z.number(),
* // })
*/
export default function generateZodSchemaString(
collection: Record<string, unknown>
): string {
const zodSchemaObject: Record<string, string> = {}
const fields = collection.fields as Array<Record<string, unknown>>
for (const field of fields.filter(e => !e.hidden)) {
const zodSchema = convertFieldToZodSchema(field as PocketBaseField)
if (zodSchema) {
zodSchemaObject[field.name as string] = zodSchema
}
}
return `z.object({\n${Object.entries(zodSchemaObject)
.map(([key, value]) => ` ${key}: ${value},`)
.join('\n')}\n})`
}

View File

@@ -0,0 +1,41 @@
import { isDockerMode } from '@/utils/helpers'
import Logging from '@/utils/logging'
import getPBInstance from '@/utils/pocketbase'
/**
* Fetches all non-system collections from a running PocketBase instance.
*
* This function handles PocketBase lifecycle automatically:
* - In Docker mode: Connects to the existing PocketBase instance
* - In local mode: Starts a temporary PocketBase instance if needed, then stops it after fetching
*
* System collections (like `_superusers`, `_auths`, etc.) are automatically filtered out
* since they're managed by PocketBase and shouldn't be included in module schemas.
*
* @returns Array of non-system PocketBase collection objects
*
* @example
* const collections = await getCollectionsFromPB()
* // Returns: [{ name: 'calendar__events', type: 'base', ... }, ...]
*/
export default async function getCollectionsFromPB() {
Logging.debug('Connecting to PocketBase...')
const { pb, killPB } = await getPBInstance(!isDockerMode())
Logging.debug('Fetching all collections...')
const allCollections = await pb.collections.getFullList()
killPB?.()
const nonSystemCollections = allCollections.filter(
collection => !collection.system
)
Logging.debug(
`Found ${Logging.highlight(String(allCollections.length))} collections, ${Logging.highlight(String(nonSystemCollections.length))} non-system`
)
return nonSystemCollections
}

View File

@@ -0,0 +1,46 @@
import fs from 'fs'
import path from 'path'
import { PROJECT_ROOT } from '@/constants/constants'
import Logging from '@/utils/logging'
/**
* Discovers all module directories that contain schema files.
*
* Scans two locations for `schema.ts` files:
* 1. Core modules: `server/src/lib/**\/schema.ts` (built-in features like users, apiKeys)
* 2. App modules: `apps\**\/server/schema.ts` (installed/created modules)
*
* Returns the parent directory of each schema file, which represents the module root.
* For app modules, strips the trailing `/server` to get the actual module directory.
*
* @returns Array of absolute paths to module directories containing schema files
*
* @example
* // Returns paths like:
* // [
* // '/project/server/src/lib/user',
* // '/project/apps/lifeforge--calendar',
* // '/project/apps/melvinchia3636--invoice-maker'
* // ]
*/
export function listSchemaPaths(): string[] {
const modulesDirs = [
'./server/src/lib/**/schema.ts',
'./apps/**/server/schema.ts'
]
let allModules: string[] = []
try {
allModules = modulesDirs
.map(dir => fs.globSync(path.resolve(PROJECT_ROOT, dir)))
.flat()
.map(entry => path.dirname(entry).replace(/\/server$/, ''))
} catch (error) {
Logging.error(`Failed to read modules directory: ${error}`)
process.exit(1)
}
return allModules
}

View File

@@ -0,0 +1,54 @@
import path from 'path'
import { parsePackageName } from '@/commands/modules/functions/parsePackageName'
import { parseCollectionName } from '@/utils/pocketbase'
/**
* 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___invoice_maker__clients`)
*
* This function parses the collection name and matches it against registered modules
* by comparing the username and module name prefixes.
*
* Special case: The `users` collection maps to the `user` module.
*
* @param allModules - Array of module directory paths from `listSchemaPaths()`
* @param collection - PocketBase collection object with `name` property
* @returns Matching module directory path, or undefined if no match found
*
* @example
* // Collection 'calendar__events' matches module at '/apps/lifeforge--calendar'
* // Collection 'melvinchia3636___invoice_maker__clients' matches '/apps/melvinchia3636--invoice-maker'
*/
export function matchCollectionToModule(
allModules: string[],
collection: Record<string, unknown>
) {
const collectionName = collection.name as string
const parsed = parseCollectionName(
collectionName,
'pb',
collectionName === 'users' ? 'user' : undefined
)
const modulePrefix = parsed.username
? `${parsed.username}___${parsed.moduleName}`
: parsed.moduleName
const matchingModule = allModules.find(module => {
const { username, moduleName } = parsePackageName(
path.basename(module),
path.dirname(module).endsWith('/server/src/lib')
)
const expectedPrefix = username ? `${username}___${moduleName}` : moduleName
return modulePrefix === expectedPrefix
})
return matchingModule
}

View File

@@ -1,117 +0,0 @@
import fs from 'fs'
import _ from 'lodash'
import {
parseCollectionName,
parsePackageName
} from '@/commands/modules/functions/registry/namespaceUtils'
import Logging from '@/utils/logging'
/**
* Builds mapping of modules to their collections
*/
export async function buildModuleCollectionsMap(
collections: Array<Record<string, unknown>>
): Promise<Record<string, Record<string, unknown>[]>> {
const moduleCollectionsMap: Record<string, Record<string, unknown>[]> = {}
const modulesDirs = [
'./server/src/lib/**/schema.ts',
'./apps/**/server/schema.ts'
]
let allModules: string[] = []
try {
allModules = modulesDirs
.map(dir => fs.globSync(dir))
.flat()
.map(entry => entry.split('/').slice(0, -1).join('/'))
} catch (error) {
Logging.error(`Failed to read modules directory: ${error}`)
process.exit(1)
}
for (const collection of collections) {
const collectionName = collection.name as string
// Parse the collection name to get the module identifier
const parsed = parseCollectionName(collectionName)
// Build the module prefix (including username for third-party)
const modulePrefix = parsed.username
? `${parsed.username}___${parsed.moduleName}`
: parsed.moduleName
const matchingModule = allModules.find(module => {
const moduleDirName =
module
.replace(/\/server$/, '')
.split('/')
.pop() || ''
const { username, moduleName } = parsePackageName(moduleDirName)
const expectedPrefix = username
? `${username}___${moduleName}`
: moduleName
return modulePrefix === expectedPrefix
})
if (!matchingModule) {
// Fallback: use camelCase for path lookup
const moduleName = _.camelCase(parsed.moduleName)
const possibleModulePath = [
`./server/src/lib/${moduleName}/server`,
`./apps/${moduleName}/server`
]
const foundModulePath = possibleModulePath.filter(modulePath =>
fs.existsSync(modulePath)
)
if (foundModulePath.length > 0) {
Logging.debug(
`Inferred module path for collection '${collectionName}': ${foundModulePath[0]}`
)
const key = `${foundModulePath[0]}|${moduleName}`
if (!moduleCollectionsMap[key]) {
moduleCollectionsMap[key] = []
}
moduleCollectionsMap[key].push(collection)
continue
}
Logging.warn(`Collection '${collectionName}' has no corresponding module`)
continue
}
const moduleName = matchingModule
.replace(/\/server$/, '')
.split('/')
.pop()
const key = `${matchingModule}|${moduleName}`
if (!moduleCollectionsMap[key]) {
moduleCollectionsMap[key] = []
}
moduleCollectionsMap[key].push(collection)
}
const totalCollections = Object.values(moduleCollectionsMap).flat().length
const moduleCount = Object.keys(moduleCollectionsMap).length
Logging.info(
`Found ${totalCollections} collections across ${moduleCount} modules`
)
return moduleCollectionsMap
}

View File

@@ -1,80 +0,0 @@
import chalk from 'chalk'
import path from 'path'
import { parseCollectionName } from '@/commands/modules/functions/registry/namespaceUtils'
import Logging from '@/utils/logging'
import { writeFormattedFile } from '../../utils'
import { generateModuleSchemaContent } from './content-generator'
/**
* Processes schema generation for modules
* @param moduleCollectionsMap - Map of module paths to their collections
* @param idToNameMap - Map of collection IDs to names for relation resolution
* @param targetModule - Optional specific module to process
*/
export async function processSchemaGeneration(
moduleCollectionsMap: Record<string, Record<string, unknown>[]>,
idToNameMap: Map<string, string>,
targetModule?: string
): Promise<{ moduleSchemas: Record<string, string>; moduleDirs: string[] }> {
const filteredModuleCollectionsMap = targetModule
? Object.fromEntries(
Object.entries(moduleCollectionsMap).filter(([key]) =>
key.includes(targetModule)
)
)
: moduleCollectionsMap
if (targetModule && Object.keys(filteredModuleCollectionsMap).length === 0) {
Logging.error(`Module "${targetModule}" not found or has no collections`)
process.exit(1)
}
const moduleSchemas: Record<string, string> = {}
const moduleDirs: string[] = []
for (const [moduleDir, collections] of Object.entries(
filteredModuleCollectionsMap
)) {
const [moduleDirPath, moduleDirName] = moduleDir.split('|')
if (!collections.length) {
Logging.warn(
`No collections found for module ${chalk.bold(moduleDirName)}`
)
continue
}
const firstCollection = collections[0] as Record<string, unknown>
// Use parseCollectionName to extract module name with username support for third-party modules
const parsed = parseCollectionName(firstCollection.name as string)
const moduleName = parsed.username
? `${parsed.username}___${parsed.moduleName}`
: parsed.moduleName
moduleDirs.push(moduleDir)
const moduleSchemaContent = generateModuleSchemaContent(
moduleName,
collections as Array<Record<string, unknown>>,
idToNameMap
)
moduleSchemas[moduleDirName] = moduleSchemaContent
// Write individual module schema file
const moduleSchemaPath = path.join(moduleDirPath, 'schema.ts')
await writeFormattedFile(moduleSchemaPath, moduleSchemaContent)
Logging.debug(
`Created schema file for module ${chalk.bold(moduleDirName)} at ${chalk.bold(`lib/${moduleDirName}/schema.ts`)}`
)
}
return { moduleSchemas, moduleDirs }
}

View File

@@ -0,0 +1,68 @@
/**
* Strips auto-generated IDs and timestamps from a collection configuration.
*
* PocketBase assigns unique IDs to collections and fields, but these IDs are
* database-specific and not portable. By stripping them, the generated schema
* can be used to recreate collections in any PocketBase instance.
*
* For relation fields, this function also converts the `collectionId` (which is
* a database-specific ID) to the collection's `name` using the provided map.
* This allows migrations to look up collections by name instead of ID.
*
* Removed properties:
* - `id` - Collection and field IDs
* - `created` / `updated` - Timestamps
* - `oauth2` - OAuth config (if present)
*
* @param collection - Raw PocketBase collection object
* @param idToNameMap - Optional map for converting relation collectionIds to names
* @returns Cleaned collection config without database-specific IDs
*
* @example
* // Before: { id: 'abc123', name: 'events', fields: [{ id: 'def456', name: 'title', ... }] }
* // After: { name: 'events', fields: [{ name: 'title', ... }] }
*/
export default function stripCollectionIds(
collection: Record<string, unknown>,
idToNameMap?: Map<string, string>
): Record<string, unknown> {
const cleaned = { ...collection }
// Remove collection-level properties that cause conflicts
delete cleaned.id
delete cleaned.created
delete cleaned.updated
if ('oauth2' in cleaned) {
delete cleaned.oauth2
}
// Remove field IDs and convert relation collectionIds to names
if (cleaned.fields && Array.isArray(cleaned.fields)) {
cleaned.fields = cleaned.fields.map((field: Record<string, unknown>) => {
const cleanedField = { ...field }
delete cleanedField.id
// For relation fields, convert collectionId to collection name
if (
cleanedField.type === 'relation' &&
cleanedField.collectionId &&
idToNameMap
) {
const collectionName = idToNameMap.get(
cleanedField.collectionId as string
)
if (collectionName) {
// PocketBase can look up by name instead of ID
cleanedField.collectionId = collectionName
}
}
return cleanedField
})
}
return cleaned
}

View File

@@ -1,163 +1,32 @@
import chalk from 'chalk'
import { isDockerMode } from '@/utils/helpers'
import Logging from '@/utils/logging'
import { checkRunningPBInstances } from '@/utils/pocketbase'
import {
createSkeletonMigration,
createStructureMigration,
createViewQueryMigration,
runMigrateUp
} from '../functions/migration-generation/migration-file'
import { getSchemaFiles, importSchemaModules } from '../utils/file-utils'
import buildIdToNameMap from '../functions/migration-generation/buildIdToNameMap'
import stageMigration from '../functions/migration-generation/stageMigrations'
import { importSchemaModules } from '../utils'
import { cleanupOldMigrations } from '../utils/pocketbase-utils'
/**
* Builds an ID-to-name map from all schemas
*/
function buildIdToNameMap(
importedSchemas: Array<{
moduleName: string
schema: Record<string, { raw: Record<string, unknown> }>
}>
): Map<string, string> {
const idToNameMap = new Map<string, string>()
for (const { schema } of importedSchemas) {
for (const entry of Object.values(schema)) {
const raw = entry?.raw
if (raw?.id && raw?.name) {
idToNameMap.set(raw.id as string, raw.name as string)
}
}
}
return idToNameMap
}
/**
* Command handler for generating database migrations
*
* Five-phase approach:
* 1. Generate skeleton migrations for all modules (stub collections)
* 2. Run migrate up to apply skeletons (all collections now exist)
* 3. Generate structure migrations for all modules (full schema with relations)
* 4. Run migrate up to apply structures (all structures now in place)
* 5. Generate view query migrations for modules with view collections
*/
export async function generateMigrationsHandler(
targetModule?: string
): Promise<void> {
try {
Logging.step('Starting database migration generation')
// Skip running instances check in Docker mode (we control the container)
if (!isDockerMode()) {
checkRunningPBInstances()
}
await cleanupOldMigrations(targetModule)
const schemaFiles = getSchemaFiles(targetModule)
const importedSchemas = await importSchemaModules(targetModule)
Logging.debug(
targetModule
? `Processing module: ${chalk.bold.blue(targetModule)}`
: `Found ${chalk.bold.blue(schemaFiles.length)} schema files.`
)
const idToNameMap = await buildIdToNameMap(targetModule)
const importedSchemas = await importSchemaModules(schemaFiles)
// Build ID-to-name map from all schemas for relation resolution
const idToNameMap = buildIdToNameMap(importedSchemas)
Logging.debug(`Built ID-to-name map with ${idToNameMap.size} collections`)
// Phase 1: Generate all skeleton migrations
Logging.debug('Phase 1: Creating skeleton migrations...')
for (const { moduleName, schema } of importedSchemas) {
const result = await createSkeletonMigration(moduleName, schema)
if (!result.success) {
Logging.actionableError(
`Skeleton migration failed for module ${moduleName}`,
'Check the module schema definition for syntax errors'
)
process.exit(1)
}
for (const [index, phase] of Object.entries([
'skeleton',
'structure',
'views'
] as const)) {
await stageMigration(phase, Number(index), importedSchemas, idToNameMap)
}
Logging.debug(`Created ${importedSchemas.length} skeleton migrations`)
// Phase 2: Run migrate up to apply skeleton migrations
Logging.debug('Phase 2: Applying skeleton migrations...')
runMigrateUp()
// Phase 3: Generate all structure migrations
Logging.debug('Phase 3: Creating structure migrations...')
for (const { moduleName, schema } of importedSchemas) {
const result = await createStructureMigration(
moduleName,
schema,
idToNameMap
)
if (!result.success) {
Logging.actionableError(
`Structure migration failed for module ${moduleName}`,
'Check the module schema definition for syntax errors'
)
process.exit(1)
}
}
Logging.debug(`Created ${importedSchemas.length} structure migrations`)
// Phase 4: Run migrate up to apply structure migrations
Logging.debug('Phase 4: Applying structure migrations...')
runMigrateUp()
// Phase 5: Generate view query migrations (for modules with view collections)
Logging.debug('Phase 5: Creating view query migrations...')
let viewMigrationCount = 0
for (const { moduleName, schema } of importedSchemas) {
const result = await createViewQueryMigration(moduleName, schema)
if (!result.success) {
Logging.actionableError(
`View query migration failed for module ${moduleName}`,
'Check the view query definition for syntax errors'
)
process.exit(1)
}
if (result.hasViews) {
viewMigrationCount++
}
}
if (viewMigrationCount > 0) {
Logging.debug(`Created ${viewMigrationCount} view query migrations`)
// Phase 6: Apply view query migrations
Logging.debug('Phase 6: Applying view query migrations...')
runMigrateUp()
} else {
Logging.debug('No view collections found, skipping view migrations')
}
// Summary
const message = targetModule
? `Database migrations applied for ${chalk.bold.blue(targetModule)}`
: 'Database migrations applied for all modules'
Logging.success(message)
Logging.success('Migrations generated successfully')
} catch (error) {
Logging.actionableError(
'Migration script failed',

View File

@@ -1,11 +1,12 @@
import chalk from 'chalk'
import path from 'path'
import { isDockerMode } from '@/utils/helpers'
import Logging from '@/utils/logging'
import getPBInstance from '@/utils/pocketbase'
import { buildModuleCollectionsMap } from '../functions/schema-generation/module-mapper'
import { processSchemaGeneration } from '../functions/schema-generation/schema-processor'
import buildModuleCollectionsMap from '../functions/schema-generation/buildModuleCollectionsMap'
import filterCollectionsMap from '../functions/schema-generation/filterCollectionsMap'
import generateSchemaContent from '../functions/schema-generation/generateSchemaContent'
import getCollectionsFromPB from '../functions/schema-generation/getCollectionsFromPB'
import { writeFormattedFile } from '../utils'
/**
* Command handler for generating database schemas
@@ -13,52 +14,51 @@ import { processSchemaGeneration } from '../functions/schema-generation/schema-p
export async function generateSchemaHandler(
targetModule?: string
): Promise<void> {
try {
Logging.info('Starting schema generation process...')
const { pb, killPB } = await getPBInstance(!isDockerMode())
Logging.debug('Fetching collections from PocketBase...')
const allCollections = await pb.collections.getFullList()
const userCollections = allCollections.filter(
collection => !collection.system
if (targetModule) {
Logging.debug(
`Generating schema for module: ${Logging.highlight(targetModule)}`
)
Logging.info(`Found ${userCollections.length} user-defined collections`)
// Build ID-to-name map for all collections (including system)
// This allows relation fields to reference collections by name instead of ID
const idToNameMap = new Map<string, string>()
for (const collection of allCollections) {
idToNameMap.set(collection.id, collection.name)
}
const moduleCollectionsMap =
await buildModuleCollectionsMap(userCollections)
const { moduleSchemas } = await processSchemaGeneration(
moduleCollectionsMap,
idToNameMap,
targetModule
)
// Note: core/schema.ts is generated by the module registry generator (bun forge modules install)
// db pull only updates individual module schema files
const moduleCount = Object.keys(moduleSchemas).length
Logging.info(
targetModule
? `Schema generation completed for module ${chalk.bold.blue(targetModule)}!`
: `Schema generation completed! Created ${moduleCount} module schema files.`
)
killPB?.()
} catch (error) {
Logging.error(`Schema generation failed: ${error}`)
process.exit(1)
}
const collections = await getCollectionsFromPB()
const moduleCollectionsMap = await buildModuleCollectionsMap(collections)
const filteredMap = filterCollectionsMap(moduleCollectionsMap, targetModule)
const entries = Object.entries(filteredMap)
if (entries.length === 0) {
Logging.warn('No modules found to generate schemas for')
return
}
await Promise.all(
entries.map(async ([modulePath, moduleCollections]) => {
const schemaPath = path.join(modulePath, 'schema.ts')
const collectionCount = moduleCollections.length
Logging.debug(
`Writing ${Logging.highlight(String(collectionCount))} collections to ${Logging.dim(schemaPath)}`
)
await writeFormattedFile(
schemaPath,
generateSchemaContent(
moduleCollections as Array<Record<string, unknown>>,
new Map<string, string>(
moduleCollections.map(
collection => [collection.id, collection.name] as [string, string]
)
)
)
)
})
)
Logging.success(
`Generated schemas for ${Logging.highlight(String(entries.length))} modules`
)
}

View File

@@ -3,11 +3,11 @@ import Logging from '@/utils/logging'
import { checkRunningPBInstances } from '@/utils/pocketbase'
import { downloadPocketBaseBinary } from '../functions/database-initialization/download-pocketbase'
import { runDatabaseMigrations } from '../functions/database-initialization/migrations'
import {
createPocketBaseSuperuser,
validatePocketBaseNotInitialized
} from '../functions/database-initialization/superuser'
import { generateMigrationsHandler } from './generateMigrationsHandler'
export async function initializeDatabaseHandler() {
const { PB_EMAIL, PB_PASSWORD } = getEnvVars([
@@ -20,10 +20,10 @@ export async function initializeDatabaseHandler() {
checkRunningPBInstances()
validatePocketBaseNotInitialized()
createPocketBaseSuperuser(PB_EMAIL, PB_PASSWORD)
runDatabaseMigrations()
Logging.success(
'Setup process complete. You can now start the PocketBase server with `bun forge dev db`'
)
createPocketBaseSuperuser(PB_EMAIL, PB_PASSWORD)
generateMigrationsHandler()
Logging.success('Database initialized successfully')
}

View File

@@ -2,48 +2,8 @@
* Constants for database command operations
*/
export interface PocketBaseField {
name: string
type: string
required?: boolean
maxSelect?: number
values?: string[]
[key: string]: unknown
}
export const FIELD_TYPE_MAPPING: Record<
string,
(field: PocketBaseField) => string
> = {
text: () => 'z.string()',
richtext: () => 'z.string()',
number: () => 'z.number()',
bool: () => 'z.boolean()',
email: () => 'z.email()',
url: () => 'z.url()',
date: () => 'z.string()',
autodate: () => 'z.string()',
password: () => 'z.string()',
json: () => 'z.any()',
geoPoint: () => 'z.object({ lat: z.number(), lon: z.number() })',
select: field => {
const values = [...(field.values ?? []), ...(field.required ? [] : [''])]
const enumSchema = `z.enum(${JSON.stringify(values)})`
return (field.maxSelect ?? 1) > 1 ? `z.array(${enumSchema})` : enumSchema
},
file: field => {
const baseSchema = 'z.string()'
return (field.maxSelect ?? 1) > 1 ? `z.array(${baseSchema})` : baseSchema
},
relation: field => {
const baseSchema = 'z.string()'
return (field.maxSelect ?? 1) > 1 ? `z.array(${baseSchema})` : baseSchema
}
}
/**
* Prettier formatting options for generated files

View File

@@ -67,12 +67,14 @@ export function getSchemaFiles(targetModule?: string): string[] {
/**
* Imports schema modules from file paths
*/
export async function importSchemaModules(schemaFiles: string[]): Promise<
export async function importSchemaModules(targetModule?: string): Promise<
Array<{
moduleName: string
schema: Record<string, { raw: Record<string, unknown> }>
}>
> {
const schemaFiles = getSchemaFiles(targetModule)
return Promise.all(
schemaFiles.map(async schemaPath => {
const module = await import(path.resolve(schemaPath))

View File

@@ -5,8 +5,4 @@ export {
importSchemaModules
} from './file-utils'
export {
FIELD_TYPE_MAPPING,
PRETTIER_OPTIONS,
SCHEMA_PATTERNS
} from './constants'
export { PRETTIER_OPTIONS, SCHEMA_PATTERNS } from './constants'

View File

@@ -1,4 +1,3 @@
import chalk from 'chalk'
import { execSync } from 'child_process'
import fs from 'fs'
import path from 'path'
@@ -39,11 +38,12 @@ export async function cleanupOldMigrations(
}
)
const removedCount = migrationFiles.filter(file =>
file.endsWith(`_${targetModule}.js`)
).length
Logging.debug(
`Removed ${chalk.bold.blue(
migrationFiles.filter(file => file.endsWith(`_${targetModule}.js`))
.length
)} old migrations for module ${chalk.bold.blue(targetModule)}.`
`Removed ${Logging.highlight(String(removedCount))} old migrations for module ${Logging.highlight(targetModule)}.`
)
}
} catch {

View File

@@ -1,13 +1,8 @@
import chalk from 'chalk'
import fs from 'fs'
import { PB_BINARY_PATH, PB_KWARGS } from '@/constants/db'
import {
checkPortInUse,
delay,
executeCommand,
killExistingProcess
} from '@/utils/helpers'
import executeCommand from '@/utils/commands'
import { checkPortInUse, delay, killExistingProcess } from '@/utils/helpers'
import Logging from '@/utils/logging'
/**
@@ -39,7 +34,7 @@ export const SERVICE_COMMANDS: Record<string, ServiceConfig> = {
if (!fs.existsSync(PB_BINARY_PATH)) {
Logging.actionableError(
`PocketBase binary does not exist: ${PB_BINARY_PATH}`,
`Please run "${chalk.bold.blue('bun forge db init')}" to initialize the database`
`Please run "${Logging.highlight('bun forge db init')}" to initialize the database`
)
process.exit(1)
}

View File

@@ -2,7 +2,8 @@ import concurrently from 'concurrently'
import { PROJECTS } from '@/commands/project/constants/projects'
import { TOOLS_ALLOWED } from '@/constants/constants'
import { executeCommand, getEnvVars } from '@/utils/helpers'
import executeCommand from '@/utils/commands'
import { getEnvVars } from '@/utils/helpers'
import Logging from '@/utils/logging'
import { SERVICE_COMMANDS } from '../config/commands'
@@ -51,8 +52,6 @@ export async function startSingleService(
* Starts all development services concurrently
*/
export async function startAllServices(): Promise<void> {
Logging.progress('Starting all services: database, server, and client')
try {
const concurrentServices = await getConcurrentServices()

View File

@@ -1,5 +1,3 @@
import Logging from '@/utils/logging'
import SERVICES from '../constants/services'
/**
@@ -7,7 +5,6 @@ import SERVICES from '../constants/services'
*/
export default function validateService(service: string): void {
if (service && !SERVICES.includes(service)) {
Logging.options(`Invalid service: "${service}"`, [...SERVICES])
process.exit(1)
}
}

View File

@@ -10,12 +10,13 @@ export function devHandler(service: string, extraArgs: string[] = []): void {
validateService(service)
if (!service) {
Logging.info('Starting all services...')
startAllServices()
return
}
Logging.progress(`Starting ${service} service`)
Logging.info(`Starting ${Logging.highlight(service)} service...`)
if (extraArgs.length > 0) {
Logging.debug(`Extra arguments: ${extraArgs.join(' ')}`)
@@ -25,7 +26,7 @@ export function devHandler(service: string, extraArgs: string[] = []): void {
startSingleService(service, extraArgs)
} catch (error) {
Logging.actionableError(
`Failed to start ${service} service`,
`Failed to start ${Logging.highlight(service)} service`,
'Check if all required dependencies are installed and environment variables are set'
)
Logging.debug(`Error details: ${error}`)

View File

@@ -6,21 +6,17 @@ async function ensureLocaleNotInUse(shortName: string) {
const { pb, killPB } = await getPBInstance()
try {
const user = await pb.collection('users').getFirstListItem("id != ''")
const user = await pb.collection('users').getFirstListItem("id != ''")
if (user.language === shortName) {
Logging.actionableError(
`Cannot uninstall locale "${shortName}"`,
'This language is currently selected. Change your language first.'
)
killPB?.()
killPB?.()
if (user.language === shortName) {
Logging.actionableError(
`Cannot uninstall locale "${shortName}"`,
'This language is currently selected. Change your language first.'
)
process.exit(1)
}
} finally {
killPB?.()
process.exit(1)
}
}

View File

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

View File

@@ -1,7 +1,7 @@
import Logging from '@/utils/logging'
import normalizePackage from '@/utils/normalizePackage'
import { getInstalledLocalesWithMeta } from './getInstalledLocales'
import { normalizeLocalePackageName } from './getLocalesMeta'
function getPackagesToCheck(langCode?: string) {
const localePackages = getInstalledLocalesWithMeta()
@@ -14,7 +14,7 @@ function getPackagesToCheck(langCode?: string) {
const packagesToCheck = langCode
? localePackages.filter(
p => p.name === normalizeLocalePackageName(langCode)
p => p.name === normalizePackage(langCode, 'locale').fullName
)
: localePackages

View File

@@ -47,13 +47,16 @@ async function getUpgrades(
}
if (!upgrades.length) {
Logging.success('All locales are up to date!')
Logging.info('All locales are up to date')
process.exit(0)
}
Logging.info('Available upgrades:')
upgrades.forEach(u => Logging.info(` ${u.name}: ${u.current}${u.latest}`))
upgrades.forEach(u =>
Logging.print(
` ${Logging.highlight(u.name)}: ${u.current}${Logging.green(u.latest)}`
)
)
return upgrades
}

View File

@@ -1,6 +1,7 @@
import fs from 'fs'
import { executeCommand } from '@/utils/helpers'
import executeCommand from '@/utils/commands'
import Logging, { LEVEL_ORDER } from '@/utils/logging'
import { addDependency } from '@/utils/packageJson'
function installAndMoveLocales(fullPackageName: string, targetDir: string) {
@@ -8,9 +9,13 @@ function installAndMoveLocales(fullPackageName: string, targetDir: string) {
fs.rmSync(targetDir, { recursive: true, force: true })
}
Logging.debug(
`Installing ${Logging.highlight(fullPackageName)} from registry...`
)
executeCommand(`bun add ${fullPackageName}@latest`, {
cwd: process.cwd(),
stdio: 'inherit'
stdio: Logging.level > LEVEL_ORDER['info'] ? 'pipe' : 'inherit'
})
const installedPath = `${process.cwd()}/node_modules/${fullPackageName}`
@@ -19,12 +24,16 @@ function installAndMoveLocales(fullPackageName: string, targetDir: string) {
throw new Error(`Failed to install ${fullPackageName}`)
}
Logging.debug(
`Copying ${Logging.highlight(fullPackageName)} to ${targetDir}...`
)
fs.cpSync(installedPath, targetDir, { recursive: true })
addDependency(fullPackageName)
fs.rmSync(installedPath, { recursive: true, force: true })
executeCommand('bun install', { cwd: process.cwd(), stdio: 'inherit' })
}
export default installAndMoveLocales

View File

@@ -7,7 +7,7 @@ async function setFirstLangInDB(shortName: string) {
const installedLocales = getInstalledLocales()
if (installedLocales.length === 1) {
Logging.step('First language pack - setting as default for user')
Logging.debug('This is the first locale, setting as default...')
const { pb, killPB } = await getPBInstance()
@@ -15,7 +15,7 @@ async function setFirstLangInDB(shortName: string) {
await pb.collection('users').update(user.id, { language: shortName })
Logging.info(`Set ${shortName} as default language`)
Logging.info(`Set ${Logging.highlight(shortName)} as default language`)
killPB?.()
}
}

View File

@@ -1,13 +1,16 @@
import fs from 'fs'
import Logging from '@/utils/logging'
import normalizePackage from '@/utils/normalizePackage'
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)
const { fullName, shortName, targetDir } = normalizePackage(
langCode,
'locale'
)
if (fs.existsSync(targetDir)) {
Logging.actionableError(
@@ -18,17 +21,17 @@ export async function installLocaleHandler(langCode: string): Promise<void> {
process.exit(1)
}
Logging.progress('Fetching locale from registry...')
Logging.info(`Installing ${Logging.highlight(fullName)}...`)
try {
installAndMoveLocales(fullPackageName, targetDir)
installAndMoveLocales(fullName, targetDir)
await setFirstLangInDB(shortName)
Logging.success(`Locale ${fullPackageName} installed successfully!`)
Logging.success(`Installed ${Logging.highlight(fullName)}`)
} catch (error) {
Logging.actionableError(
`Failed to install ${fullPackageName}`,
`Failed to install ${Logging.highlight(fullName)}`,
'Make sure the locale exists in the registry'
)
throw error

View File

@@ -1,5 +1,3 @@
import chalk from 'chalk'
import Logging from '@/utils/logging'
import { getInstalledLocalesWithMeta } from '../functions/getInstalledLocales'
@@ -19,8 +17,8 @@ export function listLocalesHandler(): void {
Logging.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})`
Logging.print(
` ${Logging.highlight(locale.name)} - ${locale.displayName} (v.${locale.version})`
)
}
}

View File

@@ -1,18 +1,18 @@
import fs from 'fs'
import { executeCommand } from '@/utils/helpers'
import executeCommand from '@/utils/commands'
import Logging from '@/utils/logging'
import normalizePackage from '@/utils/normalizePackage'
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)
const { fullName, targetDir } = normalizePackage(langCode, 'locale')
if (!fs.existsSync(targetDir)) {
Logging.actionableError(
@@ -23,24 +23,28 @@ export async function publishLocaleHandler(
return
}
Logging.info('Validating locale structure...')
validateLocaleStructure(targetDir)
const auth = await checkAuth()
if (options?.official) {
Logging.info('Validating maintainer access...')
validateMaintainerAccess(auth.username ?? '')
}
Logging.info(`Publishing ${Logging.highlight(fullName)}...`)
try {
executeCommand(`npm publish --registry ${getRegistryUrl()}`, {
cwd: targetDir,
stdio: 'inherit'
})
Logging.success(`Locale "${shortName}" published successfully!`)
Logging.success(`Published ${Logging.highlight(fullName)}`)
} catch (error) {
Logging.actionableError(
'Failed to publish locale',
`Failed to publish ${Logging.highlight(fullName)}`,
'Check if you are properly authenticated with the registry'
)
throw error

View File

@@ -1,16 +1,19 @@
import fs from 'fs'
import { executeCommand } from '@/utils/helpers'
import { installDependencies } from '@/utils/commands'
import Logging from '@/utils/logging'
import normalizePackage from '@/utils/normalizePackage'
import { findPackageName, removeDependency } from '@/utils/packageJson'
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 { fullName, shortName, targetDir } = normalizePackage(
langCode,
'locale'
)
const found = findPackageName(fullPackageName)
const found = findPackageName(fullName)
if (!found) {
Logging.actionableError(
@@ -23,13 +26,13 @@ export async function uninstallLocaleHandler(langCode: string): Promise<void> {
await ensureLocaleNotInUse(shortName)
Logging.info(`Uninstalling locale ${fullPackageName}...`)
Logging.info(`Uninstalling ${Logging.highlight(fullName)}...`)
fs.rmSync(targetDir, { recursive: true, force: true })
removeDependency(fullPackageName)
removeDependency(fullName)
executeCommand('bun install', { cwd: process.cwd(), stdio: 'inherit' })
installDependencies()
Logging.info(`Uninstalled locale ${fullPackageName}`)
Logging.success(`Uninstalled ${Logging.highlight(fullName)}`)
}

View File

@@ -1,8 +1,9 @@
import { confirmAction, executeCommand } from '@/utils/helpers'
import { installDependencies } from '@/utils/commands'
import { confirmAction } from '@/utils/helpers'
import Logging from '@/utils/logging'
import normalizePackage from '@/utils/normalizePackage'
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'
@@ -19,21 +20,27 @@ export async function upgradeLocaleHandler(langCode?: string): Promise<void> {
let upgradedCount = 0
for (const upgrade of upgrades) {
Logging.info(`Upgrading ${Logging.highlight(upgrade.name)}...`)
try {
installAndMoveLocales(
upgrade.name,
getLocalesMeta(upgrade.name).targetDir
normalizePackage(upgrade.name, 'locale').targetDir
)
Logging.success(`Upgraded ${upgrade.name} to ${upgrade.latest}`)
Logging.success(`Upgraded ${Logging.highlight(upgrade.name)}`)
upgradedCount++
} catch (error) {
Logging.error(`Failed to upgrade ${upgrade.name}: ${error}`)
Logging.error(
`Failed to upgrade ${Logging.highlight(upgrade.name)}: ${error}`
)
}
}
if (upgradedCount > 0) {
executeCommand('bun install', { cwd: process.cwd(), stdio: 'inherit' })
Logging.success(`Upgraded ${upgradedCount} locale(s)`)
installDependencies()
Logging.success(
`Upgraded ${upgradedCount} locale${upgradedCount > 1 ? 's' : ''}`
)
}
}

View File

@@ -1,10 +1,10 @@
import type { Command } from 'commander'
import { installLocaleHandler } from './handlers/install-locale'
import { listLocalesHandler } from './handlers/list-locales'
import { publishLocaleHandler } from './handlers/publish-locale'
import { uninstallLocaleHandler } from './handlers/uninstall-locale'
import { upgradeLocaleHandler } from './handlers/upgrade-locale'
import { installLocaleHandler } from './handlers/installLocaleHandler'
import { listLocalesHandler } from './handlers/listLocalesHandler'
import { publishLocaleHandler } from './handlers/publishLocaleHandler'
import { uninstallLocaleHandler } from './handlers/uninstallLocaleHandler'
import { upgradeLocaleHandler } from './handlers/upgradeLocalesHandler'
export default function setup(program: Command): void {
const command = program

View File

@@ -1,24 +0,0 @@
import fs from 'fs'
import path from 'path'
export default function getFsMetadata(moduleName: string) {
const fullName = moduleName.startsWith('@lifeforge/')
? moduleName
: `@lifeforge/${moduleName}`
const shortName = fullName.replace('@lifeforge/', '')
const appsDir = path.join(process.cwd(), 'apps')
if (!fs.existsSync(appsDir)) {
fs.mkdirSync(appsDir, { recursive: true })
}
const targetDir = path.join(appsDir, shortName)
return {
fullName,
shortName,
targetDir
}
}

View File

@@ -1,8 +1,8 @@
import fs from 'fs'
import path from 'path'
import { executeCommand } from '@/utils/helpers'
import Logging from '@/utils/logging'
import executeCommand from '@/utils/commands'
import Logging, { LEVEL_ORDER } from '@/utils/logging'
/**
* Installs a module package from the registry and copies it to the target directory.
@@ -14,18 +14,24 @@ export default function installModulePackage(
fullName: string,
targetDir: string
) {
Logging.debug(`Installing ${Logging.highlight(fullName)} from registry...`)
executeCommand(`bun add ${fullName}@latest`, {
cwd: process.cwd(),
stdio: 'inherit'
stdio: Logging.level > LEVEL_ORDER['info'] ? 'pipe' : 'inherit'
})
const installedPath = path.join(process.cwd(), 'node_modules', fullName)
if (!fs.existsSync(installedPath)) {
Logging.error(`Failed to install ${fullName}`)
Logging.actionableError(
`Failed to install ${Logging.highlight(fullName)}`,
'Check if the package exists in the registry'
)
process.exit(1)
}
Logging.debug(`Copying ${Logging.highlight(fullName)} to ${targetDir}...`)
fs.cpSync(installedPath, targetDir, { recursive: true })
}

View File

@@ -1,7 +1,8 @@
import fs from 'fs'
import path from 'path'
import { executeCommand } from '@/utils/helpers'
import { installDependencies } from '@/utils/commands'
import Logging from '@/utils/logging'
import { addDependency } from '@/utils/packageJson'
/**
@@ -12,6 +13,8 @@ import { addDependency } from '@/utils/packageJson'
* @param fullName The full name of the module
*/
export default function linkModuleToWorkspace(fullName: string) {
Logging.debug(`Linking ${Logging.highlight(fullName)} to workspace...`)
addDependency(fullName)
const nodeModulesPath = path.join(process.cwd(), 'node_modules', fullName)
@@ -20,8 +23,7 @@ export default function linkModuleToWorkspace(fullName: string) {
fs.rmSync(nodeModulesPath, { recursive: true, force: true })
}
executeCommand('bun install', {
cwd: process.cwd(),
stdio: 'inherit'
})
installDependencies()
Logging.debug(`Linked ${Logging.highlight(fullName)} to workspace`)
}

View File

@@ -4,7 +4,7 @@ import path from 'path'
import Logging from '@/utils/logging'
import { readRootPackageJson } from '@/utils/packageJson'
import getFsMetadata from './getFsMetadata'
import normalizePackage from '../../../utils/normalizePackage'
interface ModuleBasicInfo {
name: string
@@ -33,7 +33,7 @@ export default function listModules(
const modules: Record<string, ModuleBasicInfo> = {}
for (const module of allModules) {
const { targetDir } = getFsMetadata(module)
const { targetDir } = normalizePackage(module)
const packageJson = JSON.parse(
fs.readFileSync(path.join(targetDir, 'package.json'), 'utf-8')

View File

@@ -7,15 +7,27 @@ import Logging from '@/utils/logging'
* - "melvinchia3636--invoice-maker" { username: "melvinchia3636", moduleName: "invoice_maker" }
* - "lifeforge--achievements" { moduleName: "achievements" } (official, no username)
*/
export function parsePackageName(packageName: string): {
export function parsePackageName(
packageName: string,
isLibModule?: boolean
): {
username?: string
moduleName: string
} {
const withoutScope = packageName.replace(/^@lifeforge\//, '')
if (!withoutScope.includes('--')) {
Logging.error(`Invalid package name: ${packageName}`)
process.exit(1)
if (!isLibModule) {
Logging.actionableError(
`Invalid package name: ${Logging.highlight(packageName)}`,
'Package name must include "--" separator (e.g., username--module-name)'
)
process.exit(1)
}
return {
moduleName: withoutScope.replace(/-/g, '_')
}
}
const [username, moduleName] = withoutScope.split('--', 2)

View File

@@ -1,4 +1,3 @@
import chalk from 'chalk'
import fs from 'fs'
import prompts from 'prompts'
@@ -16,7 +15,7 @@ export async function promptModuleType(): Promise<
message: 'Select the type of module to create:',
choices: Object.entries(AVAILABLE_TEMPLATE_MODULE_TYPES).map(
([value, title]) => ({
title: `${title} ${chalk.dim(`(${value})`)}`,
title: `${title} ${Logging.dim(`(${value})`)}`,
value
})
)

View File

@@ -1,15 +1,19 @@
import fs from 'fs'
import path from 'path'
import getFsMetadata from '../getFsMetadata'
import { SERVER_SCHEMA_PATH } from '@/constants/constants'
import normalizePackage from '../../../../utils/normalizePackage'
import listModules from '../listModules'
import { parsePackageName } from './namespaceUtils'
import { parsePackageName } from '../parsePackageName'
export default function generateSchemaRegistry(): string {
const modules = Object.keys(listModules())
const modulesWithSchema = modules.filter(mod =>
fs.existsSync(path.join(getFsMetadata(mod).targetDir, 'server/schema.ts'))
fs.existsSync(
path.join(normalizePackage(mod).targetDir, 'server/schema.ts')
)
)
const moduleSchemas = modulesWithSchema
@@ -22,17 +26,15 @@ export default function generateSchemaRegistry(): string {
})
.join('\n')
return `// AUTO-GENERATED - DO NOT EDIT
const registry = `// 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,
const SCHEMAS = {
${moduleSchemas}
}
const COLLECTION_SCHEMAS = flattenSchemas(SCHEMAS)
export default COLLECTION_SCHEMAS
export default SCHEMAS
`
fs.writeFileSync(SERVER_SCHEMA_PATH, registry)
}

View File

@@ -1,15 +1,17 @@
import fs from 'fs'
import path from 'path'
import getFsMetadata from '../getFsMetadata'
import { SERVER_ROUTES_PATH } from '@/constants/constants'
import normalizePackage from '../../../../utils/normalizePackage'
import listModules from '../listModules'
import { parsePackageName } from './namespaceUtils'
import { parsePackageName } from '../parsePackageName'
export default function generateServerRegistry(): string {
const modules = Object.keys(listModules(true))
const modulesWithServer = modules.filter(mod =>
fs.existsSync(path.join(getFsMetadata(mod).targetDir, 'server/index.ts'))
fs.existsSync(path.join(normalizePackage(mod).targetDir, 'server/index.ts'))
)
if (modulesWithServer.length === 0) {
@@ -32,7 +34,7 @@ export default appRoutes
})
.join('\n')
return `// AUTO-GENERATED - DO NOT EDIT
const registry = `// AUTO-GENERATED - DO NOT EDIT
import { forgeRouter } from '@functions/routes'
const appRoutes = forgeRouter({
@@ -41,4 +43,6 @@ ${imports}
export default appRoutes
`
fs.writeFileSync(SERVER_ROUTES_PATH, registry)
}

View File

@@ -2,8 +2,6 @@ import fs from 'fs'
import Handlebars from 'handlebars'
import _ from 'lodash'
import Logging from '@/utils/logging'
import { AVAILABLE_TEMPLATE_MODULE_TYPES } from '../../../../constants/constants'
export type ModuleMetadata = {
@@ -74,8 +72,6 @@ export function renameTsConfigFile(moduleDir: string): void {
}
export function copyTemplateFiles(moduleMetadata: ModuleMetadata): void {
Logging.step(`Creating module "${moduleMetadata.moduleName.en}"...`)
const templateDir = `${process.cwd()}/tools/src/templates/${moduleMetadata.moduleType}`
const destinationDir = `${process.cwd()}/apps/${_.camelCase(moduleMetadata.moduleName.en)}`
@@ -89,8 +85,4 @@ export function copyTemplateFiles(moduleMetadata: ModuleMetadata): void {
)
renameTsConfigFile(destinationDir)
Logging.success(
`Module "${moduleMetadata.moduleName.en}" created successfully at ${destinationDir}`
)
}

View File

@@ -1,9 +1,6 @@
import { executeCommand } from '@/utils/helpers'
import Logging from '@/utils/logging'
import executeCommand from '@/utils/commands'
export function initializeGitRepository(modulePath: string): void {
Logging.step('Initializing git repository for the new module...')
executeCommand('git init', { cwd: modulePath, stdio: 'ignore' })
executeCommand('git add .', { cwd: modulePath, stdio: 'ignore' })
executeCommand('git commit -m "feat: initial commit"', {

View File

@@ -5,14 +5,14 @@ import { generateMigrationsHandler } from '@/commands/db/handlers/generateMigrat
import Logging from '@/utils/logging'
import { checkPackageExists } from '@/utils/registry'
import getFsMetadata from '../functions/getFsMetadata'
import normalizePackage from '../../../utils/normalizePackage'
import installModulePackage from '../functions/installModulePackage'
import linkModuleToWorkspace from '../functions/linkModuleToWorkspace'
import generateSchemaRegistry from '../functions/registry/generateSchemaRegistry'
import generateServerRegistry from '../functions/registry/generateServerRegistry'
export async function installModuleHandler(moduleName: string): Promise<void> {
const { fullName, shortName, targetDir } = getFsMetadata(moduleName)
const { fullName, shortName, targetDir } = normalizePackage(moduleName)
if (fs.existsSync(targetDir)) {
Logging.actionableError(
@@ -25,22 +25,29 @@ export async function installModuleHandler(moduleName: string): Promise<void> {
if (!(await checkPackageExists(fullName))) {
Logging.actionableError(
`Module ${fullName} does not exist`,
`Module ${Logging.highlight(fullName)} does not exist`,
`Check the module name and try again`
)
return
}
Logging.info(`Installing ${Logging.highlight(fullName)}...`)
installModulePackage(fullName, targetDir)
linkModuleToWorkspace(fullName)
Logging.info('Regenerating registries...')
generateServerRegistry()
generateSchemaRegistry()
if (fs.existsSync(path.join(targetDir, 'server', 'schema.ts'))) {
Logging.info('Generating database migrations...')
generateMigrationsHandler(moduleName)
}
Logging.success(`Installed ${Logging.highlight(fullName)}`)
}

View File

@@ -1,5 +1,3 @@
import chalk from 'chalk'
import Logging from '@/utils/logging'
import listModules from '../functions/listModules'
@@ -17,8 +15,8 @@ export async function listModulesHandler(): Promise<void> {
)
Object.entries(modules).forEach(([name, info]) => {
console.log(
` ${chalk.green(name)} - ${info.displayName} (${info.version})`
Logging.print(
` ${Logging.highlight(name)} - ${info.displayName} (${info.version})`
)
})
}

View File

@@ -1,7 +1,7 @@
import fs from 'fs'
import path from 'path'
import { executeCommand } from '@/utils/helpers'
import executeCommand from '@/utils/commands'
import Logging from '@/utils/logging'
import { getRegistryUrl } from '../../../utils/registry'
@@ -19,16 +19,27 @@ export async function publishModuleHandler(moduleName: string): Promise<void> {
process.exit(1)
}
Logging.info(`Validating module structure...`)
await validateModuleStructure(modulePath)
Logging.info(`Validating module author...`)
await validateModuleAuthor(modulePath)
Logging.info(`Publishing ${Logging.highlight(moduleName)}...`)
try {
executeCommand(`npm publish --registry ${getRegistryUrl()}`, {
cwd: modulePath,
stdio: 'inherit'
})
Logging.success(`Published ${Logging.highlight(moduleName)}`)
} catch (error) {
Logging.error(`Publish failed: ${error}`)
Logging.actionableError(
`Publish failed for ${Logging.highlight(moduleName)}`,
'Check npm authentication and try again'
)
Logging.debug(`Error: ${error}`)
process.exit(1)
}
}

View File

@@ -1,34 +1,40 @@
import fs from 'fs'
import { executeCommand } from '@/utils/helpers'
import { installDependencies } from '@/utils/commands'
import Logging from '@/utils/logging'
import { findPackageName, removeDependency } from '@/utils/packageJson'
import getFsMetadata from '../functions/getFsMetadata'
import normalizePackage from '../../../utils/normalizePackage'
import generateSchemaRegistry from '../functions/registry/generateSchemaRegistry'
import generateServerRegistry from '../functions/registry/generateServerRegistry'
export async function uninstallModuleHandler(
moduleName: string
): Promise<void> {
const { targetDir, fullName } = getFsMetadata(moduleName)
const { targetDir, fullName } = normalizePackage(moduleName)
if (!findPackageName(fullName)) {
Logging.error(`Module ${fullName} not found`)
Logging.actionableError(
`Module ${Logging.highlight(fullName)} not found`,
'Run "bun forge modules list" to see installed modules'
)
return
}
Logging.info(`Uninstalling ${Logging.highlight(fullName)}...`)
removeDependency(fullName)
fs.rmSync(targetDir, { recursive: true, force: true })
executeCommand('bun install', {
cwd: process.cwd(),
stdio: 'inherit'
})
installDependencies()
Logging.info('Regenerating registries...')
generateServerRegistry()
generateSchemaRegistry()
Logging.success(`Uninstalled ${Logging.highlight(fullName)}`)
}

View File

@@ -6,7 +6,7 @@ import { generateMigrationsHandler } from '@/commands/db/handlers/generateMigrat
import Logging from '@/utils/logging'
import { getPackageLatestVersion } from '@/utils/registry'
import getFsMetadata from '../functions/getFsMetadata'
import normalizePackage from '../../../utils/normalizePackage'
import installModulePackage from '../functions/installModulePackage'
import linkModuleToWorkspace from '../functions/linkModuleToWorkspace'
import listModules from '../functions/listModules'
@@ -17,22 +17,28 @@ async function upgradeModule(
packageName: string,
currentVersion: string
): Promise<boolean> {
const { fullName, targetDir } = getFsMetadata(packageName)
const { fullName, targetDir } = normalizePackage(packageName)
const latestVersion = await getPackageLatestVersion(fullName)
if (!latestVersion) {
Logging.warn(`Could not check registry for ${fullName}`)
Logging.warn(`Could not check registry for ${Logging.highlight(fullName)}`)
return false
}
if (semver.eq(currentVersion, latestVersion)) {
Logging.info(`${packageName}@${currentVersion} is up to date`)
Logging.info(
`${Logging.highlight(packageName)}@${currentVersion} is up to date`
)
return false
}
Logging.info(
`Upgrading ${Logging.highlight(packageName)} from ${currentVersion} to ${latestVersion}...`
)
const backupPath = path.join(path.dirname(targetDir), `${packageName}.backup`)
try {
@@ -50,9 +56,13 @@ async function upgradeModule(
fs.rmSync(backupPath, { recursive: true, force: true })
Logging.success(
`Upgraded ${Logging.highlight(packageName)} to ${latestVersion}`
)
return true
} catch (error) {
Logging.error(`Failed to upgrade ${fullName}: ${error}`)
Logging.error(`Failed to upgrade ${Logging.highlight(fullName)}: ${error}`)
if (fs.existsSync(backupPath)) {
fs.renameSync(backupPath, targetDir)
@@ -68,7 +78,7 @@ export async function upgradeModuleHandler(moduleName?: string): Promise<void> {
let upgradedCount = 0
if (moduleName) {
const { fullName } = getFsMetadata(moduleName)
const { fullName } = normalizePackage(moduleName)
const mod = modules[fullName]
@@ -97,10 +107,16 @@ export async function upgradeModuleHandler(moduleName?: string): Promise<void> {
}
if (upgradedCount > 0) {
Logging.info('Regenerating registries...')
generateServerRegistry()
generateSchemaRegistry()
generateMigrationsHandler()
Logging.success(
`Upgraded ${upgradedCount} module${upgradedCount > 1 ? 's' : ''}`
)
}
}

View File

@@ -1,8 +1,4 @@
import {
executeCommand,
logProcessComplete,
logProcessStart
} from '@/utils/helpers'
import executeCommand from '@/utils/commands'
import type { CommandType } from '../constants/commands'
import { PROJECTS, type ProjectType } from '../constants/projects'
@@ -18,15 +14,12 @@ export function executeProjectCommand(
const finalProjects = projects?.length ? projects : allProjectKeys
logProcessStart(commandType, finalProjects)
for (const projectType of finalProjects) {
const projectPath = PROJECTS[projectType as ProjectType]
const command = `cd ${projectPath} && bun run ${commandType}`
executeCommand(command)
executeCommand(`bun run ${commandType}`, {
cwd: projectPath,
stdio: 'pipe'
})
}
logProcessComplete(commandType)
}

View File

@@ -26,9 +26,9 @@ export function validateProjectArguments(projects: string[] | undefined): void {
const validation = validateProjects(projects, validProjects)
if (!validation.isValid) {
Logging.options(
Logging.actionableError(
`Invalid project(s): ${validation.invalidProjects.join(', ')}`,
validProjects
'Available projects: ' + validProjects.join(', ')
)
process.exit(1)
}

View File

@@ -1,10 +1,9 @@
import fs from 'fs'
import path from 'path'
/**
* Directory containing all tools
*/
export const TOOLS_DIR = path.join(__dirname, '../../../tools')
export const PROJECT_ROOT = import.meta.dirname.split('/tools')[0]
const TOOLS_DIR = path.join(PROJECT_ROOT, 'tools')
/**
* Dynamically discovered tools from the tools directory

View File

@@ -31,5 +31,5 @@ try {
await setupCLI()
runCLI()
} catch (error) {
Logging.fatal(`Unexpected error occurred: ${error}`)
Logging.error(`Unexpected error occurred: ${error}`)
}

View File

@@ -1,4 +1,3 @@
import type { IOType } from 'child_process'
export interface ProjectConfig {
path: string
@@ -9,10 +8,3 @@ export interface ServiceConfig extends ProjectConfig {
command?: string
cwd?: string
}
export interface CommandExecutionOptions {
stdio?: IOType | [IOType, IOType, IOType]
cwd?: string
env?: Record<string, string>
exitOnError?: boolean
}

View File

@@ -0,0 +1,76 @@
import { type IOType, spawnSync } from 'child_process'
import Logging, { LEVEL_ORDER } from './logging'
interface CommandExecutionOptions {
stdio?: IOType | [IOType, IOType, IOType]
cwd?: string
env?: Record<string, string>
exitOnError?: boolean
}
/**
* Executes a shell command with proper error handling
*/
export default function executeCommand(
command: string | (() => string),
options: CommandExecutionOptions = {},
_arguments: string[] = []
): string {
let cmd: string
try {
cmd = typeof command === 'function' ? command() : command
} catch (error) {
Logging.actionableError(
`Failed to generate command: ${error}`,
'Check the command generation logic for errors'
)
process.exit(1)
}
try {
Logging.debug(`Executing: ${cmd}`)
const [toBeExecuted, ...args] = cmd.split(' ')
const result = spawnSync(toBeExecuted, [...args, ..._arguments], {
stdio: 'inherit',
encoding: 'utf8',
shell: true,
...options
})
if (result.error) {
throw result.error
}
if (result.status !== 0) {
throw result.status
}
if (!options.stdio || options.stdio === 'inherit') {
Logging.debug(`Completed: ${cmd}`)
}
return result.stdout?.toString().trim() || ''
} catch (error) {
if (!options.exitOnError) {
throw error
}
Logging.actionableError(
`Command execution failed: ${cmd}`,
'Check if the command exists and you have the necessary permissions'
)
Logging.debug(`Error details: ${error}`)
process.exit(1)
}
}
export function installDependencies() {
executeCommand('bun install', {
cwd: process.cwd(),
stdio: Logging.level > LEVEL_ORDER['debug'] ? 'pipe' : 'inherit'
})
}

View File

@@ -1,10 +1,9 @@
import { executeCommand } from '@/utils/helpers'
import Logging from '@/utils/logging'
import executeCommand from './commands'
export function validateMaintainerAccess(username: string): void {
try {
Logging.progress(`Checking maintainer privileges for ${username}...`)
// Check permission level on the official repo
const result = executeCommand(
`gh api repos/lifeforge-app/lifeforge/collaborators/${username}/permission`,
@@ -20,8 +19,6 @@ export function validateMaintainerAccess(username: string): void {
const allowedPermissions = ['admin', 'maintain', 'write']
if (allowedPermissions.includes(response.permission)) {
Logging.success(`Verified maintainer access for ${username}`)
return
}

View File

@@ -1,71 +1,9 @@
import chalk from 'chalk'
import { spawnSync } from 'child_process'
import fs from 'fs'
import path from 'path'
import prompts from 'prompts'
import type { CommandExecutionOptions } from '../types'
import executeCommand from './commands'
import Logging from './logging'
/**
* Executes a shell command with proper error handling
*/
export function executeCommand(
command: string | (() => string),
options: CommandExecutionOptions = {},
_arguments: string[] = []
): string {
let cmd: string
try {
cmd = typeof command === 'function' ? command() : command
} catch (error) {
Logging.actionableError(
`Failed to generate command: ${error}`,
'Check the command generation logic for errors'
)
process.exit(1)
}
try {
Logging.debug(`Executing: ${cmd}`)
const [toBeExecuted, ...args] = cmd.split(' ')
const result = spawnSync(toBeExecuted, [...args, ..._arguments], {
stdio: 'inherit',
encoding: 'utf8',
shell: true,
...options
})
if (result.error) {
throw result.error
}
if (result.status !== 0) {
throw result.status
}
if (!options.stdio || options.stdio === 'inherit') {
Logging.debug(`Completed: ${cmd}`)
}
return result.stdout?.toString().trim() || ''
} catch (error) {
if (!options.exitOnError) {
throw error
}
Logging.actionableError(
`Command execution failed: ${cmd}`,
'Check if the command exists and you have the necessary permissions'
)
Logging.debug(`Error details: ${error}`)
process.exit(1)
}
}
/**
* Validates environment variables
*/
@@ -115,29 +53,6 @@ export function getEnvVar(varName: string, fallback?: string): string {
process.exit(1)
}
/**
* Formats project list for display
*/
export function formatProjectList(projects: string[]): string {
return projects.join(', ')
}
/**
* Logs a process start message
*/
export function logProcessStart(processType: string, projects: string[]): void {
Logging.step(
`Running ${processType} for ${projects.length} project(s): ${formatProjectList(projects)}`
)
}
/**
* Logs a process completion message
*/
export function logProcessComplete(processType: string): void {
Logging.success(`All projects ${processType} completed successfully`)
}
/**
* Kills existing processes matching the given keyword
*/
@@ -149,7 +64,7 @@ export function killExistingProcess(
process.kill(processKeywordOrPID)
Logging.debug(
`Killed process with PID: ${chalk.bold.blue(processKeywordOrPID)}`
`Killed process with PID: ${Logging.highlight(String(processKeywordOrPID))}`
)
return
@@ -164,9 +79,7 @@ export function killExistingProcess(
executeCommand(`pkill -f "${processKeywordOrPID}"`)
Logging.debug(
`Killed process matching keyword: ${chalk.bold.blue(
processKeywordOrPID
)} (PID: ${chalk.bold.blue(serverInstance)})`
`Killed process matching keyword: ${Logging.highlight(processKeywordOrPID)} (PID: ${Logging.highlight(serverInstance)})`
)
return parseInt(serverInstance, 10)
@@ -176,48 +89,6 @@ export function killExistingProcess(
}
}
export type PathConfig = {
path: string
type: 'file' | 'directory'
children?: PathConfig[]
}
export function validateFilePaths(
paths: PathConfig[],
basedir: string
): boolean {
for (const p of paths) {
const { path: pth, type, children } = p
const fullPath = path.resolve(basedir, pth)
if (!fs.existsSync(fullPath)) {
Logging.error(`Invalid module structure detected: ${pth} does not exist`)
process.exit(1)
}
const stats = fs.lstatSync(fullPath)
if (type === 'file' && !stats.isFile()) {
Logging.error(`Invalid module structure detected: ${pth} is not a file`)
process.exit(1)
}
if (type === 'directory' && !stats.isDirectory()) {
Logging.error(
`Invalid module structure detected: ${pth} is not a directory`
)
process.exit(1)
}
if (children) {
validateFilePaths(children, fullPath)
}
}
return true
}
export function checkPortInUse(port: number): boolean {
try {
const result = spawnSync('nc', ['-zv', 'localhost', port.toString()], {
@@ -242,35 +113,6 @@ export function isDockerMode(): boolean {
return process.env.DOCKER_MODE === 'true'
}
/**
* Executes a command and returns the output as a string
*/
export function executeCommandWithOutput(
command: string,
options: CommandExecutionOptions = {}
): string {
const [toBeExecuted, ...args] = command.split(' ')
const result = spawnSync(toBeExecuted, args, {
stdio: 'pipe',
encoding: 'utf8',
shell: true,
...options
})
if (result.error) {
throw result.error
}
if (result.status !== 0) {
throw new Error(
`Command failed with exit code ${result.status}: ${result.stderr}`
)
}
return result.stdout?.toString().trim() || ''
}
/**
* Prompts the user for confirmation
*/

View File

@@ -3,7 +3,7 @@
import { LoggingService } from '@server/core/functions/logging/loggingService'
import chalk from 'chalk'
const LEVEL_ORDER = {
export const LEVEL_ORDER = {
debug: 1,
info: 2,
warn: 3,
@@ -11,18 +11,63 @@ const LEVEL_ORDER = {
fatal: 5
}
type LogLevel = keyof typeof LEVEL_ORDER
/**
* CLI Logging service that wraps the server's LoggingService
* Provides consistent logging across the entire CLI with file persistence
*/
export default class Logging {
private static readonly SERVICE_NAME = 'CLI'
private static level: number = LEVEL_ORDER['info']
public static level: number = LEVEL_ORDER['info']
static setLevel(level: keyof typeof LEVEL_ORDER): void {
static setLevel(level: LogLevel): void {
Logging.level = LEVEL_ORDER[level]
}
// ─────────────────────────────────────────────────────────────
// Formatting Utilities
// ─────────────────────────────────────────────────────────────
/** Format text as bold */
static bold(text: string): string {
return chalk.bold(text)
}
/** Format text as dim/muted */
static dim(text: string): string {
return chalk.dim(text)
}
/** Format text as highlighted (bold blue) - for important values */
static highlight(text: string): string {
return chalk.bold.blue(text)
}
/** Format text as green - for success/positive items */
static green(text: string): string {
return chalk.green(text)
}
/** Format text as yellow - for warnings */
static yellow(text: string): string {
return chalk.yellow(text)
}
/** Format text as red - for errors */
static red(text: string): string {
return chalk.red(text)
}
/** Format text as cyan - for info/neutral items */
static cyan(text: string): string {
return chalk.cyan(text)
}
// ─────────────────────────────────────────────────────────────
// Core Logging Methods
// ─────────────────────────────────────────────────────────────
/**
* Log an informational message
*/
@@ -77,82 +122,19 @@ export default class Logging {
return
}
LoggingService.info(chalk.green(`${message}`), this.SERVICE_NAME)
LoggingService.info(`${chalk.green('✔')} ${message}`, this.SERVICE_NAME)
}
/**
* Log a step in a process with consistent formatting
* Print raw output without log prefix (for lists, tables, etc.)
* Respects log level - only prints if info level is enabled
*/
static step(message: string): void {
static print(message: string): void {
if (Logging.level > LEVEL_ORDER['info']) {
return
}
LoggingService.info(chalk.blue(`${message}`), this.SERVICE_NAME)
}
/**
* Log a process start with spinner-like indicator
*/
static progress(message: string): void {
if (Logging.level > LEVEL_ORDER['info']) {
return
}
LoggingService.info(chalk.magenta(`${message}...`), this.SERVICE_NAME)
}
/**
* Display a formatted list of items
*/
static list(title: string, items: string[]): void {
if (Logging.level > LEVEL_ORDER['info']) {
return
}
this.info(title)
this.newline()
items.forEach((item, index) => {
console.log(
` ${chalk.cyan((index + 1).toString().padStart(2))}. ${chalk.white(item)}`
)
})
this.newline()
}
/**
* Display available options in a formatted way
*/
static options(title: string, options: string[]): void {
if (Logging.level > LEVEL_ORDER['error']) {
return
}
this.error(title)
this.error(`Available options: ${options.join(', ')}`)
}
/**
* Add a visual separator/newline for better readability
*/
static newline(): void {
if (Logging.level > LEVEL_ORDER['info']) {
return
}
console.log()
}
/**
* Log a fatal error and exit the process
*/
static fatal(message: string, exitCode = 1): never {
if (Logging.level > LEVEL_ORDER['fatal']) {
process.exit(exitCode)
}
this.error(chalk.red(`Fatal: ${message}`))
process.exit(exitCode)
console.log(message)
}
/**

View File

@@ -0,0 +1,65 @@
import fs from 'fs'
import path from 'path'
import { PROJECT_ROOT } from '@/constants/constants'
type PackageType = 'module' | 'locale'
const TYPE_CONFIG = {
module: {
prefix: '@lifeforge/',
dir: 'apps'
},
locale: {
prefix: '@lifeforge/lang-',
dir: 'locales'
}
} as const
/**
* Normalizes a package name and resolves its file system location.
*
* Takes a package name (with or without the `@lifeforge/` prefix) and returns
* the fully qualified package name, short name, and absolute path to the package directory.
*
* @param packageName - The package name to normalize (e.g., "movies" or "@lifeforge/movies")
* @param type - The type of package: 'module' (default) or 'locale'
* @returns Object containing:
* - `fullName` - The fully qualified package name (e.g., "@lifeforge/movies")
* - `shortName` - The package name without the prefix (e.g., "movies")
* - `targetDir` - The absolute path to the package directory
*
* @example
* // Module example
* normalizePackage('movies')
* // { fullName: '@lifeforge/movies', shortName: 'movies', targetDir: '/path/to/apps/movies' }
*
* @example
* // Locale example
* normalizePackage('zh-TW', 'locale')
* // { fullName: '@lifeforge/lang-zh-TW', shortName: 'zh-TW', targetDir: '/path/to/locales/zh-TW' }
*/
export default function normalizePackage(
packageName: string,
type: PackageType = 'module'
) {
const { prefix, dir } = TYPE_CONFIG[type]
const fullName = packageName.startsWith(prefix)
? packageName
: `${prefix}${packageName}`
const shortName = fullName.replace(prefix, '')
const targetDir = path.join(PROJECT_ROOT, dir, shortName)
if (!fs.existsSync(path.dirname(targetDir))) {
fs.mkdirSync(path.dirname(targetDir), { recursive: true })
}
return {
fullName,
shortName,
targetDir
}
}

View File

@@ -1,12 +1,11 @@
import chalk from 'chalk'
import { spawn } from 'child_process'
import PocketBase from 'pocketbase'
import { PB_BINARY_PATH, PB_KWARGS } from '@/constants/db'
import { executeCommand } from '@/utils/helpers'
import { getEnvVars } from '@/utils/helpers'
import Logging from '@/utils/logging'
import executeCommand from './commands'
import { killExistingProcess } from './helpers'
// Triple underscore separates username from module name
@@ -30,7 +29,8 @@ const COLLECTION_SEPARATOR = {
*/
export function parseCollectionName(
str: string,
type: 'code' | 'pb'
type: 'code' | 'pb',
fallbackModuleName?: string
): {
username?: string
moduleName: string
@@ -44,8 +44,18 @@ export function parseCollectionName(
const [username, remainder] = str.split(userNameSeparator, 2)
if (!remainder.includes(collectionSeparator)) {
Logging.error(`Invalid collection name: ${str}`)
process.exit(1)
if (!fallbackModuleName) {
Logging.actionableError(
`Invalid collection name: ${Logging.highlight(str)}`,
'Collection names must follow the format: module__collection or username___module__collection'
)
process.exit(1)
}
return {
moduleName: fallbackModuleName,
collectionName: str
}
}
const [moduleName, collectionName] = remainder.split(collectionSeparator, 2)
@@ -58,8 +68,18 @@ export function parseCollectionName(
}
if (!str.includes(collectionSeparator)) {
Logging.error(`Invalid collection name: ${str}`)
process.exit(1)
if (!fallbackModuleName) {
Logging.actionableError(
`Invalid collection name: ${Logging.highlight(str)}`,
'Collection names must follow the format: module__collection or username___module__collection'
)
process.exit(1)
}
return {
moduleName: fallbackModuleName,
collectionName: str
}
}
const [moduleName, collectionName] = str.split(collectionSeparator, 2)
@@ -137,6 +157,8 @@ export function checkRunningPBInstances(exitOnError = true): boolean {
* Starts a PocketBase server instance
*/
export async function startPBServer(): Promise<number> {
Logging.debug('Starting PocketBase server...')
return new Promise((resolve, reject) => {
const pbProcess = spawn(PB_BINARY_PATH, ['serve', ...PB_KWARGS], {
stdio: ['ignore', 'pipe', 'pipe']
@@ -146,28 +168,34 @@ export async function startPBServer(): Promise<number> {
const output = data.toString()
if (output.includes('Server started')) {
Logging.debug(`PocketBase server started (PID: ${pbProcess.pid})`)
resolve(pbProcess.pid!)
}
if (output.includes('bind: address already in use')) {
Logging.actionableError(
'Port 8090 is already in use by another application.',
'Please free up the port. Are you using the port for non-pocketbase applications? (e.g., port forwarding, etc.)'
'Port 8090 is already in use',
'Run "pkill -f pocketbase" to stop existing instances, or check for other apps using port 8090'
)
process.exit(1)
}
})
pbProcess.stderr?.on('data', data => {
reject(new Error(data.toString()))
const error = data.toString().trim()
Logging.debug(`PocketBase stderr: ${error}`)
reject(new Error(error))
})
pbProcess.on('error', error => {
Logging.debug(`PocketBase spawn error: ${error.message}`)
reject(error)
})
pbProcess.on('exit', code => {
if (code !== 0) {
Logging.debug(`PocketBase exited with code ${code}`)
reject(new Error(`PocketBase process exited with code ${code}`))
}
})
@@ -182,34 +210,31 @@ export async function startPocketbase(): Promise<(() => void) | null> {
const pbRunning = checkRunningPBInstances(false)
if (pbRunning) {
Logging.step('PocketBase server is already running, skipping...')
Logging.debug('PocketBase is already running')
return null
}
Logging.step('Starting PocketBase server...')
const pbPid = await startPBServer()
Logging.success(
`PocketBase server started successfully with PID ${chalk.bold.blue(
pbPid.toString()
)}`
)
return () => {
killExistingProcess(pbPid)
}
} catch (error) {
Logging.error(
`Failed to start PocketBase server: ${
error instanceof Error ? error.message : 'Unknown error'
}`
Logging.actionableError(
`Failed to start PocketBase server: ${error instanceof Error ? error.message : 'Unknown error'}`,
'Run "bun forge db init" to initialize the database or check if the PocketBase binary exists'
)
process.exit(1)
}
}
/**
* Gets a PocketBase instance.
*
* If `createNewInstance` is true, and there is no existing instance,
* it will start a new PocketBase instance.
*/
export default async function getPBInstance(createNewInstance = true): Promise<{
pb: PocketBase
killPB: (() => void) | null
@@ -236,8 +261,9 @@ export default async function getPBInstance(createNewInstance = true): Promise<{
killPB
}
} catch (error) {
Logging.error(
`Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`
Logging.actionableError(
`Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
'Check PB_EMAIL and PB_PASSWORD in env/.env.local are correct'
)
process.exit(1)
}

View File

@@ -1,9 +1,10 @@
import fs from 'fs'
import path from 'path'
import { executeCommand } from '@/utils/helpers'
import Logging from '@/utils/logging'
import executeCommand from './commands'
export function getRegistryUrl(): string {
const bunfigPath = path.join(process.cwd(), 'bunfig.toml')
@@ -55,8 +56,6 @@ export async function checkAuth(): Promise<{
const username = result?.toString().trim()
if (username) {
Logging.success(`Authenticated as ${username}`)
return { authenticated: true, username }
}