feat(regime)

This commit is contained in:
Dan Finch 2026-04-29 00:55:53 +02:00
commit 1f712c1c84
37 changed files with 1059 additions and 0 deletions

90
src/check.ts Normal file
View file

@ -0,0 +1,90 @@
import { dirname, relative, join } from "node:path"
import { existsSync } from "node:fs"
import {
type RegimeConfig,
findRegimeConfigs,
resolveTemplateChain,
getStrategy,
interpolate,
readFileSync,
diffJson,
mergeTemplateJsonFiles,
} from "./shared"
const red = Bun.color("red", "ansi")
const orange = Bun.color("orange", "ansi")
const green = Bun.color("green", "ansi")
const purple = Bun.color("purple", "ansi")
const reset = "\x1b[0m"
export async function check(targetDir: string): Promise<void> {
const rcFiles = await findRegimeConfigs(targetDir)
if (rcFiles.length === 0) {
console.log("No regime.config.json files found.")
return
}
for (const rcFile of rcFiles) {
const rcDir = dirname(rcFile)
const relDir = relative(targetDir, rcDir) || "."
console.log(`\n${purple}${relDir}/${reset}`)
const rc: RegimeConfig = JSON.parse(readFileSync(rcFile))
const templateNames = Array.isArray(rc.templates) ? rc.templates : [rc.templates]
const vars = rc.vars ?? {}
const { files, patterns } = resolveTemplateChain(templateNames)
if (files.size === 0) {
console.log(" (no template files)")
continue
}
let synced = true
for (const [relPath, templatePaths] of files) {
const targetPath = join(rcDir, relPath)
const strategy = getStrategy(relPath, patterns)
if (!existsSync(targetPath)) {
console.log(` ${relPath}: ${red}missing${reset}`)
synced = false
continue
}
const existingContent = readFileSync(targetPath)
if (strategy === "merge json") {
try {
const templateObj = mergeTemplateJsonFiles(templatePaths, vars, relPath)
const existingObj = JSON.parse(existingContent)
const diffs = diffJson(templateObj, existingObj)
if (diffs.length > 0) {
synced = false
console.log(` ${relPath}:`)
for (const d of diffs) {
const exp = JSON.stringify(d.expected)
const act = d.actual === undefined ? `${red}missing${reset}` : `${orange}${JSON.stringify(d.actual)}${reset}`
console.log(` ${d.field}: ${act} -> ${green}${exp}${reset}`)
}
}
} catch (e) {
console.log(` ${relPath}: ${red}failed to parse JSON${reset} - ${e}`)
synced = false
}
} else if (strategy === "overwrite") {
const templateContent = interpolate(readFileSync(templatePaths[templatePaths.length - 1]), vars, relPath)
if (existingContent !== templateContent) {
console.log(` ${relPath}: ${orange}differs${reset}`)
synced = false
}
}
}
if (synced) {
console.log(` ${green}in sync${reset}`)
}
}
}

163
src/promote.ts Normal file
View file

@ -0,0 +1,163 @@
import { dirname, join } from "node:path"
import { existsSync } from "node:fs"
import { writeFile } from "node:fs/promises"
import { $ } from "bun"
import {
type RegimeConfig,
findRegimeConfigs,
resolveTemplateChain,
getStrategy,
interpolate,
deinterpolate,
readFileSync,
templatesDir,
} from "./shared"
const green = Bun.color("green", "ansi")
const orange = Bun.color("orange", "ansi")
const purple = Bun.color("purple", "ansi")
const reset = "\x1b[0m"
async function gumStdin(command: string, input: string): Promise<string> {
const proc = Bun.spawn(["gum", command], {
stdin: Buffer.from(input),
stdout: "pipe",
stderr: "inherit",
})
const output = await new Response(proc.stdout).text()
await proc.exited
return output
}
interface PromotableFile {
relPath: string // relative path within template/repo (e.g. "tsconfig.json")
repoPath: string // absolute path in the repo
configDir: string // directory containing regime.config.json
vars: Record<string, string>
templateNames: string[] // all templates in the inheritance tree
}
export async function promote(targetDir: string, yes = false): Promise<void> {
// Step 1: Find regime configs
const rcFiles = await findRegimeConfigs(targetDir)
if (rcFiles.length === 0) {
console.error("No regime.config.json files found.")
process.exit(1)
}
// Step 2: Collect promotable files (overwrite-strategy files that differ)
const promotable: PromotableFile[] = []
for (const rcFile of rcFiles) {
const rcDir = dirname(rcFile)
const rc: RegimeConfig = JSON.parse(readFileSync(rcFile))
const templateNames = Array.isArray(rc.templates) ? rc.templates : [rc.templates]
const vars = rc.vars ?? {}
const { files, patterns, templateNames: chainNames } = resolveTemplateChain(templateNames)
for (const [relPath, templatePaths] of files) {
const strategy = getStrategy(relPath, patterns)
if (strategy !== "overwrite") continue
const repoPath = join(rcDir, relPath)
if (!existsSync(repoPath)) continue
const repoContent = readFileSync(repoPath)
const templateContent = interpolate(
readFileSync(templatePaths[templatePaths.length - 1]),
vars,
relPath,
)
if (repoContent !== templateContent) {
promotable.push({
relPath,
repoPath,
configDir: rcDir,
vars,
templateNames: chainNames,
})
}
}
}
if (promotable.length === 0) {
console.log("Nothing to promote.")
return
}
// Step 3: Interactive file selection via gum filter
const fileList = promotable.map(p => p.relPath).join("\n")
const selectedFile = (await gumStdin("filter", fileList)).trim()
if (!selectedFile) {
console.log("No file selected.")
return
}
const entry = promotable.find(p => p.relPath === selectedFile)
if (!entry) {
console.error(`File "${selectedFile}" not found in promotable list.`)
process.exit(1)
}
// Step 4: Interactive template selection via gum choose
let selectedTemplate: string
if (entry.templateNames.length === 1) {
selectedTemplate = entry.templateNames[0]
} else {
const templateList = entry.templateNames.join("\n")
selectedTemplate = (await gumStdin("choose", templateList)).trim()
}
if (!selectedTemplate) {
console.log("No template selected.")
return
}
// Step 5: De-interpolate
const repoContent = readFileSync(entry.repoPath)
const promoted = deinterpolate(repoContent, entry.vars)
// Step 6: Show diff
const templateFilePath = join(templatesDir, selectedTemplate, entry.relPath)
console.log(`\n${purple}Promoting${reset} ${entry.relPath} -> ${green}${selectedTemplate}${reset}`)
if (existsSync(templateFilePath)) {
const tmpFile = `/tmp/regime-promote-${Date.now()}`
await writeFile(tmpFile, promoted)
try {
const diff = await $`diff -u ${templateFilePath} ${tmpFile} || true`.text()
if (diff.trim()) {
console.log(diff)
} else {
console.log("No changes detected after de-interpolation.")
return
}
} finally {
await $`rm -f ${tmpFile}`.quiet()
}
} else {
console.log(`${orange}New file${reset} — will be created in template "${selectedTemplate}"`)
console.log(promoted)
}
// Step 7: Confirm
if (!yes) {
const confirmed = await $`gum confirm "Write to template?"`.nothrow()
if (confirmed.exitCode !== 0) {
console.log("Cancelled.")
return
}
}
// Step 8: Write
const targetFileDir = dirname(templateFilePath)
if (!existsSync(targetFileDir)) {
await $`mkdir -p ${targetFileDir}`.quiet()
}
await writeFile(templateFilePath, promoted)
console.log(`${green}Written${reset} ${templateFilePath}`)
}

