Skip to content

Commit e3d45be

Browse files
committed
feat(genomic): add recursive boilerplate scanning
- Add scanBoilerplatesRecursive function to discover all boilerplates in a directory tree by looking for .boilerplate.json files - Add findBoilerplateByPath for matching fromPath against discovered boilerplates (exact match, then unambiguous basename match) - Add findBoilerplateByType and filterBoilerplatesByType helpers - Add scanBoilerplates method to TemplateScaffolder class - Update resolveFromPath to use recursive scan as fallback when direct path and .boilerplates.json resolution fail This enables proper boilerplate discovery when using --dir . to bypass .boilerplates.json, ensuring directories without .boilerplate.json (like scripts/) are never shown as options.
1 parent 8f2e433 commit e3d45be

File tree

3 files changed

+314
-2
lines changed

3 files changed

+314
-2
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './template-scaffolder';
22
export * from './types';
3+
export * from './scan-boilerplates';
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
import { BoilerplateConfig } from './types';
5+
6+
/**
7+
* Directories to skip during recursive scanning.
8+
* These are common directories that should never contain boilerplates.
9+
*/
10+
const SKIP_DIRECTORIES = new Set([
11+
'.git',
12+
'node_modules',
13+
'.pnpm',
14+
'dist',
15+
'build',
16+
'coverage',
17+
'.next',
18+
'.nuxt',
19+
'.cache',
20+
'__pycache__',
21+
'.venv',
22+
'venv',
23+
]);
24+
25+
/**
26+
* Result of scanning for boilerplates.
27+
*/
28+
export interface ScannedBoilerplate {
29+
/**
30+
* The relative path from the scan root to the boilerplate directory.
31+
* For example: "default/module", "default/workspace"
32+
*/
33+
relativePath: string;
34+
35+
/**
36+
* The absolute path to the boilerplate directory.
37+
*/
38+
absolutePath: string;
39+
40+
/**
41+
* The boilerplate configuration from .boilerplate.json
42+
*/
43+
config: BoilerplateConfig;
44+
}
45+
46+
/**
47+
* Options for scanning boilerplates.
48+
*/
49+
export interface ScanBoilerplatesOptions {
50+
/**
51+
* Maximum depth to recurse into directories.
52+
* Default: 10 (should be enough for any reasonable structure)
53+
*/
54+
maxDepth?: number;
55+
56+
/**
57+
* Additional directory names to skip during scanning.
58+
*/
59+
skipDirectories?: string[];
60+
}
61+
62+
/**
63+
* Read the .boilerplate.json configuration from a directory.
64+
*
65+
* @param dirPath - The directory path to check
66+
* @returns The boilerplate config or null if not found
67+
*/
68+
export function readBoilerplateConfig(dirPath: string): BoilerplateConfig | null {
69+
const configPath = path.join(dirPath, '.boilerplate.json');
70+
if (fs.existsSync(configPath)) {
71+
try {
72+
const content = fs.readFileSync(configPath, 'utf-8');
73+
return JSON.parse(content) as BoilerplateConfig;
74+
} catch {
75+
return null;
76+
}
77+
}
78+
return null;
79+
}
80+
81+
/**
82+
* Recursively scan a directory for boilerplate templates.
83+
*
84+
* A boilerplate is any directory containing a `.boilerplate.json` file.
85+
* This function recursively searches the entire directory tree (with sensible
86+
* pruning of common non-template directories like node_modules, .git, etc.)
87+
* and returns all discovered boilerplates with their relative paths.
88+
*
89+
* This is useful when:
90+
* - The user specifies `--dir .` to bypass `.boilerplates.json`
91+
* - You want to discover all available boilerplates regardless of nesting
92+
* - You need to match a `fromPath` against available boilerplates
93+
*
94+
* @param baseDir - The root directory to start scanning from
95+
* @param options - Scanning options
96+
* @returns Array of discovered boilerplates with relative paths
97+
*
98+
* @example
99+
* ```typescript
100+
* // Given structure:
101+
* // repo/
102+
* // default/
103+
* // module/.boilerplate.json
104+
* // workspace/.boilerplate.json
105+
* // scripts/ (no .boilerplate.json)
106+
*
107+
* const boilerplates = scanBoilerplatesRecursive('/path/to/repo');
108+
* // Returns:
109+
* // [
110+
* // { relativePath: 'default/module', absolutePath: '...', config: {...} },
111+
* // { relativePath: 'default/workspace', absolutePath: '...', config: {...} }
112+
* // ]
113+
* // Note: 'scripts' is not included because it has no .boilerplate.json
114+
* ```
115+
*/
116+
export function scanBoilerplatesRecursive(
117+
baseDir: string,
118+
options: ScanBoilerplatesOptions = {}
119+
): ScannedBoilerplate[] {
120+
const { maxDepth = 10, skipDirectories = [] } = options;
121+
const boilerplates: ScannedBoilerplate[] = [];
122+
const skipSet = new Set([...SKIP_DIRECTORIES, ...skipDirectories]);
123+
124+
function scan(currentDir: string, relativePath: string, depth: number): void {
125+
if (depth > maxDepth) {
126+
return;
127+
}
128+
129+
if (!fs.existsSync(currentDir)) {
130+
return;
131+
}
132+
133+
let entries: fs.Dirent[];
134+
try {
135+
entries = fs.readdirSync(currentDir, { withFileTypes: true });
136+
} catch {
137+
return;
138+
}
139+
140+
for (const entry of entries) {
141+
if (!entry.isDirectory()) {
142+
continue;
143+
}
144+
145+
if (skipSet.has(entry.name)) {
146+
continue;
147+
}
148+
149+
const entryPath = path.join(currentDir, entry.name);
150+
const entryRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name;
151+
152+
const config = readBoilerplateConfig(entryPath);
153+
if (config) {
154+
boilerplates.push({
155+
relativePath: entryRelativePath,
156+
absolutePath: entryPath,
157+
config,
158+
});
159+
}
160+
161+
// Continue scanning subdirectories even if this directory is a boilerplate
162+
// (in case there are nested boilerplates, though uncommon)
163+
scan(entryPath, entryRelativePath, depth + 1);
164+
}
165+
}
166+
167+
scan(baseDir, '', 0);
168+
169+
// Sort by relative path for consistent ordering
170+
boilerplates.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
171+
172+
return boilerplates;
173+
}
174+
175+
/**
176+
* Find a boilerplate by matching against a fromPath.
177+
*
178+
* This function attempts to match a user-provided `fromPath` against
179+
* discovered boilerplates. It supports:
180+
* 1. Exact match: `fromPath` matches a relative path exactly
181+
* 2. Basename match: `fromPath` matches the last segment of a relative path
182+
* (only if unambiguous - i.e., exactly one match)
183+
*
184+
* @param boilerplates - Array of scanned boilerplates
185+
* @param fromPath - The path to match against
186+
* @returns The matching boilerplate, or null if no match or ambiguous
187+
*
188+
* @example
189+
* ```typescript
190+
* const boilerplates = scanBoilerplatesRecursive('/path/to/repo');
191+
*
192+
* // Exact match
193+
* findBoilerplateByPath(boilerplates, 'default/module');
194+
* // Returns the 'default/module' boilerplate
195+
*
196+
* // Basename match (unambiguous)
197+
* findBoilerplateByPath(boilerplates, 'module');
198+
* // Returns the 'default/module' boilerplate if it's the only one ending in 'module'
199+
*
200+
* // Ambiguous basename match
201+
* // If both 'default/module' and 'supabase/module' exist:
202+
* findBoilerplateByPath(boilerplates, 'module');
203+
* // Returns null (ambiguous)
204+
* ```
205+
*/
206+
export function findBoilerplateByPath(
207+
boilerplates: ScannedBoilerplate[],
208+
fromPath: string
209+
): ScannedBoilerplate | null {
210+
// Normalize the fromPath (remove leading/trailing slashes)
211+
const normalizedPath = fromPath.replace(/^\/+|\/+$/g, '');
212+
213+
// Try exact match first
214+
const exactMatch = boilerplates.find(
215+
(bp) => bp.relativePath === normalizedPath
216+
);
217+
if (exactMatch) {
218+
return exactMatch;
219+
}
220+
221+
// Try basename match (last segment of path)
222+
const basename = path.basename(normalizedPath);
223+
const basenameMatches = boilerplates.filter(
224+
(bp) => path.basename(bp.relativePath) === basename
225+
);
226+
227+
// Only return if unambiguous (exactly one match)
228+
if (basenameMatches.length === 1) {
229+
return basenameMatches[0];
230+
}
231+
232+
return null;
233+
}
234+
235+
/**
236+
* Find a boilerplate by type within a scanned list.
237+
*
238+
* @param boilerplates - Array of scanned boilerplates
239+
* @param type - The type to find (e.g., 'workspace', 'module')
240+
* @returns The matching boilerplate or undefined
241+
*/
242+
export function findBoilerplateByType(
243+
boilerplates: ScannedBoilerplate[],
244+
type: string
245+
): ScannedBoilerplate | undefined {
246+
return boilerplates.find((bp) => bp.config.type === type);
247+
}
248+
249+
/**
250+
* Get all boilerplates of a specific type.
251+
*
252+
* @param boilerplates - Array of scanned boilerplates
253+
* @param type - The type to filter by
254+
* @returns Array of matching boilerplates
255+
*/
256+
export function filterBoilerplatesByType(
257+
boilerplates: ScannedBoilerplate[],
258+
type: string
259+
): ScannedBoilerplate[] {
260+
return boilerplates.filter((bp) => bp.config.type === type);
261+
}

