236 lines
6.4 KiB
TypeScript
236 lines
6.4 KiB
TypeScript
import { resolve, join, dirname } from "node:path"
|
|
import { existsSync } from "node:fs"
|
|
import { readdir } from "node:fs/promises"
|
|
import { Glob } from "bun"
|
|
|
|
// --- Types ---
|
|
|
|
export interface RegimeConfig {
|
|
templates: string | string[]
|
|
vars?: Record<string, string>
|
|
}
|
|
|
|
export interface TemplateConfig {
|
|
inherits?: string[]
|
|
patterns?: Record<string, string>
|
|
}
|
|
|
|
export interface CollectedTemplate {
|
|
files: Map<string, string[]> // relative path -> absolute paths (in chain order)
|
|
patterns: Record<string, string>
|
|
templateNames: string[] // ordered list of visited template names
|
|
}
|
|
|
|
// --- Constants ---
|
|
|
|
export const regimeDir = resolve(dirname(import.meta.dir))
|
|
export const templatesDir = join(regimeDir, "templates")
|
|
|
|
// --- Utilities ---
|
|
|
|
export function readFileSync(path: string): string {
|
|
return require("fs").readFileSync(path, "utf-8")
|
|
}
|
|
|
|
export function readdirSyncRecursive(dir: string, prefix = ""): string[] {
|
|
const fs = require("fs")
|
|
const results: string[] = []
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const rel = prefix ? `${prefix}/${entry.name}` : entry.name
|
|
if (entry.isDirectory()) {
|
|
results.push(...readdirSyncRecursive(join(dir, entry.name), rel))
|
|
} else {
|
|
results.push(rel)
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
// --- Template resolution ---
|
|
|
|
export function resolveTemplateConfig(name: string): TemplateConfig {
|
|
const configPath = join(templatesDir, name, ".regime-template.json")
|
|
if (!existsSync(configPath)) return {}
|
|
const raw = JSON.parse(readFileSync(configPath))
|
|
return raw as TemplateConfig
|
|
}
|
|
|
|
export function resolveTemplateChain(names: string[]): CollectedTemplate {
|
|
const visited = new Set<string>()
|
|
const files = new Map<string, string[]>()
|
|
const patterns: Record<string, string> = {}
|
|
const templateNames: string[] = []
|
|
|
|
function walk(name: string) {
|
|
if (visited.has(name)) return
|
|
visited.add(name)
|
|
|
|
const dir = join(templatesDir, name)
|
|
if (!existsSync(dir)) {
|
|
console.error(` warning: template "${name}" not found at ${dir}`)
|
|
return
|
|
}
|
|
|
|
const config = resolveTemplateConfig(name)
|
|
|
|
// Walk parents first so children override
|
|
if (config.inherits) {
|
|
for (const parent of config.inherits) {
|
|
walk(parent)
|
|
}
|
|
}
|
|
|
|
templateNames.push(name)
|
|
|
|
// Collect patterns
|
|
if (config.patterns) {
|
|
Object.assign(patterns, config.patterns)
|
|
}
|
|
|
|
// Collect files (skip .regime-template.json)
|
|
const entries = readdirSyncRecursive(dir)
|
|
for (const entry of entries) {
|
|
if (entry === ".regime-template.json") continue
|
|
const existing = files.get(entry) ?? []
|
|
existing.push(join(dir, entry))
|
|
files.set(entry, existing)
|
|
}
|
|
}
|
|
|
|
for (const name of names) {
|
|
walk(name)
|
|
}
|
|
|
|
return { files, patterns, templateNames }
|
|
}
|
|
|
|
// --- Strategy matching ---
|
|
|
|
export function getStrategy(filePath: string, patterns: Record<string, string>): string {
|
|
// Check exact match first
|
|
if (patterns[filePath]) return patterns[filePath]
|
|
|
|
// Check glob patterns
|
|
for (const [pattern, strategy] of Object.entries(patterns)) {
|
|
if (pattern.includes("*")) {
|
|
const glob = new Glob(pattern)
|
|
if (glob.match(filePath)) return strategy
|
|
}
|
|
}
|
|
|
|
return "overwrite" // default
|
|
}
|
|
|
|
// --- Variable interpolation ---
|
|
|
|
export function interpolate(content: string, vars: Record<string, string>, context?: string): string {
|
|
return content.replace(/<<(\w+)>>/g, (_, key) => {
|
|
if (!(key in vars)) {
|
|
console.error(` warning: undeclared var "<<${key}>>"${context ? ` in ${context}` : ""}`)
|
|
}
|
|
return vars[key] ?? `<<${key}>>`
|
|
})
|
|
}
|
|
|
|
export function deinterpolate(content: string, vars: Record<string, string>): string {
|
|
let result = content
|
|
for (const [key, value] of Object.entries(vars)) {
|
|
result = result.replaceAll(value, `<<${key}>>`)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// --- Deep merge (template values win; existing-only fields preserved) ---
|
|
|
|
export function deepMerge(base: any, overlay: any): any {
|
|
if (typeof base !== "object" || base === null || Array.isArray(base)) return overlay
|
|
if (typeof overlay !== "object" || overlay === null || Array.isArray(overlay)) return overlay
|
|
|
|
const result = { ...base }
|
|
for (const key of Object.keys(overlay)) {
|
|
if (key in result) {
|
|
result[key] = deepMerge(result[key], overlay[key])
|
|
} else {
|
|
result[key] = overlay[key]
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// --- Merge all template JSON files into one combined object ---
|
|
|
|
export function mergeTemplateJsonFiles(paths: string[], vars: Record<string, string>, relPath: string): any {
|
|
let merged: any = {}
|
|
for (const p of paths) {
|
|
const content = interpolate(readFileSync(p), vars, relPath)
|
|
merged = deepMerge(merged, JSON.parse(content))
|
|
}
|
|
return merged
|
|
}
|
|
|
|
// --- Indentation detection ---
|
|
|
|
export function detectIndent(content: string): string {
|
|
const match = content.match(/^(\s+)/m)
|
|
return match?.[1] ?? " "
|
|
}
|
|
|
|
// --- Diff reporting ---
|
|
|
|
export function diffJson(
|
|
templateObj: any,
|
|
existingObj: any,
|
|
path: string[] = [],
|
|
): { field: string; expected: any; actual: any }[] {
|
|
const diffs: { field: string; expected: any; actual: any }[] = []
|
|
|
|
for (const key of Object.keys(templateObj)) {
|
|
const fieldPath = [...path, key].join(".")
|
|
const expected = templateObj[key]
|
|
const actual = existingObj?.[key]
|
|
|
|
if (actual === undefined) {
|
|
diffs.push({ field: fieldPath, expected, actual: undefined })
|
|
} else if (
|
|
typeof expected === "object" &&
|
|
expected !== null &&
|
|
!Array.isArray(expected) &&
|
|
typeof actual === "object" &&
|
|
actual !== null &&
|
|
!Array.isArray(actual)
|
|
) {
|
|
diffs.push(...diffJson(expected, actual, [...path, key]))
|
|
} else if (JSON.stringify(expected) !== JSON.stringify(actual)) {
|
|
diffs.push({ field: fieldPath, expected, actual })
|
|
}
|
|
}
|
|
|
|
return diffs
|
|
}
|
|
|
|
// --- Find all regime.config.json files in a repo ---
|
|
|
|
export async function findRegimeConfigs(repoDir: string): Promise<string[]> {
|
|
const results: string[] = []
|
|
|
|
async function walk(dir: string) {
|
|
let entries
|
|
try {
|
|
entries = await readdir(dir, { withFileTypes: true })
|
|
} catch {
|
|
return
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.name === "node_modules" || entry.name === ".git") continue
|
|
const full = join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
await walk(full)
|
|
} else if (entry.name === "regime.config.json") {
|
|
results.push(full)
|
|
}
|
|
}
|
|
}
|
|
|
|
await walk(repoDir)
|
|
return results
|
|
}
|