236
src/shared.ts Normal file
View file

@ -0,0 +1,236 @@
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
}

124
src/sync.ts Normal file
View file

@ -0,0 +1,124 @@
import { dirname, relative, join } from "node:path"
import { existsSync } from "node:fs"
import { writeFile } from "node:fs/promises"
import {
type RegimeConfig,
findRegimeConfigs,
resolveTemplateChain,
getStrategy,
interpolate,
readFileSync,
deepMerge,
mergeTemplateJsonFiles,
detectIndent,
} from "./shared"
const green = Bun.color("green", "ansi")
const yellow = Bun.color("orange", "ansi")
const red = Bun.color("red", "ansi")
const purple = Bun.color("purple", "ansi")
const reset = "\x1b[0m"
export async function sync(targetDir: string): Promise<void> {
const rcFiles = await findRegimeConfigs(targetDir)
if (rcFiles.length === 0) {
console.log("No regime.config.json files found.")
return
}
for (const rcFile of rcFiles) {
const rcDir = dirname(rcFile)
const relDir = relative(targetDir, rcDir) || "."
console.log(`\n${purple}${relDir}/${reset}`)
const rc: RegimeConfig = JSON.parse(readFileSync(rcFile))
const templateNames = Array.isArray(rc.templates) ? rc.templates : [rc.templates]
const vars = rc.vars ?? {}
const { files, patterns } = resolveTemplateChain(templateNames)
if (files.size === 0) {
console.log(" (no template files)")
continue
}
let allSynced = true
for (const [relPath, templatePaths] of files) {
const targetPath = join(rcDir, relPath)
const strategy = getStrategy(relPath, patterns)
// Check target directory exists
const targetFileDir = dirname(targetPath)
if (!existsSync(targetFileDir)) {
console.log(` ${relPath}: ${yellow}warning: directory does not exist, skipping${reset}`)
continue
}
if (strategy === "merge json") {
let templateObj: any
try {
templateObj = mergeTemplateJsonFiles(templatePaths, vars, relPath)
} catch (e) {
console.log(` ${relPath}: ${red}failed to parse template JSON${reset} - ${e}`)
continue
}
if (existsSync(targetPath)) {
const existingContent = readFileSync(targetPath)
let existingObj: any
try {
existingObj = JSON.parse(existingContent)
} catch (e) {
console.log(` ${relPath}: ${red}failed to parse existing JSON${reset} - ${e}`)
continue
}
const merged = deepMerge(existingObj, templateObj)
const indent = detectIndent(existingContent)
const mergedContent = JSON.stringify(merged, null, indent) + "\n"
if (mergedContent === existingContent) {
// already in sync
continue
}
await writeFile(targetPath, mergedContent)
console.log(` ${relPath}: ${green}updated${reset}`)
allSynced = false
} else {
const content = JSON.stringify(templateObj, null, " ") + "\n"
await writeFile(targetPath, content)
console.log(` ${relPath}: ${green}created${reset}`)
allSynced = false
}
} else if (strategy === "overwrite") {
const templateContent = interpolate(
readFileSync(templatePaths[templatePaths.length - 1]),
vars,
relPath,
)
if (existsSync(targetPath)) {
const existingContent = readFileSync(targetPath)
if (existingContent === templateContent) {
// already in sync
continue
}
await writeFile(targetPath, templateContent)
console.log(` ${relPath}: ${green}updated${reset}`)
allSynced = false
} else {
await writeFile(targetPath, templateContent)
console.log(` ${relPath}: ${green}created${reset}`)
allSynced = false
}
}
}
if (allSynced) {
console.log(` ${green}in sync${reset}`)
}
}
}