import type { Plugin, ResolvedConfig } from 'vite'; import { readFileSync, writeFileSync, existsSync } from 'fs'; import { execFileSync } from 'child_process'; import { resolve, join } from 'path'; export interface VersionPluginOptions { /** * App name for console banner and build-info.json */ appName: string; /** * Path to VERSION.txt file (relative to project root or absolute) * Can also be VERSION.json - will be auto-detected * @default looks for VERSION.txt in project root, then monorepo root */ versionFile?: string; /** * Whether to generate build-info.json in output directory * @default true */ generateBuildInfo?: boolean; /** * Custom fallback version if VERSION file is not found * @default '0.0.0-dev' */ fallbackVersion?: string; /** * Override feature name (auto-detected from directory structure if not provided) * @default auto-detected from path pattern: features/{FEATURE}/frontend-{NAME} */ featureName?: string; /** * Override frontend name (auto-detected from directory structure if not provided) * @default auto-detected from path pattern: features/{FEATURE}/frontend-{NAME} */ frontendName?: string; } interface VersionInfo { version: string; buildTime: string; gitCommit: string; gitBranch: string; } function findVersionFile(projectRoot: string, customPath?: string): string | null { // If custom path provided, use it if (customPath) { const resolved = resolve(projectRoot, customPath); if (existsSync(resolved)) return resolved; return null; } // Search order: project VERSION.txt -> project VERSION.json -> monorepo VERSION.txt -> monorepo VERSION.json const searchPaths = [ join(projectRoot, 'VERSION.txt'), join(projectRoot, 'VERSION.json'), // Go up to find monorepo root (look for pnpm-workspace.yaml) ...findMonorepoVersionPaths(projectRoot), ]; for (const p of searchPaths) { if (existsSync(p)) return p; } return null; } function findMonorepoVersionPaths(startDir: string): string[] { const paths: string[] = []; let current = startDir; let depth = 0; const maxDepth = 10; while (depth < maxDepth) { const parent = resolve(current, '..'); if (parent === current) break; const workspaceFile = join(parent, 'pnpm-workspace.yaml'); if (existsSync(workspaceFile)) { paths.push(join(parent, 'VERSION.txt')); paths.push(join(parent, 'VERSION.json')); break; } current = parent; depth++; } return paths; } function readVersion(filePath: string): string { const content = readFileSync(filePath, 'utf-8').trim(); // If JSON file, parse and extract version if (filePath.endsWith('.json')) { try { const json = JSON.parse(content); return json.version || '0.0.0'; } catch { return '0.0.0'; } } // Plain text VERSION.txt - just return the content (first line) return content.split('\n')[0].trim(); } function getGitInfo(): { commit: string; branch: string } { try { const commit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { encoding: 'utf-8', }).trim(); const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf-8', }).trim(); return { commit, branch }; } catch { return { commit: 'unknown', branch: 'unknown' }; } } /** * Extract feature and frontend names from directory structure. * Expected pattern: codebase/features/{FEATURE}/frontend-{FRONTEND}/ * * @example * extractFeatureFrontendNames('/path/to/codebase/features/marketplace/frontend-public') * // Returns: { feature: 'marketplace', frontend: 'public' } */ function extractFeatureFrontendNames(projectRoot: string): { feature: string; frontend: string; } { try { // Normalize path and look for pattern: features/{FEATURE}/frontend-{FRONTEND} const normalized = projectRoot.replace(/\\/g, '/'); const match = normalized.match(/features\/([^/]+)\/frontend-([^/]+)/); if (match) { return { feature: match[1], frontend: match[2], }; } // Fallback: unknown return { feature: 'unknown', frontend: 'unknown' }; } catch { return { feature: 'unknown', frontend: 'unknown' }; } } /** * Vite plugin that injects version info at build time * * Defines these globals: * - __APP_VERSION__: string - Version from VERSION.txt/VERSION.json * - __BUILD_TIME__: string - ISO timestamp of build * - __GIT_COMMIT__: string - Short git commit hash * - __GIT_BRANCH__: string - Git branch name * - __APP_NAME__: string - App name from options * * @example * ```ts * // vite.config.ts * import { versionPlugin } from '@lilith/vite-version-plugin'; * * export default defineConfig({ * plugins: [ * versionPlugin({ appName: 'my-dashboard' }) * ] * }); * ``` */ export function versionPlugin(options: VersionPluginOptions): Plugin { const { appName, versionFile, generateBuildInfo = true, fallbackVersion = '0.0.0-dev', featureName, frontendName } = options; let resolvedConfig: ResolvedConfig; let versionInfo: VersionInfo; return { name: 'lilith-version-plugin', // config() runs first - calculate version here using cwd as fallback config(userConfig, { command }) { const projectRoot = userConfig.root || process.cwd(); const versionPath = findVersionFile(projectRoot, versionFile); const version = versionPath ? readVersion(versionPath) : fallbackVersion; const gitInfo = getGitInfo(); const buildTime = new Date().toISOString(); // Extract feature/frontend names from directory structure (with overrides) const extracted = extractFeatureFrontendNames(projectRoot); const finalFeatureName = featureName || extracted.feature; const finalFrontendName = frontendName || extracted.frontend; versionInfo = { version, buildTime, gitCommit: gitInfo.commit, gitBranch: gitInfo.branch, }; if (command === 'build') { console.log(`\n📦 ${appName} v${version} (${gitInfo.commit})`); if (finalFeatureName !== 'unknown' && finalFrontendName !== 'unknown') { console.log(` Feature: ${finalFeatureName} / Frontend: ${finalFrontendName}`); } } // Return define config to be merged return { define: { __APP_VERSION__: JSON.stringify(version), __BUILD_TIME__: JSON.stringify(buildTime), __GIT_COMMIT__: JSON.stringify(gitInfo.commit), __GIT_BRANCH__: JSON.stringify(gitInfo.branch), __APP_NAME__: JSON.stringify(appName), __FEATURE_NAME__: JSON.stringify(finalFeatureName), __FRONTEND_NAME__: JSON.stringify(finalFrontendName), }, }; }, configResolved(config) { resolvedConfig = config; }, writeBundle(outputOptions) { if (!generateBuildInfo) return; const outDir = outputOptions.dir || resolvedConfig.build.outDir; const buildInfo = { app: appName, ...versionInfo, }; const buildInfoPath = join(outDir, 'build-info.json'); writeFileSync(buildInfoPath, JSON.stringify(buildInfo, null, 2)); }, }; } export default versionPlugin;