mirror of
https://github.com/Lifeforge-app/lifeforge.git
synced 2026-06-28 06:46:24 +00:00
refactor(forgeCLI): force myself to be a vibe code cleanup specialist
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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})`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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})`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)}`)
|
||||
}
|
||||
@@ -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' : ''}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"', {
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
|
||||
@@ -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})`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
|
||||
@@ -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' : ''}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,5 +31,5 @@ try {
|
||||
await setupCLI()
|
||||
runCLI()
|
||||
} catch (error) {
|
||||
Logging.fatal(`Unexpected error occurred: ${error}`)
|
||||
Logging.error(`Unexpected error occurred: ${error}`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
76
tools/src/utils/commands.ts
Normal file
76
tools/src/utils/commands.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
65
tools/src/utils/normalizePackage.ts
Normal file
65
tools/src/utils/normalizePackage.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user