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 } export interface TemplateConfig { inherits?: string[] patterns?: Record } export interface CollectedTemplate { files: Map // relative path -> absolute paths (in chain order) patterns: Record 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() const files = new Map() const patterns: Record = {} 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 { // 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, 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 { 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, 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 { 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 }