upgrade-opentui.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. #!/usr/bin/env bun
  2. import path from "node:path"
  3. const args = process.argv.slice(2)
  4. const usage = "Usage: bun run script/upgrade-opentui.ts [--snapshot] <version>"
  5. if (args.includes("--help") || args.includes("-h")) {
  6. console.log(usage)
  7. process.exit(0)
  8. }
  9. const snapshotArg = args.find((arg) => arg.startsWith("--snapshot="))
  10. const snapshot = args.includes("--snapshot") || snapshotArg !== undefined
  11. const unknown = args.find((arg) => arg.startsWith("-") && arg !== "--snapshot" && !arg.startsWith("--snapshot="))
  12. if (unknown) {
  13. console.error(`Unknown option: ${unknown}`)
  14. console.error(usage)
  15. process.exit(1)
  16. }
  17. const positional = args.filter((arg) => arg !== "--snapshot" && !arg.startsWith("--snapshot="))
  18. const raw = snapshotArg?.slice("--snapshot=".length) || positional[0]
  19. if (!raw || positional.length > (snapshotArg ? 0 : 1)) {
  20. console.error(usage)
  21. process.exit(1)
  22. }
  23. if (snapshotArg === "--snapshot=") {
  24. console.error("Missing snapshot version")
  25. console.error(usage)
  26. process.exit(1)
  27. }
  28. const ver = raw.replace(/^v/, "")
  29. const root = path.resolve(import.meta.dir, "..")
  30. const lockfile = path.join(root, "bun.lock")
  31. const skip = new Set([".git", ".opencode", ".turbo", "dist", "node_modules"])
  32. const keys = ["@opentui/core", "@opentui/keymap", "@opentui/solid"] as const
  33. const files = (await Array.fromAsync(new Bun.Glob("**/package.json").scan({ cwd: root }))).filter(
  34. (file) => !file.split("/").some((part) => skip.has(part)),
  35. )
  36. const setVersion = (cur: string, kind: "dep" | "peer") => {
  37. if (cur === "catalog:" || cur.startsWith("workspace:")) return cur
  38. if (snapshot) return ver
  39. if (kind === "peer") return `>=${ver}`
  40. if (cur.startsWith(">=")) return `>=${ver}`
  41. if (cur.startsWith("^")) return `^${ver}`
  42. if (cur.startsWith("~")) return `~${ver}`
  43. return ver
  44. }
  45. const editDeps = (obj: unknown, kind: "dep" | "peer") => {
  46. if (!obj || typeof obj !== "object") return false
  47. const map = obj as Record<string, unknown>
  48. return keys
  49. .map((key) => {
  50. const cur = map[key]
  51. if (typeof cur !== "string") return false
  52. const next = setVersion(cur, kind)
  53. if (next === cur) return false
  54. map[key] = next
  55. return true
  56. })
  57. .some(Boolean)
  58. }
  59. const editCatalog = (obj: unknown) => {
  60. if (!obj || typeof obj !== "object") return false
  61. const map = obj as Record<string, unknown>
  62. return keys
  63. .map((key) => {
  64. const cur = map[key]
  65. if (typeof cur !== "string" || cur === ver) return false
  66. map[key] = ver
  67. return true
  68. })
  69. .some(Boolean)
  70. }
  71. const editOverrides = (obj: unknown) => {
  72. if (!obj || typeof obj !== "object") return false
  73. const map = obj as Record<string, unknown>
  74. return keys
  75. .map((key) => {
  76. const cur = map[key]
  77. if (typeof cur !== "string") return false
  78. const next = snapshot ? ver : "catalog:"
  79. if (next === cur) return false
  80. map[key] = next
  81. return true
  82. })
  83. .some(Boolean)
  84. }
  85. const out = (
  86. await Promise.all(
  87. files.map(async (rel) => {
  88. const file = path.join(root, rel)
  89. const txt = await Bun.file(file).text()
  90. const json = JSON.parse(txt)
  91. const hit = [
  92. editCatalog(json.workspaces?.catalog),
  93. editOverrides(json.overrides),
  94. editDeps(json.dependencies, "dep"),
  95. editDeps(json.devDependencies, "dep"),
  96. editDeps(json.peerDependencies, "peer"),
  97. ].some(Boolean)
  98. if (!hit) return null
  99. await Bun.write(file, `${JSON.stringify(json, null, 2)}\n`)
  100. return rel
  101. }),
  102. )
  103. ).filter((item): item is string => item !== null)
  104. if (out.length === 0) {
  105. console.log(`No opentui manifest updates needed for ${ver}`)
  106. }
  107. if (out.length > 0) {
  108. console.log(`Updated opentui${snapshot ? " snapshot" : ""} to ${ver} in:`)
  109. for (const file of out) {
  110. console.log(`- ${file}`)
  111. }
  112. }
  113. console.log("Running bun install to update bun.lock...")
  114. const install = Bun.spawn([process.execPath, "install"], {
  115. cwd: root,
  116. stdout: "inherit",
  117. stderr: "inherit",
  118. })
  119. const installCode = await install.exited
  120. if (installCode !== 0) process.exit(installCode)
  121. const fixed = await fixKnownLockfileIssues()
  122. if (fixed.length > 0) {
  123. console.log("Removed stale opentui-spinner peer lockfile entries:")
  124. for (const item of fixed) {
  125. console.log(`- ${item}`)
  126. }
  127. }
  128. const stale = await findStaleLockfileEntries()
  129. if (stale.length > 0) {
  130. console.error(`bun.lock still contains stale opentui versions after upgrading to ${ver}:`)
  131. for (const item of stale) {
  132. console.error(`- ${item.entry}: ${item.pkg}@${item.version}`)
  133. }
  134. process.exit(1)
  135. }
  136. console.log("bun.lock opentui versions are consistent")
  137. async function fixKnownLockfileIssues() {
  138. const txt = await Bun.file(lockfile).text()
  139. const stale = findStaleLockfileEntriesInText(txt)
  140. if (stale.length === 0) return []
  141. if (stale.some((item) => !item.entry.startsWith("opentui-spinner/@opentui/"))) return []
  142. const lines = txt.split("\n")
  143. const spinnerEntry = /^ "(opentui-spinner\/@opentui\/[^"]+)": \[.*\],\r?$/
  144. const removed = lines
  145. .map((line) => line.match(spinnerEntry)?.[1])
  146. .filter((item): item is string => item !== undefined)
  147. if (removed.length === 0) return []
  148. // Bun separates package records with a blank line, so remove each record's separator with it.
  149. await Bun.write(
  150. lockfile,
  151. lines
  152. .filter(
  153. (line, index) => !spinnerEntry.test(line) && !(line.trim() === "" && spinnerEntry.test(lines[index - 1] ?? "")),
  154. )
  155. .join("\n"),
  156. )
  157. return removed
  158. }
  159. async function findStaleLockfileEntries() {
  160. return findStaleLockfileEntriesInText(await Bun.file(lockfile).text())
  161. }
  162. function findStaleLockfileEntriesInText(txt: string) {
  163. return Array.from(txt.matchAll(/^ "([^"]+)": \["(@opentui\/(?:core(?:-[^@"]+)?|keymap|solid))@([^"]+)"/gm))
  164. .map((match) => ({
  165. entry: match[1]!,
  166. pkg: match[2]!,
  167. version: match[3]!,
  168. }))
  169. .filter((item) => item.version !== ver)
  170. }