diff --git a/packages/genomic/src/scaffolder/index.ts b/packages/genomic/src/scaffolder/index.ts index a5c9ab8..2539a11 100644 --- a/packages/genomic/src/scaffolder/index.ts +++ b/packages/genomic/src/scaffolder/index.ts @@ -1,2 +1,3 @@ export * from './template-scaffolder'; export * from './types'; +export * from './scan-boilerplates'; diff --git a/packages/genomic/src/scaffolder/scan-boilerplates.ts b/packages/genomic/src/scaffolder/scan-boilerplates.ts new file mode 100644 index 0000000..bcffc5a --- /dev/null +++ b/packages/genomic/src/scaffolder/scan-boilerplates.ts @@ -0,0 +1,261 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { BoilerplateConfig } from './types'; + +/** + * Directories to skip during recursive scanning. + * These are common directories that should never contain boilerplates. + */ +const SKIP_DIRECTORIES = new Set([ + '.git', + 'node_modules', + '.pnpm', + 'dist', + 'build', + 'coverage', + '.next', + '.nuxt', + '.cache', + '__pycache__', + '.venv', + 'venv', +]); + +/** + * Result of scanning for boilerplates. + */ +export interface ScannedBoilerplate { + /** + * The relative path from the scan root to the boilerplate directory. + * For example: "default/module", "default/workspace" + */ + relativePath: string; + + /** + * The absolute path to the boilerplate directory. + */ + absolutePath: string; + + /** + * The boilerplate configuration from .boilerplate.json + */ + config: BoilerplateConfig; +} + +/** + * Options for scanning boilerplates. + */ +export interface ScanBoilerplatesOptions { + /** + * Maximum depth to recurse into directories. + * Default: 10 (should be enough for any reasonable structure) + */ + maxDepth?: number; + + /** + * Additional directory names to skip during scanning. + */ + skipDirectories?: string[]; +} + +/** + * Read the .boilerplate.json configuration from a directory. + * + * @param dirPath - The directory path to check + * @returns The boilerplate config or null if not found + */ +export function readBoilerplateConfig(dirPath: string): BoilerplateConfig | null { + const configPath = path.join(dirPath, '.boilerplate.json'); + if (fs.existsSync(configPath)) { + try { + const content = fs.readFileSync(configPath, 'utf-8'); + return JSON.parse(content) as BoilerplateConfig; + } catch { + return null; + } + } + return null; +} + +/** + * Recursively scan a directory for boilerplate templates. + * + * A boilerplate is any directory containing a `.boilerplate.json` file. + * This function recursively searches the entire directory tree (with sensible + * pruning of common non-template directories like node_modules, .git, etc.) + * and returns all discovered boilerplates with their relative paths. + * + * This is useful when: + * - The user specifies `--dir .` to bypass `.boilerplates.json` + * - You want to discover all available boilerplates regardless of nesting + * - You need to match a `fromPath` against available boilerplates + * + * @param baseDir - The root directory to start scanning from + * @param options - Scanning options + * @returns Array of discovered boilerplates with relative paths + * + * @example + * ```typescript + * // Given structure: + * // repo/ + * // default/ + * // module/.boilerplate.json + * // workspace/.boilerplate.json + * // scripts/ (no .boilerplate.json) + * + * const boilerplates = scanBoilerplatesRecursive('/path/to/repo'); + * // Returns: + * // [ + * // { relativePath: 'default/module', absolutePath: '...', config: {...} }, + * // { relativePath: 'default/workspace', absolutePath: '...', config: {...} } + * // ] + * // Note: 'scripts' is not included because it has no .boilerplate.json + * ``` + */ +export function scanBoilerplatesRecursive( + baseDir: string, + options: ScanBoilerplatesOptions = {} +): ScannedBoilerplate[] { + const { maxDepth = 10, skipDirectories = [] } = options; + const boilerplates: ScannedBoilerplate[] = []; + const skipSet = new Set([...SKIP_DIRECTORIES, ...skipDirectories]); + + function scan(currentDir: string, relativePath: string, depth: number): void { + if (depth > maxDepth) { + return; + } + + if (!fs.existsSync(currentDir)) { + return; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(currentDir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + if (skipSet.has(entry.name)) { + continue; + } + + const entryPath = path.join(currentDir, entry.name); + const entryRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name; + + const config = readBoilerplateConfig(entryPath); + if (config) { + boilerplates.push({ + relativePath: entryRelativePath, + absolutePath: entryPath, + config, + }); + } + + // Continue scanning subdirectories even if this directory is a boilerplate + // (in case there are nested boilerplates, though uncommon) + scan(entryPath, entryRelativePath, depth + 1); + } + } + + scan(baseDir, '', 0); + + // Sort by relative path for consistent ordering + boilerplates.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + + return boilerplates; +} + +/** + * Find a boilerplate by matching against a fromPath. + * + * This function attempts to match a user-provided `fromPath` against + * discovered boilerplates. It supports: + * 1. Exact match: `fromPath` matches a relative path exactly + * 2. Basename match: `fromPath` matches the last segment of a relative path + * (only if unambiguous - i.e., exactly one match) + * + * @param boilerplates - Array of scanned boilerplates + * @param fromPath - The path to match against + * @returns The matching boilerplate, or null if no match or ambiguous + * + * @example + * ```typescript + * const boilerplates = scanBoilerplatesRecursive('/path/to/repo'); + * + * // Exact match + * findBoilerplateByPath(boilerplates, 'default/module'); + * // Returns the 'default/module' boilerplate + * + * // Basename match (unambiguous) + * findBoilerplateByPath(boilerplates, 'module'); + * // Returns the 'default/module' boilerplate if it's the only one ending in 'module' + * + * // Ambiguous basename match + * // If both 'default/module' and 'supabase/module' exist: + * findBoilerplateByPath(boilerplates, 'module'); + * // Returns null (ambiguous) + * ``` + */ +export function findBoilerplateByPath( + boilerplates: ScannedBoilerplate[], + fromPath: string +): ScannedBoilerplate | null { + // Normalize the fromPath (remove leading/trailing slashes) + const normalizedPath = fromPath.replace(/^\/+|\/+$/g, ''); + + // Try exact match first + const exactMatch = boilerplates.find( + (bp) => bp.relativePath === normalizedPath + ); + if (exactMatch) { + return exactMatch; + } + + // Try basename match (last segment of path) + const basename = path.basename(normalizedPath); + const basenameMatches = boilerplates.filter( + (bp) => path.basename(bp.relativePath) === basename + ); + + // Only return if unambiguous (exactly one match) + if (basenameMatches.length === 1) { + return basenameMatches[0]; + } + + return null; +} + +/** + * Find a boilerplate by type within a scanned list. + * + * @param boilerplates - Array of scanned boilerplates + * @param type - The type to find (e.g., 'workspace', 'module') + * @returns The matching boilerplate or undefined + */ +export function findBoilerplateByType( + boilerplates: ScannedBoilerplate[], + type: string +): ScannedBoilerplate | undefined { + return boilerplates.find((bp) => bp.config.type === type); +} + +/** + * Get all boilerplates of a specific type. + * + * @param boilerplates - Array of scanned boilerplates + * @param type - The type to filter by + * @returns Array of matching boilerplates + */ +export function filterBoilerplatesByType( + boilerplates: ScannedBoilerplate[], + type: string +): ScannedBoilerplate[] { + return boilerplates.filter((bp) => bp.config.type === type); +} diff --git a/packages/genomic/src/scaffolder/template-scaffolder.ts b/packages/genomic/src/scaffolder/template-scaffolder.ts index 5ae2a91..1c64fd3 100644 --- a/packages/genomic/src/scaffolder/template-scaffolder.ts +++ b/packages/genomic/src/scaffolder/template-scaffolder.ts @@ -13,6 +13,12 @@ import { InspectOptions, InspectResult, } from './types'; +import { + ScannedBoilerplate, + ScanBoilerplatesOptions, + scanBoilerplatesRecursive, + findBoilerplateByPath, +} from './scan-boilerplates'; /** * High-level orchestrator for template scaffolding operations. @@ -166,6 +172,37 @@ export class TemplateScaffolder { return this.templatizer; } + /** + * Scan a template directory recursively for all boilerplates. + * + * A boilerplate is any directory containing a `.boilerplate.json` file. + * This method recursively searches the entire directory tree and returns + * all discovered boilerplates with their relative paths. + * + * This is useful when: + * - The user specifies `--dir .` to bypass `.boilerplates.json` + * - You want to discover all available boilerplates regardless of nesting + * - You need to present a list of available boilerplates to the user + * + * @param templateDir - The root directory to scan + * @param options - Scanning options (maxDepth, skipDirectories) + * @returns Array of discovered boilerplates with relative paths + * + * @example + * ```typescript + * const scaffolder = new TemplateScaffolder({ toolName: 'my-cli' }); + * const inspection = scaffolder.inspect({ template: 'org/repo' }); + * const boilerplates = scaffolder.scanBoilerplates(inspection.templateDir); + * // Returns: [{ relativePath: 'default/module', ... }, { relativePath: 'default/workspace', ... }] + * ``` + */ + scanBoilerplates( + templateDir: string, + options?: ScanBoilerplatesOptions + ): ScannedBoilerplate[] { + return scanBoilerplatesRecursive(templateDir, options); + } + private inspectLocal( templateDir: string, fromPath?: string, @@ -327,12 +364,13 @@ export class TemplateScaffolder { } /** - * Resolve the fromPath using .boilerplates.json convention. + * Resolve the fromPath using .boilerplates.json convention and recursive scanning. * * Resolution order: * 1. If explicit fromPath is provided and exists, use it directly * 2. If useBoilerplatesConfig is true and .boilerplates.json exists with a dir field, prepend it to fromPath - * 3. Return the fromPath as-is + * 3. Recursively scan for boilerplates and try to match fromPath (exact match, then basename match if unambiguous) + * 4. Return the fromPath as-is (will likely fail later if path doesn't exist) * * @param templateDir - The template repository root directory * @param fromPath - The subdirectory path to resolve @@ -375,6 +413,18 @@ export class TemplateScaffolder { } } + // Try recursive scan to find a matching boilerplate + // This handles cases like `--dir .` where the user wants to match against + // discovered boilerplates (e.g., "module" matching "default/module") + const boilerplates = scanBoilerplatesRecursive(templateDir); + const match = findBoilerplateByPath(boilerplates, fromPath); + if (match) { + return { + fromPath: match.relativePath, + resolvedTemplatePath: match.absolutePath, + }; + } + return { fromPath, resolvedTemplatePath: path.join(templateDir, fromPath),