@launchql/core
Version:
LaunchQL Package and Migration Tools
162 lines (161 loc) • 6.48 kB
JavaScript
import fs from 'fs';
import os from 'os';
import path from 'path';
import { CacheManager, GitCloner, Templatizer } from 'create-gen-app';
import { readBoilerplateConfig, readBoilerplatesConfig, } from './boilerplate-scanner';
export const DEFAULT_TEMPLATE_REPO = 'https://github.com/constructive-io/pgpm-boilerplates.git';
export const DEFAULT_TEMPLATE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
export const DEFAULT_TEMPLATE_TOOL_NAME = 'pgpm';
const templatizer = new Templatizer();
const looksLikePath = (value) => {
return (value.startsWith('.') || value.startsWith('/') || value.startsWith('~'));
};
const normalizeQuestions = (questions) => questions?.map((q) => ({
...q,
type: q.type || 'text',
}));
const attachQuestionsToTemplatizer = (templ, questions) => {
if (!questions?.length || typeof templ?.extract !== 'function')
return;
const originalExtract = templ.extract.bind(templ);
templ.extract = async (templateDir) => {
const extracted = await originalExtract(templateDir);
extracted.projectQuestions = {
questions: normalizeQuestions(questions),
};
return extracted;
};
};
/**
* Resolve the template path using the new metadata-driven resolution.
*
* Resolution order:
* 1. If explicit `templatePath` is provided, use it directly
* 2. If `.boilerplates.json` exists, use its `dir` field to find the base directory
* 3. Look for `{baseDir}/{type}` (e.g., "default/module")
* 4. Fallback to legacy structure: `{type}` directly in root
*/
const resolveFromPath = (templateDir, templatePath, type, dirOverride) => {
// If explicit templatePath is provided, use it directly
if (templatePath) {
const candidateDir = path.isAbsolute(templatePath)
? templatePath
: path.join(templateDir, templatePath);
if (fs.existsSync(candidateDir) &&
fs.statSync(candidateDir).isDirectory()) {
return {
fromPath: path.relative(templateDir, candidateDir) || '.',
resolvedTemplatePath: candidateDir,
};
}
return {
fromPath: templatePath,
resolvedTemplatePath: path.join(templateDir, templatePath),
};
}
// Try new metadata-driven resolution
const rootConfig = readBoilerplatesConfig(templateDir);
const baseDir = dirOverride ?? rootConfig?.dir;
if (baseDir) {
// New structure: {templateDir}/{baseDir}/{type}
const newStructurePath = path.join(templateDir, baseDir, type);
if (fs.existsSync(newStructurePath) &&
fs.statSync(newStructurePath).isDirectory()) {
return {
fromPath: path.join(baseDir, type),
resolvedTemplatePath: newStructurePath,
};
}
}
// Fallback to legacy structure: {templateDir}/{type}
const legacyPath = path.join(templateDir, type);
if (fs.existsSync(legacyPath) && fs.statSync(legacyPath).isDirectory()) {
return {
fromPath: type,
resolvedTemplatePath: legacyPath,
};
}
// Default fallback
return {
fromPath: type,
resolvedTemplatePath: path.join(templateDir, type),
};
};
export async function scaffoldTemplate(options) {
const { type, outputDir, templateRepo = DEFAULT_TEMPLATE_REPO, branch, templatePath, answers, noTty = false, cacheTtlMs = DEFAULT_TEMPLATE_TTL_MS, toolName = DEFAULT_TEMPLATE_TOOL_NAME, cwd, cacheBaseDir, dir, } = options;
const resolvedRepo = looksLikePath(templateRepo)
? path.resolve(cwd ?? process.cwd(), templateRepo)
: templateRepo;
// Handle local template directories without caching
if (looksLikePath(templateRepo) &&
fs.existsSync(resolvedRepo) &&
fs.statSync(resolvedRepo).isDirectory()) {
const { fromPath, resolvedTemplatePath } = resolveFromPath(resolvedRepo, templatePath, type, dir);
// Read boilerplate config for questions
const boilerplateConfig = readBoilerplateConfig(resolvedTemplatePath);
// Inject questions into the templatizer pipeline so prompt types and defaults are applied
attachQuestionsToTemplatizer(templatizer, boilerplateConfig?.questions);
await templatizer.process(resolvedRepo, outputDir, {
argv: answers,
noTty,
fromPath,
});
return {
cacheUsed: false,
cacheExpired: false,
templateDir: resolvedRepo,
questions: boilerplateConfig?.questions,
};
}
// Remote repo with caching
const cacheManager = new CacheManager({
toolName,
ttl: cacheTtlMs,
baseDir: cacheBaseDir ??
process.env.PGPM_CACHE_BASE_DIR ??
(process.env.JEST_WORKER_ID
? path.join(os.tmpdir(), `pgpm-cache-${process.env.JEST_WORKER_ID}`)
: undefined),
});
const gitCloner = new GitCloner();
const normalizedUrl = gitCloner.normalizeUrl(resolvedRepo);
const cacheKey = cacheManager.createKey(normalizedUrl, branch);
const expiredMetadata = cacheManager.checkExpiration(cacheKey);
if (expiredMetadata) {
cacheManager.clear(cacheKey);
}
let templateDir;
let cacheUsed = false;
const cachedPath = cacheManager.get(cacheKey);
if (cachedPath && !expiredMetadata) {
templateDir = cachedPath;
cacheUsed = true;
}
else {
const tempDest = path.join(cacheManager.getReposDir(), cacheKey);
gitCloner.clone(normalizedUrl, tempDest, {
branch,
depth: 1,
singleBranch: true,
});
cacheManager.set(cacheKey, tempDest);
templateDir = tempDest;
}
const { fromPath, resolvedTemplatePath } = resolveFromPath(templateDir, templatePath, type, dir);
// Read boilerplate config for questions
const boilerplateConfig = readBoilerplateConfig(resolvedTemplatePath);
// Inject questions into the templatizer pipeline so prompt types and defaults are applied
attachQuestionsToTemplatizer(templatizer, boilerplateConfig?.questions);
await templatizer.process(templateDir, outputDir, {
argv: answers,
noTty,
fromPath,
});
return {
cacheUsed,
cacheExpired: Boolean(expiredMetadata),
cachePath: templateDir,
templateDir,
questions: boilerplateConfig?.questions,
};
}