feat(regime)
This commit is contained in:
commit
2be7ec6836
62 changed files with 1817 additions and 0 deletions
128
src/check.ts
Normal file
128
src/check.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { dirname, relative, join } from "node:path"
|
||||
import { existsSync } from "node:fs"
|
||||
import {
|
||||
type RegimeConfig,
|
||||
findRegimeConfigs,
|
||||
resolveTemplateChain,
|
||||
getStrategy,
|
||||
interpolate,
|
||||
readFileSync,
|
||||
diffJson,
|
||||
mergeTemplateJsonFiles,
|
||||
mergeTemplateJsoncFiles,
|
||||
parseJsonc,
|
||||
} 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, full = false): 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 targetRelPath = interpolate(relPath, vars)
|
||||
const targetPath = join(rcDir, targetRelPath)
|
||||
const strategy = getStrategy(targetRelPath, patterns)
|
||||
|
||||
if (!existsSync(targetPath)) {
|
||||
console.log(` ${targetRelPath}: ${red}missing${reset}`)
|
||||
synced = false
|
||||
continue
|
||||
}
|
||||
|
||||
const existingContent = readFileSync(targetPath)
|
||||
|
||||
if (strategy === "merge json") {
|
||||
try {
|
||||
const templateObj = mergeTemplateJsonFiles(templatePaths, vars, targetRelPath)
|
||||
const existingObj = JSON.parse(existingContent)
|
||||
const entries = diffJson(templateObj, existingObj, full)
|
||||
const diffs = entries.filter(e => !e.ok)
|
||||
|
||||
if (diffs.length > 0 || (full && entries.length > 0)) {
|
||||
if (diffs.length > 0) synced = false
|
||||
console.log(` ${targetRelPath}:`)
|
||||
for (const d of entries) {
|
||||
if (d.ok) {
|
||||
console.log(` ${d.field}: ${green}ok${reset}`)
|
||||
} else {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
} else if (full) {
|
||||
console.log(` ${targetRelPath}: ${green}ok${reset}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` ${targetRelPath}: ${red}failed to parse JSON${reset} - ${e}`)
|
||||
synced = false
|
||||
}
|
||||
} else if (strategy === "merge jsonc") {
|
||||
try {
|
||||
const templateObj = mergeTemplateJsoncFiles(templatePaths, vars, targetRelPath)
|
||||
const existingObj = parseJsonc(existingContent)
|
||||
const entries = diffJson(templateObj, existingObj, full)
|
||||
const diffs = entries.filter(e => !e.ok)
|
||||
|
||||
if (diffs.length > 0 || (full && entries.length > 0)) {
|
||||
if (diffs.length > 0) synced = false
|
||||
console.log(` ${targetRelPath}:`)
|
||||
for (const d of entries) {
|
||||
if (d.ok) {
|
||||
console.log(` ${d.field}: ${green}ok${reset}`)
|
||||
} else {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
} else if (full) {
|
||||
console.log(` ${targetRelPath}: ${green}ok${reset}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` ${targetRelPath}: ${red}failed to parse JSONC${reset} - ${e}`)
|
||||
synced = false
|
||||
}
|
||||
} else if (strategy === "overwrite") {
|
||||
const templateContent = interpolate(readFileSync(templatePaths[templatePaths.length - 1]), vars, targetRelPath)
|
||||
if (existingContent !== templateContent) {
|
||||
console.log(` ${targetRelPath}: ${orange}differs${reset}`)
|
||||
synced = false
|
||||
} else if (full) {
|
||||
console.log(` ${targetRelPath}: ${green}ok${reset}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (synced) {
|
||||
console.log(` ${green}in sync${reset}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
170
src/promote.ts
Normal file
170
src/promote.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
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 // interpolated relative path (e.g. "tsconfig.json")
|
||||
templateRelPath: string // raw template path (may contain <<var>> placeholders)
|
||||
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 targetRelPath = interpolate(relPath, vars)
|
||||
const strategy = getStrategy(targetRelPath, patterns)
|
||||
if (strategy !== "overwrite") continue
|
||||
|
||||
const repoPath = join(rcDir, targetRelPath)
|
||||
if (!existsSync(repoPath)) continue
|
||||
|
||||
const repoContent = readFileSync(repoPath)
|
||||
const templateContent = interpolate(
|
||||
readFileSync(templatePaths[templatePaths.length - 1]),
|
||||
vars,
|
||||
targetRelPath,
|
||||
)
|
||||
|
||||
if (repoContent !== templateContent) {
|
||||
promotable.push({
|
||||
relPath: targetRelPath,
|
||||
templateRelPath: 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.templateRelPath)
|
||||
|
||||
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 proc = Bun.spawn(["gum", "confirm", "Write to template?"], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
if (await proc.exited !== 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}`)
|
||||
}
|
||||
356
src/shared.ts
Normal file
356
src/shared.ts
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
import { resolve, join, dirname } from "node:path"
|
||||
import { existsSync } from "node:fs"
|
||||
import { readdir } from "node:fs/promises"
|
||||
import { Glob } from "bun"
|
||||
import { parse as parseJsonc } from "jsonc-parser"
|
||||
export { parseJsonc }
|
||||
|
||||
// --- 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 deepEqual(a: any, b: any): boolean {
|
||||
if (a === b) return true
|
||||
if (typeof a !== typeof b) return false
|
||||
if (a === null || b === null) return a === b
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((item: any, i: number) => deepEqual(item, b[i]))
|
||||
}
|
||||
if (typeof a === "object") {
|
||||
const keysA = Object.keys(a)
|
||||
const keysB = Object.keys(b)
|
||||
if (keysA.length !== keysB.length) return false
|
||||
return keysA.every(k => k in b && deepEqual(a[k], b[k]))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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 discovery ---
|
||||
|
||||
export function findAllTemplateNames(): string[] {
|
||||
const fs = require("fs")
|
||||
const results: string[] = []
|
||||
|
||||
function walk(dir: string, prefix: string) {
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (prefix && existsSync(join(dir, ".regime-template.json"))) {
|
||||
results.push(prefix)
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const childName = prefix ? `${prefix}/${entry.name}` : entry.name
|
||||
walk(join(dir, entry.name), childName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(templatesDir, "")
|
||||
return results.sort()
|
||||
}
|
||||
|
||||
// --- 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 (Array.isArray(overlay) && Array.isArray(base)) {
|
||||
const result = [...overlay]
|
||||
for (const item of base) {
|
||||
if (!result.some(o => deepEqual(o, item))) {
|
||||
result.push(item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
if (typeof base !== "object" || base === null) return overlay
|
||||
if (typeof overlay !== "object" || overlay === null) 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
|
||||
}
|
||||
|
||||
// --- Merge all template JSONC files into one combined object ---
|
||||
|
||||
export function mergeTemplateJsoncFiles(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, parseJsonc(content))
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// --- JSONC stringify (JSON with trailing commas) ---
|
||||
|
||||
export function stringifyJsonc(obj: any, indent: string): string {
|
||||
return jsonWithTrailingCommas(obj, indent, 0) + "\n"
|
||||
}
|
||||
|
||||
function jsonWithTrailingCommas(value: any, indent: string, depth: number): string {
|
||||
if (value === null) return "null"
|
||||
if (typeof value === "boolean" || typeof value === "number") return JSON.stringify(value)
|
||||
if (typeof value === "string") return JSON.stringify(value)
|
||||
|
||||
const currentIndent = indent.repeat(depth + 1)
|
||||
const closingIndent = indent.repeat(depth)
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return "[]"
|
||||
const items = value.map(item => `${currentIndent}${jsonWithTrailingCommas(item, indent, depth + 1)},`)
|
||||
return `[\n${items.join("\n")}\n${closingIndent}]`
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
const keys = Object.keys(value)
|
||||
if (keys.length === 0) return "{}"
|
||||
const entries = keys.map(key =>
|
||||
`${currentIndent}${JSON.stringify(key)}: ${jsonWithTrailingCommas(value[key], indent, depth + 1)},`
|
||||
)
|
||||
return `{\n${entries.join("\n")}\n${closingIndent}}`
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// --- Indentation detection ---
|
||||
|
||||
export function detectIndent(content: string): string {
|
||||
const match = content.match(/^(\s+)/m)
|
||||
return match?.[1] ?? " "
|
||||
}
|
||||
|
||||
// --- Diff reporting ---
|
||||
|
||||
export interface DiffEntry {
|
||||
field: string
|
||||
expected: any
|
||||
actual: any
|
||||
ok: boolean
|
||||
}
|
||||
|
||||
export function diffJson(
|
||||
templateObj: any,
|
||||
existingObj: any,
|
||||
full = false,
|
||||
path: string[] = [],
|
||||
): DiffEntry[] {
|
||||
const results: DiffEntry[] = []
|
||||
|
||||
for (const key of Object.keys(templateObj)) {
|
||||
const fieldPath = [...path, key].join(".")
|
||||
const expected = templateObj[key]
|
||||
const actual = existingObj?.[key]
|
||||
|
||||
if (actual === undefined) {
|
||||
results.push({ field: fieldPath, expected, actual: undefined, ok: false })
|
||||
} else if (
|
||||
typeof expected === "object" &&
|
||||
expected !== null &&
|
||||
!Array.isArray(expected) &&
|
||||
typeof actual === "object" &&
|
||||
actual !== null &&
|
||||
!Array.isArray(actual)
|
||||
) {
|
||||
results.push(...diffJson(expected, actual, full, [...path, key]))
|
||||
} else if (Array.isArray(expected) && Array.isArray(actual)) {
|
||||
const missing = expected.filter(
|
||||
(e: any) => !actual.some((a: any) => deepEqual(a, e))
|
||||
)
|
||||
if (missing.length > 0) {
|
||||
results.push({ field: fieldPath, expected, actual, ok: false })
|
||||
} else if (full) {
|
||||
results.push({ field: fieldPath, expected, actual, ok: true })
|
||||
}
|
||||
} else if (JSON.stringify(expected) !== JSON.stringify(actual)) {
|
||||
results.push({ field: fieldPath, expected, actual, ok: false })
|
||||
} else if (full) {
|
||||
results.push({ field: fieldPath, expected, actual, ok: true })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// --- 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
|
||||
}
|
||||
192
src/sync.ts
Normal file
192
src/sync.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
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,
|
||||
mergeTemplateJsoncFiles,
|
||||
stringifyJsonc,
|
||||
parseJsonc,
|
||||
detectIndent,
|
||||
} from "./shared"
|
||||
|
||||
const green = Bun.color("green", "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 targetRelPath = interpolate(relPath, vars)
|
||||
const targetPath = join(rcDir, targetRelPath)
|
||||
const strategy = getStrategy(targetRelPath, patterns)
|
||||
|
||||
// Ensure target directory exists
|
||||
const targetFileDir = dirname(targetPath)
|
||||
if (!existsSync(targetFileDir)) {
|
||||
require("fs").mkdirSync(targetFileDir, { recursive: true })
|
||||
}
|
||||
|
||||
if (strategy === "merge json") {
|
||||
let templateObj: any
|
||||
try {
|
||||
templateObj = mergeTemplateJsonFiles(templatePaths, vars, targetRelPath)
|
||||
} catch (e) {
|
||||
console.log(` ${targetRelPath}: ${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(` ${targetRelPath}: ${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(` ${targetRelPath}: ${green}updated${reset}`)
|
||||
allSynced = false
|
||||
} else {
|
||||
const content = JSON.stringify(templateObj, null, " ") + "\n"
|
||||
await writeFile(targetPath, content)
|
||||
console.log(` ${targetRelPath}: ${green}created${reset}`)
|
||||
allSynced = false
|
||||
}
|
||||
} else if (strategy === "merge jsonc") {
|
||||
let templateObj: any
|
||||
try {
|
||||
templateObj = mergeTemplateJsoncFiles(templatePaths, vars, targetRelPath)
|
||||
} catch (e) {
|
||||
console.log(` ${targetRelPath}: ${red}failed to parse template JSONC${reset} - ${e}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (existsSync(targetPath)) {
|
||||
const existingContent = readFileSync(targetPath)
|
||||
let existingObj: any
|
||||
try {
|
||||
existingObj = parseJsonc(existingContent)
|
||||
} catch (e) {
|
||||
console.log(` ${targetRelPath}: ${red}failed to parse existing JSONC${reset} - ${e}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const merged = deepMerge(existingObj, templateObj)
|
||||
const indent = detectIndent(existingContent)
|
||||
const mergedContent = stringifyJsonc(merged, indent)
|
||||
|
||||
if (mergedContent === existingContent) {
|
||||
// already in sync
|
||||
continue
|
||||
}
|
||||
|
||||
await writeFile(targetPath, mergedContent)
|
||||
console.log(` ${targetRelPath}: ${green}updated${reset}`)
|
||||
allSynced = false
|
||||
} else {
|
||||
const content = stringifyJsonc(templateObj, "\t")
|
||||
await writeFile(targetPath, content)
|
||||
console.log(` ${targetRelPath}: ${green}created${reset}`)
|
||||
allSynced = false
|
||||
}
|
||||
|
||||
if (existsSync(targetPath)) {
|
||||
const existingContent = readFileSync(targetPath)
|
||||
let existingObj: any
|
||||
try {
|
||||
existingObj = parseJsonc(existingContent)
|
||||
} catch (e) {
|
||||
console.log(` ${targetRelPath}: ${red}failed to parse existing JSON5${reset} - ${e}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const merged = deepMerge(existingObj, templateObj)
|
||||
const indent = detectIndent(existingContent)
|
||||
const mergedContent = stringifyJsonc(merged, indent)
|
||||
|
||||
if (mergedContent === existingContent) {
|
||||
// already in sync
|
||||
continue
|
||||
}
|
||||
|
||||
await writeFile(targetPath, mergedContent)
|
||||
console.log(` ${targetRelPath}: ${green}updated${reset}`)
|
||||
allSynced = false
|
||||
} else {
|
||||
const content = stringifyJsonc(templateObj, "\t")
|
||||
await writeFile(targetPath, content)
|
||||
console.log(` ${targetRelPath}: ${green}created${reset}`)
|
||||
allSynced = false
|
||||
}
|
||||
} else if (strategy === "overwrite") {
|
||||
const templateContent = interpolate(
|
||||
readFileSync(templatePaths[templatePaths.length - 1]),
|
||||
vars,
|
||||
targetRelPath,
|
||||
)
|
||||
|
||||
if (existsSync(targetPath)) {
|
||||
const existingContent = readFileSync(targetPath)
|
||||
if (existingContent === templateContent) {
|
||||
// already in sync
|
||||
continue
|
||||
}
|
||||
await writeFile(targetPath, templateContent)
|
||||
console.log(` ${targetRelPath}: ${green}updated${reset}`)
|
||||
allSynced = false
|
||||
} else {
|
||||
await writeFile(targetPath, templateContent)
|
||||
console.log(` ${targetRelPath}: ${green}created${reset}`)
|
||||
allSynced = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allSynced) {
|
||||
console.log(` ${green}in sync${reset}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/templates.ts
Normal file
57
src/templates.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { join } from "node:path"
|
||||
import { templatesDir, resolveTemplateConfig, readdirSyncRecursive, findAllTemplateNames } from "./shared"
|
||||
|
||||
const purple = Bun.color("green", "ansi")
|
||||
const reset = "\x1b[0m"
|
||||
|
||||
interface TreeNode {
|
||||
name: string
|
||||
children: TreeNode[]
|
||||
files: string[]
|
||||
}
|
||||
|
||||
function buildTree(name: string, visited = new Set<string>()): TreeNode {
|
||||
if (visited.has(name)) return { name, children: [], files: [] }
|
||||
visited.add(name)
|
||||
|
||||
const config = resolveTemplateConfig(name)
|
||||
const children = (config.inherits ?? []).map(p => buildTree(p, visited))
|
||||
|
||||
const dir = join(templatesDir, name)
|
||||
const files = readdirSyncRecursive(dir).filter(f => f !== ".regime-template.json")
|
||||
|
||||
return { name, children, files }
|
||||
}
|
||||
|
||||
function printTree(node: TreeNode, full: boolean, prefix = "", isLast = true, isRoot = true) {
|
||||
const connector = isRoot ? "" : isLast ? "└── " : "├── "
|
||||
const line = isRoot ? node.name : `${prefix}${connector}${node.name}`
|
||||
console.log(line)
|
||||
|
||||
const childPrefix = isRoot ? "" : prefix + (isLast ? " " : "│ ")
|
||||
|
||||
if (full && node.files.length > 0) {
|
||||
const hasChildren = node.children.length > 0
|
||||
for (let i = 0; i < node.files.length; i++) {
|
||||
const fileConnector = hasChildren || i < node.files.length - 1 ? "│ " : " "
|
||||
const bullet = "·"
|
||||
console.log(`${childPrefix}${fileConnector}${purple}${bullet} ${node.files[i]}${reset}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
const child = node.children[i]
|
||||
const last = i === node.children.length - 1
|
||||
printTree(child, full, childPrefix, last, false)
|
||||
}
|
||||
}
|
||||
|
||||
export function templates(full: boolean) {
|
||||
const entries = findAllTemplateNames()
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const tree = buildTree(entries[i])
|
||||
printTree(tree, full)
|
||||
if (i < entries.length - 1) console.log()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue