transform-take-theirs.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. #!/usr/bin/env bun
  2. /**
  3. * Transform files by taking upstream version and applying Kilo branding
  4. *
  5. * This script handles files that have only branding differences (no logic changes).
  6. * It takes the upstream version and applies Kilo branding transforms.
  7. *
  8. * Use this for:
  9. * - UI components with OpenCode -> Kilo branding
  10. * - Config files with predictable patterns
  11. * - Files without kilocode_change logic blocks
  12. */
  13. import { $ } from "bun"
  14. import { info, success, warn, debug } from "../utils/logger"
  15. import { defaultConfig } from "../utils/config"
  16. import { oursHasKilocodeChanges } from "../utils/git"
  17. export interface TakeTheirsResult {
  18. file: string
  19. action: "transformed" | "skipped" | "failed" | "flagged"
  20. replacements: number
  21. dryRun: boolean
  22. }
  23. export interface TakeTheirsOptions {
  24. dryRun?: boolean
  25. verbose?: boolean
  26. patterns?: string[]
  27. }
  28. interface BrandingReplacement {
  29. pattern: RegExp
  30. replacement: string
  31. description: string
  32. }
  33. // Branding replacements - order matters (specific patterns first)
  34. const BRANDING_REPLACEMENTS: BrandingReplacement[] = [
  35. // GitHub repo references
  36. {
  37. pattern: /github\.com\/anomalyco\/opencode/g,
  38. replacement: "github.com/Kilo-Org/kilocode",
  39. description: "GitHub URL",
  40. },
  41. {
  42. pattern: /anomalyco\/opencode/g,
  43. replacement: "Kilo-Org/kilocode",
  44. description: "GitHub repo reference",
  45. },
  46. // Domain replacements (specific first)
  47. {
  48. pattern: /app\.opencode\.ai/g,
  49. replacement: "app.kilo.ai",
  50. description: "App domain",
  51. },
  52. {
  53. pattern: /opencode\.ai(?!\/zen)/g,
  54. replacement: "kilo.ai",
  55. description: "Main domain (excluding zen)",
  56. },
  57. // Product name (specific phrases first)
  58. {
  59. pattern: /OpenCode Desktop/g,
  60. replacement: "Kilo Desktop",
  61. description: "Desktop app name",
  62. },
  63. // CLI commands
  64. {
  65. pattern: /npx opencode(?!\w)/g,
  66. replacement: "npx kilo",
  67. description: "npx command",
  68. },
  69. {
  70. pattern: /bun add opencode(?!\w)/g,
  71. replacement: "bun add kilo",
  72. description: "bun add command",
  73. },
  74. {
  75. pattern: /npm install opencode(?!\w)/g,
  76. replacement: "npm install kilo",
  77. description: "npm install command",
  78. },
  79. {
  80. pattern: /opencode upgrade(?!\w)/g,
  81. replacement: "kilo upgrade",
  82. description: "upgrade command",
  83. },
  84. // Database filename
  85. {
  86. pattern: /opencode\.db/g,
  87. replacement: "kilo.db",
  88. description: "Database filename",
  89. },
  90. // Generic product name replacement (must come after specific patterns)
  91. // Only replace "OpenCode" when it's a standalone word
  92. {
  93. pattern: /\bOpenCode\b(?!\.json|\/| Zen)/g,
  94. replacement: "Kilo",
  95. description: "Product name",
  96. },
  97. // Environment variables (exclude OPENCODE_API_KEY)
  98. {
  99. pattern: /\bOPENCODE_(?!API_KEY\b)([A-Z_]+)\b/g,
  100. replacement: "KILO_$1",
  101. description: "Environment variable",
  102. },
  103. {
  104. pattern: /VITE_OPENCODE_/g,
  105. replacement: "VITE_KILO_",
  106. description: "Vite env var",
  107. },
  108. {
  109. pattern: /window\.__OPENCODE__/g,
  110. replacement: "window.__KILO__",
  111. description: "Window global",
  112. },
  113. {
  114. pattern: /x-opencode-/g,
  115. replacement: "x-kilo-",
  116. description: "HTTP header prefix",
  117. },
  118. {
  119. pattern: /_EXTENSION_OPENCODE_/g,
  120. replacement: "_EXTENSION_KILO_",
  121. description: "Extension env var",
  122. },
  123. ]
  124. // Patterns that should NOT be replaced (preserved as-is)
  125. const PRESERVE_PATTERNS = [
  126. /opencode\.json/g, // Config filename
  127. /\.opencode\//g, // Directory name
  128. /\.opencode`/g, // Directory name in template strings
  129. /"\.opencode"/g, // Directory name in quotes
  130. /'\.opencode'/g, // Directory name in single quotes
  131. /\/\/\s*kilocode_change/g, // Already has marker
  132. ]
  133. /**
  134. * Check if a file matches any of the patterns
  135. */
  136. export function matchesPattern(file: string, patterns: string[]): boolean {
  137. return patterns.some((pattern) => {
  138. // Convert glob pattern to regex
  139. const regex = new RegExp("^" + pattern.replace(/\./g, "\\.").replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*") + "$")
  140. return regex.test(file)
  141. })
  142. }
  143. /**
  144. * Apply branding transforms to content
  145. */
  146. export function applyBrandingTransforms(content: string, verbose = false): { result: string; replacements: number } {
  147. const lines = content.split("\n")
  148. const transformed: string[] = []
  149. let total = 0
  150. for (const line of lines) {
  151. // Skip lines with kilocode_change marker (already customized)
  152. if (line.includes("// kilocode_change")) {
  153. transformed.push(line)
  154. continue
  155. }
  156. // Check if line has preserve patterns
  157. let hasPreserve = false
  158. for (const pattern of PRESERVE_PATTERNS) {
  159. pattern.lastIndex = 0
  160. if (pattern.test(line)) {
  161. hasPreserve = true
  162. pattern.lastIndex = 0
  163. }
  164. }
  165. let result = line
  166. let count = 0
  167. // Apply replacements
  168. for (const { pattern, replacement, description } of BRANDING_REPLACEMENTS) {
  169. pattern.lastIndex = 0
  170. if (pattern.test(result)) {
  171. pattern.lastIndex = 0
  172. const before = result
  173. result = result.replace(pattern, replacement)
  174. if (before !== result) {
  175. count++
  176. if (verbose) debug(` ${description}: "${before.trim()}" -> "${result.trim()}"`)
  177. }
  178. }
  179. }
  180. transformed.push(result)
  181. total += count
  182. }
  183. return { result: transformed.join("\n"), replacements: total }
  184. }
  185. /**
  186. * Take upstream version of a file and apply branding transforms
  187. */
  188. export async function transformTakeTheirs(file: string, options: TakeTheirsOptions = {}): Promise<TakeTheirsResult> {
  189. if (options.dryRun) {
  190. info(`[DRY-RUN] Would take theirs and transform: ${file}`)
  191. return { file, action: "transformed", replacements: 0, dryRun: true }
  192. }
  193. // If our version has kilocode_change markers, flag for manual resolution
  194. if (await oursHasKilocodeChanges(file)) {
  195. warn(`${file} has kilocode_change markers — skipping auto-transform, needs manual resolution`)
  196. return { file, action: "flagged", replacements: 0, dryRun: false }
  197. }
  198. try {
  199. // Take upstream's version
  200. await $`git checkout --theirs ${file}`.quiet().nothrow()
  201. await $`git add ${file}`.quiet().nothrow()
  202. // Read the file
  203. const content = await Bun.file(file).text()
  204. // Apply branding transforms
  205. const { result, replacements } = applyBrandingTransforms(content, options.verbose)
  206. // Write back
  207. if (replacements > 0) {
  208. await Bun.write(file, result)
  209. await $`git add ${file}`.quiet().nothrow()
  210. }
  211. success(`Transformed ${file}: took upstream + ${replacements} branding replacements`)
  212. return { file, action: "transformed", replacements, dryRun: false }
  213. } catch (err) {
  214. warn(`Failed to transform ${file}: ${err}`)
  215. return { file, action: "failed", replacements: 0, dryRun: false }
  216. }
  217. }
  218. /**
  219. * Transform multiple files that are in conflict
  220. */
  221. export async function transformConflictedTakeTheirs(
  222. files: string[],
  223. options: TakeTheirsOptions = {},
  224. ): Promise<TakeTheirsResult[]> {
  225. const results: TakeTheirsResult[] = []
  226. const patterns = options.patterns || defaultConfig.takeTheirsAndTransform
  227. for (const file of files) {
  228. if (!matchesPattern(file, patterns)) {
  229. debug(`Skipping ${file} - doesn't match take-theirs patterns`)
  230. results.push({ file, action: "skipped", replacements: 0, dryRun: options.dryRun ?? false })
  231. continue
  232. }
  233. const result = await transformTakeTheirs(file, options)
  234. results.push(result)
  235. }
  236. return results
  237. }
  238. /**
  239. * Check if a file should use take-theirs strategy
  240. */
  241. export function shouldTakeTheirs(file: string, patterns?: string[]): boolean {
  242. const p = patterns || defaultConfig.takeTheirsAndTransform
  243. return matchesPattern(file, p)
  244. }
  245. /**
  246. * Transform all files matching take-theirs patterns (pre-merge, on opencode branch)
  247. * This applies branding transforms to files that exist on the current branch
  248. */
  249. export async function transformAllTakeTheirs(options: TakeTheirsOptions = {}): Promise<TakeTheirsResult[]> {
  250. const { Glob } = await import("bun")
  251. const results: TakeTheirsResult[] = []
  252. const patterns = options.patterns || defaultConfig.takeTheirsAndTransform
  253. for (const pattern of patterns) {
  254. const glob = new Glob(pattern)
  255. for await (const path of glob.scan({ absolute: false })) {
  256. // Skip if file doesn't exist
  257. const file = Bun.file(path)
  258. if (!(await file.exists())) continue
  259. try {
  260. const content = await file.text()
  261. const { result, replacements } = applyBrandingTransforms(content, options.verbose)
  262. if (replacements > 0 && !options.dryRun) {
  263. await Bun.write(path, result)
  264. success(`Transformed ${path}: ${replacements} branding replacements`)
  265. } else if (options.dryRun && replacements > 0) {
  266. info(`[DRY-RUN] Would transform ${path}: ${replacements} branding replacements`)
  267. }
  268. results.push({ file: path, action: "transformed", replacements, dryRun: options.dryRun ?? false })
  269. } catch (err) {
  270. warn(`Failed to transform ${path}: ${err}`)
  271. results.push({ file: path, action: "failed", replacements: 0, dryRun: options.dryRun ?? false })
  272. }
  273. }
  274. }
  275. return results
  276. }
  277. // CLI entry point
  278. if (import.meta.main) {
  279. const args = process.argv.slice(2)
  280. const dryRun = args.includes("--dry-run")
  281. const verbose = args.includes("--verbose")
  282. const files = args.filter((a) => !a.startsWith("--"))
  283. if (files.length === 0) {
  284. info("Usage: transform-take-theirs.ts [--dry-run] [--verbose] <file1> <file2> ...")
  285. process.exit(1)
  286. }
  287. if (dryRun) {
  288. info("Running in dry-run mode (no files will be modified)")
  289. }
  290. const results = await transformConflictedTakeTheirs(files, { dryRun, verbose })
  291. const transformed = results.filter((r) => r.action === "transformed")
  292. const total = results.reduce((sum, r) => sum + r.replacements, 0)
  293. console.log()
  294. success(`Transformed ${transformed.length} files with ${total} replacements`)
  295. if (dryRun) {
  296. info("Run without --dry-run to apply changes")
  297. }
  298. }