packages/genomic/src/scaffolder/template-scaffolder.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ import {
1313
InspectOptions,
1414
InspectResult,
1515
} from './types';
16+
import {
17+
ScannedBoilerplate,
18+
ScanBoilerplatesOptions,
19+
scanBoilerplatesRecursive,
20+
findBoilerplateByPath,
21+
} from './scan-boilerplates';
1622

1723
/**
1824
* High-level orchestrator for template scaffolding operations.
@@ -166,6 +172,37 @@ export class TemplateScaffolder {
166172
return this.templatizer;
167173
}
168174

175+
/**
176+
* Scan a template directory recursively for all boilerplates.
177+
*
178+
* A boilerplate is any directory containing a `.boilerplate.json` file.
179+
* This method recursively searches the entire directory tree and returns
180+
* all discovered boilerplates with their relative paths.
181+
*
182+
* This is useful when:
183+
* - The user specifies `--dir .` to bypass `.boilerplates.json`
184+
* - You want to discover all available boilerplates regardless of nesting
185+
* - You need to present a list of available boilerplates to the user
186+
*
187+
* @param templateDir - The root directory to scan
188+
* @param options - Scanning options (maxDepth, skipDirectories)
189+
* @returns Array of discovered boilerplates with relative paths
190+
*
191+
* @example
192+
* ```typescript
193+
* const scaffolder = new TemplateScaffolder({ toolName: 'my-cli' });
194+
* const inspection = scaffolder.inspect({ template: 'org/repo' });
195+
* const boilerplates = scaffolder.scanBoilerplates(inspection.templateDir);
196+
* // Returns: [{ relativePath: 'default/module', ... }, { relativePath: 'default/workspace', ... }]
197+
* ```
198+
*/
199+
scanBoilerplates(
200+
templateDir: string,
201+
options?: ScanBoilerplatesOptions
202+
): ScannedBoilerplate[] {
203+
return scanBoilerplatesRecursive(templateDir, options);
204+
}
205+
169206
private inspectLocal(
170207
templateDir: string,
171208
fromPath?: string,
@@ -327,12 +364,13 @@ export class TemplateScaffolder {
327364
}
328365

329366
/**
330-
* Resolve the fromPath using .boilerplates.json convention.
367+
* Resolve the fromPath using .boilerplates.json convention and recursive scanning.
331368
*
332369
* Resolution order:
333370
* 1. If explicit fromPath is provided and exists, use it directly
334371
* 2. If useBoilerplatesConfig is true and .boilerplates.json exists with a dir field, prepend it to fromPath
335-
* 3. Return the fromPath as-is
372+
* 3. Recursively scan for boilerplates and try to match fromPath (exact match, then basename match if unambiguous)
373+
* 4. Return the fromPath as-is (will likely fail later if path doesn't exist)
336374
*
337375
* @param templateDir - The template repository root directory
338376
* @param fromPath - The subdirectory path to resolve
@@ -375,6 +413,18 @@ export class TemplateScaffolder {
375413
}
376414
}
377415

416+
// Try recursive scan to find a matching boilerplate
417+
// This handles cases like `--dir .` where the user wants to match against
418+
// discovered boilerplates (e.g., "module" matching "default/module")
419+
const boilerplates = scanBoilerplatesRecursive(templateDir);
420+
const match = findBoilerplateByPath(boilerplates, fromPath);
421+
if (match) {
422+
return {
423+
fromPath: match.relativePath,
424+
resolvedTemplatePath: match.absolutePath,
425+
};
426+
}
427+
378428
return {
379429
fromPath,
380430
resolvedTemplatePath: path.join(templateDir, fromPath),

0 commit comments

Comments
 (0)