Merge pull request #11124 from Kilo-Org/clover-tuck

fix: prevent Bun downgrades during upstream merges
This commit is contained in:
Catriel Müller
2026-06-11 11:46:41 -03:00
committed by GitHub
5 changed files with 148 additions and 3 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.13",
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:storybook": "bun --cwd packages/storybook storybook",
+5 -1
View File
@@ -48,7 +48,7 @@ bun run merge.ts --version v1.1.50 --base-branch catrielmuller/kilo-opencode-v1.
| `transforms/skip-files.ts` | Skip/remove files that shouldn't exist in Kilo |
| `transforms/transform-i18n.ts` | Transform i18n files with Kilo branding |
| `transforms/transform-take-theirs.ts` | Take upstream + apply Kilo branding for branding-only files |
| `transforms/transform-package-json.ts` | Enhanced package.json with Kilo dependency injection |
| `transforms/transform-package-json.ts` | Enhanced package.json with Kilo dependency injection and newest-Bun-wins reconciliation |
| `transforms/transform-scripts.ts` | Transform script files with GitHub API references |
| `transforms/transform-extensions.ts` | Transform extension files (Zed, etc.) |
| `transforms/transform-web.ts` | Transform web/docs files (.mdx) |
@@ -196,6 +196,10 @@ Now:
The only remaining conflicts are files with **actual code differences** - files with `kilocode_change` markers that contain Kilo-specific logic.
### Bun Version Safety
Root `package.json` reconciliation uses the newer valid `packageManager` Bun version from Kilo and upstream. An older upstream version cannot downgrade Kilo, while a newer upstream version is retained as an upgrade. Before the merge is finalized, `merge.ts` also validates the result against the pristine Kilo base and upstream commit and aborts if the merged Bun version is lower than either input.
## CLI Options
### merge.ts
+24
View File
@@ -37,6 +37,7 @@ import {
transformConflictedPackageJson,
transformAllPackageJson,
reconcileAllPackageJson,
assertBunPackageManager,
} from "./transforms/transform-package-json"
import { transformConflictedScripts, transformAllScripts } from "./transforms/transform-scripts"
import { transformConflictedExtensions, transformAllExtensions } from "./transforms/transform-extensions"
@@ -206,6 +207,27 @@ async function getAuthor(): Promise<string> {
.replace(/\s+/g, "")
}
function manager(content: string): string | undefined {
const pkg: unknown = JSON.parse(content)
if (!pkg || typeof pkg !== "object" || !("packageManager" in pkg)) return undefined
return typeof pkg.packageManager === "string" ? pkg.packageManager : undefined
}
async function managerAt(ref: string): Promise<string | undefined> {
const result = await $`git show ${ref}:package.json`.quiet().nothrow()
if (result.exitCode === 0) return manager(result.stdout.toString())
logger.warn(`Could not read package.json at ${ref}; excluding it from Bun packageManager validation`)
return undefined
}
async function validateBun(base: string, upstream: string): Promise<void> {
const current = manager(await Bun.file("package.json").text())
const ours = await managerAt(base)
const theirs = await managerAt(upstream)
assertBunPackageManager(current, ours, theirs)
logger.success(`Validated Bun packageManager: ${current ?? "missing"}`)
}
async function createBackupBranch(baseBranch: string): Promise<string> {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
const backupName = `backup/${baseBranch}-${timestamp}`
@@ -807,6 +829,7 @@ async function main() {
// Exit early - don't continue to finalization steps
process.exit(1)
} else {
await validateBun(baseSha, targetVersion.commit)
await git.stageAll()
await git.commit(`merge: upstream ${targetVersion.tag}`)
logger.success("Merge completed - all conflicts auto-resolved!")
@@ -824,6 +847,7 @@ async function main() {
if (reconcileCount > 0) {
logger.success(`Reconciled ${reconcileCount} package.json file(s) post-merge`)
}
await validateBun(baseSha, targetVersion.commit)
await git.stageAll()
const hasChanges = await git.hasUncommittedChanges()
if (hasChanges) {
@@ -1,5 +1,13 @@
import { expect, test } from "bun:test"
import { fixCatalog, fixMetadata, fixScripts, mergeWithNewestVersions } from "./transform-package-json"
import {
assertBunPackageManager,
fixCatalog,
fixMetadata,
fixPackageManager,
fixScripts,
mergeWithNewestVersions,
selectBunPackageManager,
} from "./transform-package-json"
test("fixScripts preserves Kilo-only root scripts from base", () => {
const ours = {
@@ -125,3 +133,62 @@ test("mergeWithNewestVersions appends theirs-only keys at the end", () => {
const result = mergeWithNewestVersions(ours, theirs, changes, "dependencies")
expect(Object.keys(result)).toEqual(["a", "b", "c"])
})
test("selectBunPackageManager keeps the newer Bun version and prefers Kilo on ties", () => {
expect(selectBunPackageManager("bun@1.3.14", "bun@1.3.13")).toBe("bun@1.3.14")
expect(selectBunPackageManager("bun@1.3.14", "bun@1.3.15")).toBe("bun@1.3.15")
expect(selectBunPackageManager("bun@1.3.14+kilo", "bun@1.3.14+upstream")).toBe("bun@1.3.14+kilo")
})
test("selectBunPackageManager preserves valid versions over malformed values", () => {
expect(selectBunPackageManager("bun@1.3.14", "bun@latest")).toBe("bun@1.3.14")
expect(selectBunPackageManager("bun@latest", "bun@1.3.15")).toBe("bun@1.3.15")
expect(selectBunPackageManager("bun@latest", "npm@11.0.0")).toBeUndefined()
})
test("fixPackageManager prevents root Bun downgrades", () => {
const pkg: Record<string, unknown> = { packageManager: "bun@1.3.13" }
const ours = { packageManager: "bun@1.3.14" }
const changes: string[] = []
fixPackageManager(pkg, "package.json", ours, changes)
expect(pkg.packageManager).toBe("bun@1.3.14")
expect(changes).toEqual(["packageManager: bun@1.3.13 -> bun@1.3.14 (preserved Kilo pin)"])
})
test("fixPackageManager restores a valid Kilo pin over malformed upstream", () => {
const pkg: Record<string, unknown> = { packageManager: "bun@latest" }
const changes: string[] = []
fixPackageManager(pkg, "package.json", { packageManager: "bun@1.3.14" }, changes)
expect(pkg.packageManager).toBe("bun@1.3.14")
expect(changes).toEqual(["packageManager: bun@latest -> bun@1.3.14 (preserved Kilo pin)"])
})
test("fixPackageManager accepts upstream Bun upgrades", () => {
const pkg: Record<string, unknown> = { packageManager: "bun@1.3.15" }
const changes: string[] = []
fixPackageManager(pkg, "package.json", { packageManager: "bun@1.3.14" }, changes)
expect(pkg.packageManager).toBe("bun@1.3.15")
expect(changes).toEqual([])
})
test("fixPackageManager ignores nested package.json files", () => {
const pkg: Record<string, unknown> = { packageManager: "bun@1.3.13" }
const changes: string[] = []
fixPackageManager(pkg, "packages/opencode/package.json", { packageManager: "bun@1.3.14" }, changes)
expect(pkg.packageManager).toBe("bun@1.3.13")
expect(changes).toEqual([])
})
test("assertBunPackageManager rejects merged downgrades and invalid values", () => {
expect(() => assertBunPackageManager("bun@1.3.13", "bun@1.3.14", "bun@1.3.12")).toThrow(
"Bun packageManager downgrade detected",
)
expect(() => assertBunPackageManager("bun@latest", "bun@1.3.14", "bun@1.3.15")).toThrow(
"Bun packageManager validation failed",
)
})
test("assertBunPackageManager accepts the newest input or a newer result", () => {
expect(() => assertBunPackageManager("bun@1.3.15", "bun@1.3.14", "bun@1.3.15")).not.toThrow()
expect(() => assertBunPackageManager("bun@1.3.16", "bun@1.3.14", "bun@1.3.15")).not.toThrow()
})
@@ -105,6 +105,50 @@ function compareVersions(a: string, b: string): number | null {
return 0
}
function bun(value: unknown): { value: string; version: string } | null {
if (typeof value !== "string") return null
const match = value.match(/^bun@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/)
if (!match) return null
return { value, version: match[1] }
}
export function selectBunPackageManager(ours: unknown, theirs: unknown): string | undefined {
const left = bun(ours)
const right = bun(theirs)
if (left && right) return compareVersions(left.version, right.version)! >= 0 ? left.value : right.value
if (left) return left.value
if (right) return right.value
return undefined
}
export function fixPackageManager(
pkg: Record<string, unknown>,
path: string,
ours: Record<string, unknown> | null,
changes: string[],
): void {
if (path !== "package.json") return
const next = selectBunPackageManager(ours?.packageManager, pkg.packageManager)
if (!next || pkg.packageManager === next) return
const prior = typeof pkg.packageManager === "string" ? pkg.packageManager : "missing or invalid"
changes.push(`packageManager: ${prior} -> ${next} (preserved Kilo pin)`)
pkg.packageManager = next
}
export function assertBunPackageManager(current: unknown, base: unknown, upstream: unknown): void {
const inputs = [bun(base), bun(upstream)].filter((item): item is NonNullable<typeof item> => item !== null)
if (inputs.length === 0) return
const required = inputs.reduce((max, item) => (compareVersions(item.version, max.version)! > 0 ? item : max))
const actual = bun(current)
if (!actual) {
throw new Error(
`Bun packageManager validation failed: merged value is invalid; expected at least ${required.value}`,
)
}
if (compareVersions(actual.version, required.version)! >= 0) return
throw new Error(`Bun packageManager downgrade detected: merged ${actual.value}, expected at least ${required.value}`)
}
/**
* Merge two dependency objects using "newest wins" strategy
* For non-comparable versions (URLs, catalog:, workspace:*), upstream (theirs) wins
@@ -392,6 +436,8 @@ export async function transformPackageJson(file: string, options: PackageJsonOpt
pkg.name = newName
}
fixPackageManager(pkg, relativePath, ourPkg, changes)
// 2. Preserve Kilo version if requested
if (options.preserveVersion !== false) {
const kiloVersion = await getCurrentVersion()
@@ -612,6 +658,8 @@ export async function transformAllPackageJson(options: PackageJsonOptions = {}):
pkg.name = newName
}
fixPackageManager(pkg, path, kiloPkg, changes)
// 2. Preserve Kilo version if requested
if (options.preserveVersion !== false) {
const kiloVersion = await getCurrentVersion()
@@ -824,6 +872,8 @@ export async function reconcilePackageJsonFromRefs(
pkg.name = newName
}
fixPackageManager(pkg, relativePath, ourPkg, changes)
if (options.preserveVersion !== false) {
const kiloVersion = await getCurrentVersion()
if (pkg.version !== kiloVersion) {