diff --git a/package.json b/package.json index 96965ca3635..4a60f5b96d7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/script/upstream/README.md b/script/upstream/README.md index 2024a11ad6b..0423f0dd886 100644 --- a/script/upstream/README.md +++ b/script/upstream/README.md @@ -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 diff --git a/script/upstream/merge.ts b/script/upstream/merge.ts index 0e1d9a5f1ec..d6eae52ee2e 100644 --- a/script/upstream/merge.ts +++ b/script/upstream/merge.ts @@ -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 { .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 { + 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 { + 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 { 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) { diff --git a/script/upstream/transforms/transform-package-json.test.ts b/script/upstream/transforms/transform-package-json.test.ts index 60e6d46e09f..0efa2feaeb3 100644 --- a/script/upstream/transforms/transform-package-json.test.ts +++ b/script/upstream/transforms/transform-package-json.test.ts @@ -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 = { 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 = { 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 = { 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 = { 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() +}) diff --git a/script/upstream/transforms/transform-package-json.ts b/script/upstream/transforms/transform-package-json.ts index 20fa0b280a3..8061f16e41b 100644 --- a/script/upstream/transforms/transform-package-json.ts +++ b/script/upstream/transforms/transform-package-json.ts @@ -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, + path: string, + ours: Record | 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 => 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) {